diff --git a/changelog.d/2276-memory-panel-readonly.fixed.md b/changelog.d/2276-memory-panel-readonly.fixed.md new file mode 100644 index 00000000..3c26b822 --- /dev/null +++ b/changelog.d/2276-memory-panel-readonly.fixed.md @@ -0,0 +1 @@ +In a read-only window a Bot's Memory panel no longer draws the **Remove** button or the **Ask or update** form the host would refuse, and says why in the host's own sentence, while the entries and **Regenerate summary** keep working ([#2276](https://github.com/ThinkFlowLab/FlyBot/issues/2276)). diff --git a/changelog.d/2279-details-bot-settings-readonly.fixed.md b/changelog.d/2279-details-bot-settings-readonly.fixed.md new file mode 100644 index 00000000..dace4950 --- /dev/null +++ b/changelog.d/2279-details-bot-settings-readonly.fixed.md @@ -0,0 +1 @@ +In a read-only window a Bot's details drawer no longer draws the Settings form, which the host would refuse, and says why in the host's own sentence instead; a writable window keeps the form unchanged ([#2279](https://github.com/ThinkFlowLab/FlyBot/issues/2279)). diff --git a/changelog.d/2283-questions-readonly.fixed.md b/changelog.d/2283-questions-readonly.fixed.md new file mode 100644 index 00000000..c6b4ae13 --- /dev/null +++ b/changelog.d/2283-questions-readonly.fixed.md @@ -0,0 +1 @@ +In a read-only window the chat's `ask_user` card no longer draws an answer control, and the answers a card already holds stay readable ([#2283](https://github.com/ThinkFlowLab/FlyBot/issues/2283)). diff --git a/core/client.js b/core/client.js index 0156c32d..73c42031 100644 --- a/core/client.js +++ b/core/client.js @@ -5411,7188 +5411,7190 @@ function FlyBotMark({ size = 24 }) { ); } -// src/client/questions.js +// src/client/schedules.js var import_react6 = require("react"); -var keyLetter = (index) => String.fromCharCode(65 + index); -var optionsOf = (question) => (question?.options ?? []).map((option) => typeof option === "string" ? { label: option } : option); -var isTypingTarget = (node) => node instanceof HTMLInputElement || node instanceof HTMLTextAreaElement || node instanceof HTMLSelectElement || node instanceof HTMLElement && node.isContentEditable; -function useOptionKeys(count, onPick, ownRef) { - const pick2 = (0, import_react6.useRef)(onPick); - pick2.current = onPick; +var POLL_MS2 = 3e4; +var shared = null; +var listeners = /* @__PURE__ */ new Set(); +function publish(view) { + shared = view; + for (const listener of listeners) listener(view); +} +function publishFailure(error) { + publish({ available: false, tasks: [], readFailure: hostText(error?.message ?? String(error)) }); +} +function useSchedules(actions) { + const [view, setView] = (0, import_react6.useState)(shared); (0, import_react6.useEffect)(() => { - if (count === 0) return void 0; - const onKey = (event) => { - if (event.defaultPrevented || event.metaKey || event.ctrlKey || event.altKey || event.shiftKey || event.key.length !== 1) return; - if (isTypingTarget(event.target) || isTypingTarget(document.activeElement)) return; - if (document.querySelector('[role="dialog"], [aria-modal="true"]') !== null) return; - const index = event.key.toUpperCase().charCodeAt(0) - 65; - if (index >= 0 && index < count) { - event.preventDefault(); - pick2.current(index); - } else if (index === count && ownRef.current) { - event.preventDefault(); - ownRef.current.focus(); - } + listeners.add(setView); + let stopped = false; + let timer; + const load = () => actions.schedules?.().then((next) => { + if (!stopped) publish(next); + }).catch((error) => { + if (!stopped) publishFailure(error); + }).finally(() => { + if (!stopped) timer = setTimeout(load, POLL_MS2); + }); + load(); + return () => { + stopped = true; + clearTimeout(timer); + listeners.delete(setView); }; - document.addEventListener("keydown", onKey, true); - return () => document.removeEventListener("keydown", onKey, true); - }, [count, ownRef]); + }, []); + return view; } -function QuestionView({ question, onAnswer, onDismiss, ownAnswer = false, raised = false }) { - const [own, setOwn] = (0, import_react6.useState)(""); - const [picked, setPicked] = (0, import_react6.useState)([]); - const ownRef = (0, import_react6.useRef)(null); - const titleId = (0, import_react6.useId)(); - const options = optionsOf(question); - const multi = question.multiSelect === true; - const custom = own.trim(); - const canSubmit = multi ? picked.length > 0 || custom !== "" : custom !== ""; - const choose = (label) => { - if (multi) { - setPicked((current) => current.includes(label) ? current.filter((item) => item !== label) : [...current, label]); - return; +var schedulesReadFailure = (failure) => typeof failure === "string" && failure !== "" ? t("The scheduled tasks could not be read: {reason}", { reason: failure }) : null; +var localZone = () => Intl.DateTimeFormat().resolvedOptions().timeZone; +var clock2 = (time) => String(time ?? "").slice(0, 5); +var when = (ms, options) => new Intl.DateTimeFormat(dateLocale(), options).format(ms); +function dayAndTime(ms) { + const sameYear = new Date(ms).getFullYear() === (/* @__PURE__ */ new Date()).getFullYear(); + return when(ms, { ...sameYear ? {} : { year: "numeric" }, month: "short", day: "numeric", hour: "2-digit", minute: "2-digit" }); +} +function instant(value) { + const at = Date.parse(value); + return Number.isFinite(at) ? dayAndTime(at) : ""; +} +function weekdayNames(days) { + const names = days.map((day) => when(Date.UTC(2024, 0, day), { weekday: "short", timeZone: "UTC" })); + return names.join(dateLocale().startsWith("zh") ? "、" : ", "); +} +function everyText(seconds) { + if (seconds % 86400 === 0) return seconds === 86400 ? t("Every day") : t("Every {count} days", { count: seconds / 86400 }); + if (seconds % 3600 === 0) return seconds === 3600 ? t("Every hour") : t("Every {count} hours", { count: seconds / 3600 }); + if (seconds % 60 === 0) return t("Every {count} minutes", { count: seconds / 60 }); + return t("Every {count} seconds", { count: seconds }); +} +function timingText(task) { + const zone = task.timeZone && task.timeZone !== localZone() ? ` (${task.timeZone})` : ""; + switch (task.kind) { + case "after": + case "at": { + const time = instant(task.scheduledAt); + return time === "" ? "" : t("Once, {time}", { time }); } - onAnswer({ selected: [label] }); - }; - const submit = () => { - if (!canSubmit) return; - onAnswer({ selected: multi ? picked : [], ...custom ? { custom } : {} }); - }; - useOptionKeys(options.length, (index) => choose(options[index].label), ownRef); - const ownLetter = options.length > 0 ? keyLetter(options.length) : void 0; - const field = ownAnswer ? (0, import_react6.createElement)("textarea", { - ref: ownRef, - rows: 1, - value: own, - autoComplete: "off", - spellCheck: false, - className: "bt-q-input", - "aria-label": t("Your own answer"), - placeholder: t("Type your own answer"), - "aria-keyshortcuts": ownLetter?.toLowerCase(), - onChange: (event) => setOwn(event.target.value), - onKeyDown: (event) => { - if (event.key !== "Enter" || event.shiftKey || event.nativeEvent.isComposing) return; - event.preventDefault(); - submit(); + case "every": + return everyText(task.everySeconds); + case "daily": + return `${t("Every day at {time}", { time: clock2(task.time) })}${zone}`; + case "weekly": { + const days = (task.weekdays ?? []).join(); + const text2 = days === "1,2,3,4,5" ? t("Every weekday at {time}", { time: clock2(task.time) }) : days === "6,7" ? t("Every weekend at {time}", { time: clock2(task.time) }) : days === "1,2,3,4,5,6,7" ? t("Every day at {time}", { time: clock2(task.time) }) : t("Every {days} at {time}", { days: weekdayNames(task.weekdays ?? []), time: clock2(task.time) }); + return `${text2}${zone}`; } - }) : null; - const rows = options.map((option, optionIndex) => { - const selected = multi && picked.includes(option.label); - return (0, import_react6.createElement)( - "button", - { - key: option.label, - type: "button", - className: "bt-q-opt", - "aria-pressed": multi ? selected : void 0, - "aria-keyshortcuts": keyLetter(optionIndex).toLowerCase(), - onClick: () => choose(option.label) - }, - (0, import_react6.createElement)("span", { className: "bt-key", "aria-hidden": true }, keyLetter(optionIndex)), - (0, import_react6.createElement)( - "span", - { className: "bt-q-body" }, - (0, import_react6.createElement)("span", { className: "bt-q-label" }, option.label), - option.description ? (0, import_react6.createElement)("span", { className: "bt-q-desc" }, option.description) : null - ), - selected && !raised ? (0, import_react6.createElement)("span", { className: "bt-q-check", title: t("Selected") }, (0, import_react6.createElement)(CheckIcon)) : null - ); - }); - if (raised && field) { - rows.push((0, import_react6.createElement)( - "label", - { key: "own", className: "bt-q-opt bt-q-ownrow", "data-filled": custom !== "" ? "" : void 0 }, - ownLetter ? (0, import_react6.createElement)("span", { className: "bt-key", "aria-hidden": true }, ownLetter) : null, - (0, import_react6.createElement)("span", { className: "bt-q-body" }, field) - )); - } - let foot = null; - if (raised && multi) { - foot = (0, import_react6.createElement)( - "div", - { className: "bt-q-foot" }, - (0, import_react6.createElement)("button", { type: "button", className: "bt-q-btn", onClick: onDismiss }, t("Skip")), - (0, import_react6.createElement)("button", { type: "submit", className: "bt-q-submit", disabled: !canSubmit }, t("Submit")) - ); - } else if ((raised || multi) && canSubmit) { - foot = (0, import_react6.createElement)("div", { className: "bt-q-foot" }, (0, import_react6.createElement)("button", { type: "submit", className: "bt-q-submit" }, t("Submit"))); + case "cron": + return `${t("On the schedule {expression}", { expression: task.expression })}${zone}`; + default: + return ""; } - return (0, import_react6.createElement)( - "form", - { - className: raised ? "bt-q bt-q-raised" : "bt-q", - "aria-labelledby": titleId, - onSubmit: (event) => { - event.preventDefault(); - submit(); - } - }, - (0, import_react6.createElement)( - "div", - { className: "bt-q-head" }, - (0, import_react6.createElement)( - "div", - { className: "bt-q-text" }, - (0, import_react6.createElement)("p", { className: "bt-q-title", id: titleId }, question.header ? `${question.header} · ` : "", question.question), - question.detail ? (0, import_react6.createElement)("div", { className: "bt-q-detail" }, question.detail) : null, - raised && multi ? (0, import_react6.createElement)("div", { className: "bt-q-detail" }, t("Choose all that apply")) : null - ), - (0, import_react6.createElement)("button", { type: "button", className: "bt-q-x", "aria-label": t("Dismiss question"), title: t("Dismiss"), onClick: onDismiss }, (0, import_react6.createElement)(CloseIcon)) - ), - rows.length > 0 ? (0, import_react6.createElement)("div", { className: "bt-q-list", role: "group" }, rows) : null, - field && !raised ? (0, import_react6.createElement)( - "div", - { className: "bt-q-custom" }, - (0, import_react6.createElement)("label", { className: "bt-q-field" }, field), - !multi && canSubmit ? (0, import_react6.createElement)("button", { type: "submit", className: "bt-q-submit" }, t("Submit")) : null - ) : null, - foot - ); } -function SettledQuestion({ question, answer }) { - const options = optionsOf(question); - const prompt = question?.question ?? ""; - const chosen = [...answer.selected ?? [], ...answer.custom ? [answer.custom] : []]; - if (chosen.length === 0) { - return (0, import_react6.createElement)( - "div", - { className: "bt-q bt-q-settled bt-q-dismissed", role: "group", "aria-label": prompt }, - (0, import_react6.createElement)( - "div", - { className: "bt-q-head" }, - (0, import_react6.createElement)("div", { className: "bt-q-text" }, (0, import_react6.createElement)("p", { className: "bt-q-title" }, prompt)), - (0, import_react6.createElement)("span", { className: "bt-q-badge" }, t("Dismissed")) - ) - ); +var nextText = (task) => { + if (task.status === "paused") return t("Paused"); + if (task.status !== "active") return t("Finished"); + const at = instant(task.scheduledAt); + return at === "" ? timingText(task) : t("Next: {time}", { time: at }); +}; +function lastRunText(run) { + if (run === void 0 || !Number.isFinite(run.at)) return ""; + const time = dayAndTime(run.at); + switch (run.result) { + case "running": + return t("Running since {time}", { time }); + case "stopped": + return t("Last run {time} · Stopped", { time }); + case "failed": + return t("Last run {time} · Failed: {reason}", { time, reason: hostText(run.reason || "Unknown error") }); + default: + return t("Last run {time}", { time }); } - return (0, import_react6.createElement)( - "div", - { className: "bt-q bt-q-settled", role: "group", "aria-label": prompt }, - prompt ? (0, import_react6.createElement)("div", { className: "bt-q-head" }, (0, import_react6.createElement)("div", { className: "bt-q-text" }, (0, import_react6.createElement)("p", { className: "bt-q-title" }, prompt))) : null, - (0, import_react6.createElement)("div", { className: "bt-q-list", role: "group", "aria-label": t("Your answer") }, chosen.map((label, index) => { - const optionIndex = options.findIndex((option) => option.label === label); - return (0, import_react6.createElement)( - "div", - { key: `${label}-${index}`, className: "bt-q-opt" }, - optionIndex >= 0 ? (0, import_react6.createElement)("span", { className: "bt-key", "aria-hidden": true }, keyLetter(optionIndex)) : null, - (0, import_react6.createElement)("span", { className: "bt-q-body" }, (0, import_react6.createElement)("span", { className: "bt-q-label" }, label)), - (0, import_react6.createElement)("span", { className: "bt-q-check", title: t("Selected") }, (0, import_react6.createElement)(CheckIcon)) - ); - })) - ); } -function QuestionCard({ matched }) { - const pending = matched; - const questions = pending.questions ?? []; - const [index, setIndex] = (0, import_react6.useState)(0); - const [answers, setAnswers] = (0, import_react6.useState)([]); - const question = questions[index]; - if (question === void 0) return null; - const onAnswer = (answer) => { - const next = [...answers, { id: question.id, ...answer }]; - if (index + 1 < questions.length) { - setAnswers(next); - setIndex(index + 1); +var lockedWindow = (roster) => typeof roster?.readOnly === "string" && roster.readOnly.trim() !== ""; +function TaskRow({ task, actions, onView, onEdit, onCopy, showBot, roster, locked }) { + const [confirming, setConfirming] = (0, import_react6.useState)(false); + const [busy, setBusy] = (0, import_react6.useState)(false); + const [rowError, setRowError] = (0, import_react6.useState)(""); + const refuse = (error) => setRowError(hostText(error?.message ?? String(error))); + (0, import_react6.useEffect)(() => { + if (!confirming) return void 0; + const timer = setTimeout(() => setConfirming(false), 3e3); + return () => clearTimeout(timer); + }, [confirming]); + const remove = () => { + if (!confirming) { + setConfirming(true); return; } - pending.answer({ answers: next }); - }; - return (0, import_react6.createElement)(QuestionView, { key: index, question, onAnswer, onDismiss: () => pending.dismiss(), ownAnswer: true, raised: true }); -} -function PendingQuestion({ sessionId, question, actions, inline }) { - const [answered, setAnswered] = (0, import_react6.useState)(null); - if (answered === question.id) return null; - const onAnswer = (answer) => { - setAnswered(question.id); - void actions.answerQuestion(sessionId, question.id, { selected: answer.selected ?? [], custom: answer.custom ?? "" }).catch(() => setAnswered(null)); + setBusy(true); + setRowError(""); + void actions.scheduleDelete(task.sessionId, task.id).then((next) => { + setBusy(false); + setRowError(""); + onView(next); + }, (error) => { + setBusy(false); + refuse(error); + }); }; - const onDismiss = () => { - setAnswered(question.id); - void actions.dismissQuestion(sessionId); + const toggle = () => { + setBusy(true); + setRowError(""); + const step = task.status === "paused" ? actions.scheduleResume : actions.schedulePause; + void step(task.sessionId, task.id).then((next) => { + setBusy(false); + setRowError(""); + onView(next); + }, (error) => { + setBusy(false); + refuse(error); + }); }; + const retry = () => { + setBusy(true); + setRowError(""); + void actions.scheduleRetry(task.sessionId, task.id).then((next) => { + setBusy(false); + onView(next); + }, (error) => { + setBusy(false); + refuse(error); + }); + }; + const room2 = task.roomId ? roster.roomsById[task.roomId] : void 0; + const stoppable = task.status === "active" || task.status === "paused"; + const failed = task.lastRun?.result === "failed"; + const last = lastRunText(task.lastRun); return (0, import_react6.createElement)( "div", - { className: inline ? "bt-q-inline" : "bt-q-dock" }, - (0, import_react6.createElement)(QuestionView, { key: question.id, question, onAnswer, onDismiss, ownAnswer: question.allowCustom === true, raised: !inline }) + { className: "bt-set-row bt-task", "data-done": stoppable ? void 0 : "", "data-paused": task.status === "paused" || void 0, "data-busy": busy || void 0 }, + showBot ? (0, import_react6.createElement)(BotAvatar, { bot: roster.byId[task.botId], size: 28, badge: false, live: false }) : null, + (0, import_react6.createElement)( + "span", + { className: "bt-set-copy" }, + (0, import_react6.createElement)("span", { className: "bt-task-title" }, task.title), + (0, import_react6.createElement)("span", { className: "bt-set-hint" }, [timingText(task), room2 ? t("in {name}", { name: room2.name }) : ""].filter(Boolean).join(" · ")), + task.prompt && task.prompt !== task.title ? (0, import_react6.createElement)("span", { className: "bt-task-prompt" }, task.prompt) : null, + last ? (0, import_react6.createElement)("span", { className: "bt-task-last", "data-failed": failed || void 0 }, last) : null, + rowError ? (0, import_react6.createElement)("span", { className: "bt-task-last", "data-failed": "", role: "alert" }, rowError) : null + ), + (0, import_react6.createElement)("span", { className: "bt-task-next" }, nextText(task)), + // A locked window draws none of the writes the host would refuse (#2232). Hiding, not + // disabling: a disabled button still says this window can do it. + !locked && failed && task.status !== "paused" && actions.scheduleRetry ? (0, import_react6.createElement)("button", { type: "button", className: "bt-mini bt-task-pause", disabled: busy, title: t("Run the task once now"), onClick: retry }, t("Retry")) : null, + !locked && onEdit ? (0, import_react6.createElement)("button", { type: "button", className: "bt-mini bt-task-tool", disabled: busy, "aria-label": t("Edit"), title: t("Edit"), onClick: () => onEdit(task) }, (0, import_react6.createElement)(PencilIcon)) : null, + !locked && onCopy ? (0, import_react6.createElement)("button", { type: "button", className: "bt-mini bt-task-tool", disabled: busy, "aria-label": t("Copy to another Bot or group chat"), title: t("Copy to…"), onClick: () => onCopy(task) }, (0, import_react6.createElement)(DuplicateIcon)) : null, + !locked && stoppable ? (0, import_react6.createElement)("button", { type: "button", className: "bt-mini bt-task-pause", disabled: busy, onClick: toggle }, task.status === "paused" ? t("Resume") : t("Pause")) : null, + locked ? null : (0, import_react6.createElement)("button", { + type: "button", + className: "bt-mini bt-task-remove", + "data-confirm": confirming || void 0, + disabled: busy, + "aria-label": confirming ? t("Click again to delete") : t("Delete task"), + title: confirming ? void 0 : t("Delete task"), + onClick: remove + }, confirming ? t("Delete") : (0, import_react6.createElement)(TrashIcon)) ); } -function QuestionDock({ sessionId, useRoster, useCards, useSession, actions }) { - const question = useRoster((value) => value.questions?.[sessionId]); - const drawn = useCards?.((state) => state[sessionId]?.[question?.callId] > 0) === true; - const loading = useSession?.(historyLoading) === true; - if (question === void 0 || question.callId && (drawn || loading)) return null; - return (0, import_react6.createElement)(PendingQuestion, { sessionId, question, actions, inline: false }); -} -function AnsweredCell({ node }) { - const data = node.data ?? {}; - const questions = Array.isArray(data.questions) ? data.questions : []; - if (questions.length === 0) { - return data.text ? (0, import_react6.createElement)("div", { className: "bt-urow" }, (0, import_react6.createElement)("div", { className: "bt-ububble" }, data.text)) : null; - } - const answers = Array.isArray(data.answers) ? data.answers : []; - return (0, import_react6.createElement)("div", { className: "bt-q-inline" }, questions.map((question) => (0, import_react6.createElement)(SettledQuestion, { - key: question.id, - question, - answer: answers.find((answer) => answer.id === question.id) ?? { selected: [] } - }))); -} - -// src/client/secret-card.js -var import_react7 = require("react"); -var MIN_VALUE = 4; -var NONE = {}; -var KeyIcon = () => (0, import_react7.createElement)( - "svg", - { width: 15, height: 15, viewBox: "0 0 20 20", fill: "none", stroke: "currentColor", strokeWidth: 1.5, strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": true }, - (0, import_react7.createElement)("path", { d: "M7 9.5a3.5 3.5 0 1 1 0 .01z" }), - (0, import_react7.createElement)("path", { d: "M10.4 10.6L17 10.6M14.5 10.6v2.4M16.6 10.6v1.8" }) -); -var LockIcon = () => (0, import_react7.createElement)( - "svg", - { width: 14, height: 14, viewBox: "0 0 20 20", fill: "none", stroke: "currentColor", strokeWidth: 1.5, strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": true }, - (0, import_react7.createElement)("path", { d: "M6.5 9V7a3.5 3.5 0 0 1 7 0v2" }), - (0, import_react7.createElement)("rect", { x: 4.5, y: 9, width: 11, height: 8, rx: 2 }) -); -var WarnIcon = () => (0, import_react7.createElement)( - "svg", - { width: 14, height: 14, viewBox: "0 0 20 20", fill: "none", stroke: "currentColor", strokeWidth: 1.5, strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": true }, - (0, import_react7.createElement)("path", { d: "M10 3.5l7 12.5H3z" }), - (0, import_react7.createElement)("path", { d: "M10 8.5v3.5M10 14.2h.01" }) -); -var MASK_BY_CSS = typeof CSS !== "undefined" && typeof CSS.supports === "function" && CSS.supports("-webkit-text-security", "disc"); -var insecure = () => typeof location !== "undefined" && location.protocol === "http:" && !/^(localhost|127(?:\.\d{1,3}){3}|\[::1\])$/.test(location.hostname); -var scopeLabel = (scope, roster) => { - if (scope === "all") return t("All my Bots"); - const names = (scope ?? []).map((id) => roster.byId[id]?.name).filter(Boolean); - return names.length === 0 ? t("No Bot") : names.join(t(" and ")); -}; -var when = (time) => Number.isFinite(time) ? new Date(time).toLocaleString(dateLocale(), { month: "short", day: "numeric", hour: "2-digit", minute: "2-digit" }) : ""; -function MaskedInput({ value, onChange, onEnter, label, autoFocus = false }) { - return (0, import_react7.createElement)("input", { - className: "bt-secret-input", - type: MASK_BY_CSS ? "text" : "password", - "data-masked": MASK_BY_CSS ? "" : void 0, - value, - autoFocus, - autoComplete: MASK_BY_CSS ? "off" : "new-password", - autoCorrect: "off", - autoCapitalize: "off", - spellCheck: false, - "data-1p-ignore": "", - "data-lpignore": "true", - "data-bwignore": "", - "data-form-type": "other", - "aria-label": label, - placeholder: t("Paste or type the key"), - onChange: (event) => onChange(event.target.value), - onKeyDown: (event) => { - if (event.key !== "Enter" || event.nativeEvent.isComposing) return; - event.preventDefault(); - onEnter?.(); - } - }); +var pad = (number) => String(number).padStart(2, "0"); +function inAnHour() { + const date = new Date(Date.now() + 36e5); + date.setMinutes(0, 0, 0); + return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:00`; } -function ScopeSwitch({ value, onChange, bot }) { - const options = [["all", t("All my Bots")], ["bot", t("Only {name}", { name: bot.name })]]; - return (0, import_react7.createElement)( - "div", - { className: "bt-secret-scope", role: "radiogroup", "aria-label": t("Who may use it") }, - (0, import_react7.createElement)("span", { className: "bt-secret-glide", "data-at": value === "all" ? 0 : 1, "aria-hidden": true }), - options.map(([id, text2]) => (0, import_react7.createElement)("button", { - key: id, - type: "button", - role: "radio", - "aria-checked": value === id, - className: "bt-secret-seg", - onClick: () => onChange(id) - }, text2)) - ); +var KINDS = [["at", "Once"], ["daily", "Every day"], ["weekly", "Every week"], ["every", "Repeat"]]; +var UNITS = [["seconds", 1, "seconds"], ["minutes", 60, "minutes"], ["hours", 3600, "hours"], ["days", 86400, "days"]]; +function localInput(ms) { + const date = new Date(ms); + return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}`; } -function SecretHead({ bot, title, detail, name, onClose }) { - return (0, import_react7.createElement)( - "div", - { className: "bt-q-head bt-secret-head" }, - (0, import_react7.createElement)("span", { className: "bt-secret-who" }, (0, import_react7.createElement)(BotMark, { bot, size: 28 }), (0, import_react7.createElement)("span", { className: "bt-secret-lock" }, (0, import_react7.createElement)(LockIcon))), - (0, import_react7.createElement)( - "div", - { className: "bt-q-text" }, - (0, import_react7.createElement)("p", { className: "bt-q-title" }, title), - (0, import_react7.createElement)("div", { className: "bt-q-detail" }, (0, import_react7.createElement)("code", { className: "bt-secret-name" }, name), detail ? ` · ${detail}` : "") - ), - onClose ? (0, import_react7.createElement)("button", { type: "button", className: "bt-q-x", "aria-label": t("Cancel the request"), title: t("Cancel"), onClick: onClose }, (0, import_react7.createElement)(CloseIcon)) : null - ); +function formStart(task) { + const seconds = task?.kind === "every" ? Number(task.everySeconds) : 3600; + const [unit, size] = seconds % 86400 === 0 ? ["days", 86400] : seconds % 3600 === 0 ? ["hours", 3600] : seconds % 60 === 0 ? ["minutes", 60] : ["seconds", 1]; + const once = task?.kind === "at" || task?.kind === "after"; + return { + title: task?.title ?? "", + prompt: task?.prompt ?? "", + kind: once ? "at" : task?.kind ?? "daily", + at: once && !Number.isNaN(Date.parse(task.scheduledAt)) ? localInput(Date.parse(task.scheduledAt)) : inAnHour(), + time: task?.time ? clock2(task.time) : "09:00", + weekdays: task?.kind === "weekly" && Array.isArray(task.weekdays) ? task.weekdays : [1, 2, 3, 4, 5], + count: Math.max(1, Math.round(seconds / size)), + unit, + timeZone: task?.timeZone || localZone() + }; } -function SecretCard({ bot, request, roster, actions }) { - const [value, setValue] = (0, import_react7.useState)(""); - const [scope, setScope] = (0, import_react7.useState)("all"); - const [replacing, setReplacing] = (0, import_react7.useState)(false); - const [busy, setBusy] = (0, import_react7.useState)(false); - const [error, setError] = (0, import_react7.useState)(""); - const noteId = (0, import_react7.useId)(); - const secret = (roster.secrets ?? []).find((entry) => entry.name === request.name); - const run = (promise) => { +function TaskForm({ roster, actions, start, task, onDone, onCancel }) { + const first = formStart(task); + const [target, setTarget] = (0, import_react6.useState)(task ? task.botId ?? task.roomId : start ?? ""); + const [title, setTitle] = (0, import_react6.useState)(first.title); + const [prompt, setPrompt] = (0, import_react6.useState)(first.prompt); + const [kind, setKind] = (0, import_react6.useState)(first.kind); + const [at, setAt] = (0, import_react6.useState)(first.at); + const [time, setTime] = (0, import_react6.useState)(first.time); + const [weekdays, setWeekdays] = (0, import_react6.useState)(first.weekdays); + const [count, setCount] = (0, import_react6.useState)(first.count); + const [unit, setUnit] = (0, import_react6.useState)(first.unit); + const [busy, setBusy] = (0, import_react6.useState)(false); + const [error, setError] = (0, import_react6.useState)(""); + const timing = () => { + if (kind === "cron") return { kind: "keep" }; + if (kind === "at") return { kind, at: new Date(at).toISOString(), timeZone: first.timeZone }; + if (kind === "every") return { kind, seconds: Math.round(Number(count) * UNITS.find(([id]) => id === unit)[1]) }; + return { kind, time, timeZone: first.timeZone, ...kind === "weekly" ? { weekdays } : {} }; + }; + const submit = (event) => { + event.preventDefault(); + if (busy || target === "") return; setBusy(true); setError(""); - return Promise.resolve(promise).then(() => setValue(""), (failure) => setError(failure?.message ?? String(failure))).finally(() => setBusy(false)); - }; - const cancel = () => run(actions.secretCancel(request.id)); - const long = value.trim().length >= MIN_VALUE; - const save = () => { - if (long && !busy) void run(actions.secretSet({ requestId: request.id, value, scope })); - }; - const allow = () => { - if (!busy) void run(actions.secretAllow(request.id)); + let when3; + try { + when3 = timing(); + } catch { + setError(t("Pick a date and time")); + setBusy(false); + return; + } + const saved = task ? actions.scheduleUpdate({ sessionId: task.sessionId, id: task.id, target, title, prompt, when: when3 }) : actions.scheduleCreate({ target, title, prompt, when: when3 }); + void saved.then(onDone, (failure) => { + setError(hostText(failure?.message ?? String(failure))); + setBusy(false); + }); }; - const filling = request.kind === "fill" || replacing; - const warn = insecure() ? (0, import_react7.createElement)("div", { className: "bt-secret-warn", role: "note" }, (0, import_react7.createElement)(WarnIcon), t("This page is not on HTTPS: the key crosses the network unencrypted.")) : null; - return (0, import_react7.createElement)( - "div", - { className: "bt-q bt-secret", role: "group", "aria-describedby": noteId, "data-kind": request.kind }, - (0, import_react7.createElement)(SecretHead, { - bot, - title: request.kind === "allow" ? t("Let {bot} use {name}?", { bot: bot.name, name: request.name }) : t("{name} needs a key", { name: bot.name }), - detail: request.purpose, - name: request.name, - onClose: busy ? void 0 : cancel - }), - request.kind === "allow" ? (0, import_react7.createElement)("div", { className: "bt-secret-saved" }, (0, import_react7.createElement)(KeyIcon), t("Saved for {scope}", { scope: scopeLabel(secret?.scope, roster) })) : null, - filling ? (0, import_react7.createElement)( - "div", - { className: "bt-secret-body" }, - (0, import_react7.createElement)("label", { className: "bt-q-field bt-secret-field" }, (0, import_react7.createElement)(MaskedInput, { value, onChange: setValue, onEnter: save, label: t("Value of {name}", { name: request.name }), autoFocus: replacing })), - request.kind === "fill" ? (0, import_react7.createElement)(ScopeSwitch, { value: scope, onChange: setScope, bot }) : null - ) : null, - (0, import_react7.createElement)( + const toggleDay = (day) => setWeekdays((days) => days.includes(day) ? days.filter((item) => item !== day) : [...days, day].sort()); + return (0, import_react6.createElement)( + "form", + { className: "bt-set-card bt-task-form", onSubmit: submit }, + (0, import_react6.createElement)( + "label", + { className: "bt-set-row bt-bs-row" }, + (0, import_react6.createElement)("span", { className: "bt-set-copy" }, (0, import_react6.createElement)("span", { className: "bt-bs-label" }, t("Send to"))), + (0, import_react6.createElement)( + "select", + { className: "bt-select bt-task-bot", value: target, onChange: (event) => setTarget(event.target.value) }, + (0, import_react6.createElement)("option", { value: "" }, t("Pick a Bot or group chat")), + (0, import_react6.createElement)("optgroup", { label: t("Bots") }, roster.bots.map((bot) => (0, import_react6.createElement)("option", { key: bot.id, value: bot.id }, bot.name))), + roster.rooms.length > 0 ? (0, import_react6.createElement)("optgroup", { label: t("Group chats") }, roster.rooms.map((room2) => (0, import_react6.createElement)("option", { key: room2.id, value: room2.id }, room2.name))) : null + ) + ), + (0, import_react6.createElement)( + "label", + { className: "bt-set-row bt-bs-row" }, + (0, import_react6.createElement)("span", { className: "bt-set-copy" }, (0, import_react6.createElement)("span", { className: "bt-bs-label" }, t("Name"))), + (0, import_react6.createElement)("input", { className: "bt-bs-input", value: title, maxLength: 120, placeholder: t("Morning briefing"), autoFocus: true, onChange: (event) => setTitle(event.target.value) }) + ), + (0, import_react6.createElement)( + "label", + { className: "bt-set-row bt-bs-row bt-bs-stack" }, + (0, import_react6.createElement)("span", { className: "bt-set-copy" }, (0, import_react6.createElement)("span", { className: "bt-bs-label" }, t("What to do"))), + (0, import_react6.createElement)("textarea", { className: "bt-bs-input bt-bs-area", value: prompt, rows: 3, placeholder: t("Sum up what changed since yesterday and what needs me today."), onChange: (event) => setPrompt(event.target.value) }) + ), + (0, import_react6.createElement)( "div", - { className: "bt-secret-note", id: noteId }, - t("{bot} never sees the value. It reaches {bot}'s shell as ", { bot: bot.name }), - (0, import_react7.createElement)("code", null, `$DSH_SECRET_${request.name}`), - ".", - filling && value !== "" && !long ? t(" At least {count} characters.", { count: MIN_VALUE }) : "" + { className: "bt-set-row bt-bs-row bt-bs-stack" }, + (0, import_react6.createElement)("span", { className: "bt-set-copy" }, (0, import_react6.createElement)("span", { className: "bt-bs-label" }, t("When"))), + (0, import_react6.createElement)(Segmented, { label: t("When"), value: kind }, KINDS.map(([id, label]) => (0, import_react6.createElement)("button", { key: id, type: "button", "aria-pressed": kind === id, onClick: () => setKind(id) }, t(label)))), + (0, import_react6.createElement)( + "div", + { key: kind, className: "bt-task-when bt-veil" }, + kind === "cron" ? (0, import_react6.createElement)("span", { className: "bt-task-every" }, timingText(task)) : null, + kind === "at" ? (0, import_react6.createElement)("input", { className: "bt-input", type: "datetime-local", value: at, "aria-label": t("Date and time"), onChange: (event) => setAt(event.target.value) }) : null, + kind === "weekly" ? (0, import_react6.createElement)("div", { className: "bt-task-days", role: "group", "aria-label": t("Days") }, [1, 2, 3, 4, 5, 6, 7].map((day) => (0, import_react6.createElement)("button", { + key: day, + type: "button", + className: "bt-task-day", + "aria-pressed": weekdays.includes(day), + onClick: () => toggleDay(day) + }, when(Date.UTC(2024, 0, day), { weekday: "narrow", timeZone: "UTC" })))) : null, + kind === "daily" || kind === "weekly" ? (0, import_react6.createElement)("input", { className: "bt-input bt-task-time", type: "time", value: time, "aria-label": t("Time"), onChange: (event) => setTime(event.target.value) }) : null, + kind === "every" ? (0, import_react6.createElement)( + import_react6.Fragment, + null, + (0, import_react6.createElement)("span", { className: "bt-task-every" }, t("Every")), + (0, import_react6.createElement)("input", { className: "bt-input bt-task-count", type: "number", min: 1, value: count, "aria-label": t("How many"), onChange: (event) => setCount(event.target.value) }), + (0, import_react6.createElement)( + "select", + { className: "bt-select bt-task-unit", value: unit, "aria-label": t("Unit"), onChange: (event) => setUnit(event.target.value) }, + UNITS.map(([id, , label]) => (0, import_react6.createElement)("option", { key: id, value: id }, t(label))) + ) + ) : null + ) ), - warn, - error ? (0, import_react7.createElement)("div", { className: "bt-secret-error", role: "alert" }, error) : null, - (0, import_react7.createElement)( + error ? (0, import_react6.createElement)("div", { className: "bt-set-row" }, (0, import_react6.createElement)("span", { className: "bt-danger bt-set-hint", role: "alert" }, error)) : null, + (0, import_react6.createElement)( "div", - { className: "bt-q-foot" }, - request.kind === "allow" && !replacing ? (0, import_react7.createElement)("button", { type: "button", className: "bt-q-btn", disabled: busy, onClick: () => setReplacing(true) }, t("Replace value")) : null, - (0, import_react7.createElement)("button", { type: "button", className: "bt-q-btn", disabled: busy, onClick: cancel }, t("Cancel")), - filling ? (0, import_react7.createElement)("button", { type: "button", className: "bt-q-submit", disabled: !long || busy, onClick: save }, busy ? t("Saving…") : t("Save")) : (0, import_react7.createElement)("button", { type: "button", className: "bt-q-submit", disabled: busy, onClick: allow }, busy ? t("Allowing…") : t("Allow")) + { className: "bt-set-row bt-task-foot" }, + (0, import_react6.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: onCancel }, t("Cancel")), + (0, import_react6.createElement)( + "button", + { type: "submit", className: "bt-send bt-soft-sm", disabled: busy || !target || title.trim() === "" || prompt.trim() === "" }, + task ? busy ? t("Saving…") : t("Save changes") : busy ? t("Creating…") : t("Create task") + ) ) ); } -function SettledSecret({ bot, request, roster }) { - const canceled = request.status === "canceled"; - const text2 = canceled ? t("{bot} asked for {name}", { bot: bot.name, name: request.name }) : request.status === "allowed" ? t("Allowed {bot} to use {name}", { bot: bot.name, name: request.name }) : t("Saved {name}", { name: request.name }); - return (0, import_react7.createElement)( - "div", - { className: "bt-q bt-q-settled bt-secret bt-secret-done", role: "group", "aria-label": text2, "data-status": request.status }, - (0, import_react7.createElement)( - "div", - { className: "bt-q-head" }, - (0, import_react7.createElement)("span", { className: "bt-secret-who" }, (0, import_react7.createElement)(BotMark, { bot, size: 22 })), - (0, import_react7.createElement)("div", { className: "bt-q-text" }, (0, import_react7.createElement)( - "p", - { className: "bt-q-title" }, - text2, - canceled ? null : (0, import_react7.createElement)("span", { className: "bt-secret-scope-tag" }, ` · ${scopeLabel(request.scope, roster)}`) - )), - canceled ? (0, import_react7.createElement)("span", { className: "bt-q-badge" }, t("Canceled")) : (0, import_react7.createElement)("span", { className: "bt-q-check bt-secret-check", title: t("Done") }, (0, import_react7.createElement)(CheckIcon)) - ) - ); +function openPlugins(actions) { + actions.closeSettings(); + actions.selectPanel("plugins"); } -function RequestView({ bot, request, roster, actions }) { - return request.status === "pending" ? (0, import_react7.createElement)(SecretCard, { key: `${request.id}:${request.kind}`, bot, request, roster, actions }) : (0, import_react7.createElement)(SettledSecret, { bot, request, roster }); -} -function SecretToolCell({ sessionId, callId, name, done, content, roster, actions }) { - const bot = roster.byId[sessionId]; - if (!bot) return null; - const request = (roster.secretRequests?.[bot.id] ?? []).find((entry) => entry.callId === callId && (entry.sessionId ?? sessionId) === sessionId); - if (request) return (0, import_react7.createElement)("div", { className: "bt-q-inline" }, (0, import_react7.createElement)(RequestView, { bot, request, roster, actions })); - if (done && /already saved/.test(contentText(content))) { - return (0, import_react7.createElement)("div", { className: "bt-event" }, (0, import_react7.createElement)("span", { className: "bt-secret-event" }, (0, import_react7.createElement)(KeyIcon), t("{bot} is using {name}", { bot: bot.name, name: name ?? t("a saved key") }))); - } - return null; -} -function SecretDock({ sessionId, useRoster, useCards, useSession, actions }) { - const roster = useRoster((value) => value); - const shown = useCards?.((state) => state[sessionId]) ?? NONE; - const loading = useSession?.(historyLoading) === true; - const bot = roster.byId[sessionId]; - if (!bot || currentPart(bot) !== sessionId) return null; - const drawn = (entry) => entry.callId && entry.sessionId === sessionId && (loading || shown[entry.callId] > 0); - const waiting = (roster.secretRequests?.[bot.id] ?? []).filter((entry) => entry.status === "pending" && !drawn(entry)); - if (waiting.length === 0) return null; - return (0, import_react7.createElement)("div", { className: "bt-q-dock bt-secret-dock" }, waiting.map((request) => (0, import_react7.createElement)(SecretCard, { key: `${request.id}:${request.kind}`, bot, request, roster, actions }))); -} -var scopeMode = (scope) => scope === "all" ? "all" : scope.length === 0 ? "none" : "some"; -function ScopeEditor({ secret, roster, onSave }) { - const [mode, setMode] = (0, import_react7.useState)(scopeMode(secret.scope)); - const [picked, setPicked] = (0, import_react7.useState)(secret.scope === "all" ? [] : secret.scope); - const toggle = (id) => setPicked((list) => list.includes(id) ? list.filter((item) => item !== id) : [...list, id]); - const next = mode === "all" ? "all" : mode === "none" ? [] : picked; - const same2 = next === "all" ? secret.scope === "all" : secret.scope !== "all" && [...next].sort().join() === [...secret.scope].sort().join(); - return (0, import_react7.createElement)( +function AutomationOff({ actions }) { + return (0, import_react6.createElement)( "div", - { className: "bt-secret-edit-block" }, - (0, import_react7.createElement)("div", { className: "bt-secret-edit-label" }, t("Who may use it")), - (0, import_react7.createElement)( - "div", - { className: "bt-secret-radios", role: "radiogroup", "aria-label": t("Who may use {name}", { name: secret.name }) }, - [["all", t("All my Bots")], ["some", t("Chosen Bots")], ["none", t("No Bot")]].map(([id, text2]) => (0, import_react7.createElement)( - "label", - { key: id, className: "bt-secret-radio" }, - (0, import_react7.createElement)("input", { type: "radio", name: `scope-${secret.name}`, checked: mode === id, onChange: () => setMode(id) }), - text2 - )) - ), - mode === "some" ? (0, import_react7.createElement)("div", { className: "bt-secret-bots" }, roster.bots.map((bot) => (0, import_react7.createElement)("button", { - key: bot.id, - type: "button", - className: "bt-secret-bot", - "aria-pressed": picked.includes(bot.id), - onClick: () => toggle(bot.id) - }, (0, import_react7.createElement)(BotAvatar, { bot, size: 20, badge: false, live: false }), bot.name, picked.includes(bot.id) ? (0, import_react7.createElement)(CheckIcon) : null))) : null, - (0, import_react7.createElement)( - "div", - { className: "bt-secret-row-actions" }, - (0, import_react7.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", disabled: same2, onClick: () => onSave(next) }, t("Save access")) - ) + { className: "bt-set-card bt-task-off" }, + (0, import_react6.createElement)("span", { className: "bt-task-off-mark" }, (0, import_react6.createElement)(ClockIcon)), + (0, import_react6.createElement)("span", { className: "bt-task-off-title" }, t("Turn on Automation tasks in DSH")), + (0, import_react6.createElement)("span", { className: "bt-set-hint" }, t("Switch on Automation tasks on the Plugins page.")), + (0, import_react6.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: () => openPlugins(actions) }, t("Open Plugins")) ); } -function ValueEditor({ secret, onSave }) { - const [value, setValue] = (0, import_react7.useState)(""); - const [replaced, setReplaced] = (0, import_react7.useState)(false); - const long = value.trim().length >= MIN_VALUE; - const save = () => { - if (!long) return; - void Promise.resolve(onSave(value)).then((ok) => { - if (ok) { - setValue(""); - setReplaced(true); - } +function QuietHoursCard({ view, actions, locked }) { + const quiet = view.quietHours ?? { on: false }; + const [busy, setBusy] = (0, import_react6.useState)(false); + const [error, setError] = (0, import_react6.useState)(""); + const [from, setFrom] = (0, import_react6.useState)(quiet.from ?? "22:00"); + const [to, setTo] = (0, import_react6.useState)(quiet.to ?? "07:00"); + const unchecked = view.quietUnchecked === true; + const refresh = () => { + void Promise.resolve(actions.schedules?.()).then((next) => { + if (next) publish(next); + }).catch(() => { }); }; - const edit = (next) => { - setValue(next); - setReplaced(false); + const save = (next) => { + if (busy) return; + setBusy(true); + setError(""); + const shown = next.on ? { on: true, from: next.from ?? from, to: next.to ?? to } : { on: false }; + void Promise.resolve(actions.setPrefs({ quietHours: next })).then(() => { + publish({ ...view, quietHours: shown }); + refresh(); + }, (failure) => setError(hostText(failure?.message ?? String(failure)))).finally(() => setBusy(false)); }; - return (0, import_react7.createElement)( - "div", - { className: "bt-secret-edit-block" }, - (0, import_react7.createElement)("div", { className: "bt-secret-edit-label" }, t("Replace the value")), - (0, import_react7.createElement)( + return (0, import_react6.createElement)( + "section", + { className: "bt-set-section" }, + (0, import_react6.createElement)("h3", null, t("Quiet hours")), + (0, import_react6.createElement)( "div", - { className: "bt-secret-inline" }, - (0, import_react7.createElement)("label", { className: "bt-q-field bt-secret-field" }, (0, import_react7.createElement)(MaskedInput, { value, onChange: edit, onEnter: save, label: t("New value of {name}", { name: secret.name }) })), - (0, import_react7.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", disabled: !long, onClick: save }, t("Replace")) - ), - value !== "" && !long ? (0, import_react7.createElement)("div", { className: "bt-set-hint" }, t("At least {count} characters.", { count: MIN_VALUE })) : replaced ? (0, import_react7.createElement)("div", { className: "bt-set-hint bt-secret-ok", role: "status" }, (0, import_react7.createElement)(CheckIcon), t("Replaced. Bots get the new value from their next command.")) : null + { className: "bt-set-card" }, + (0, import_react6.createElement)( + "div", + { className: "bt-set-row bt-bs-row" }, + (0, import_react6.createElement)( + "span", + { className: "bt-set-copy" }, + (0, import_react6.createElement)("span", { className: "bt-bs-label" }, quiet.on ? t("On, {from}–{to}", { from: quiet.from, to: quiet.to }) : t("Off")), + (0, import_react6.createElement)("span", { className: "bt-set-hint" }, t("A new or edited task cannot be due inside the window. It never mutes a task that already exists.")) + ), + locked ? null : (0, import_react6.createElement)("button", { + type: "button", + className: "bt-soft bt-soft-sm", + disabled: busy, + "aria-pressed": quiet.on, + onClick: () => save(quiet.on ? { on: false } : { on: true, from, to }) + }, quiet.on ? t("Turn off") : t("Turn on")) + ), + quiet.on && !locked ? (0, import_react6.createElement)( + "div", + { className: "bt-set-row bt-bs-row" }, + (0, import_react6.createElement)("span", { className: "bt-set-copy" }, (0, import_react6.createElement)("span", { className: "bt-bs-label" }, t("Window"))), + (0, import_react6.createElement)( + "div", + { className: "bt-task-when" }, + (0, import_react6.createElement)("input", { className: "bt-input bt-task-time", type: "time", value: from, "aria-label": t("Quiet hours start"), disabled: busy, onChange: (event) => setFrom(event.target.value) }), + (0, import_react6.createElement)("span", { className: "bt-task-every" }, "–"), + (0, import_react6.createElement)("input", { className: "bt-input bt-task-time", type: "time", value: to, "aria-label": t("Quiet hours end"), disabled: busy, onChange: (event) => setTo(event.target.value) }), + (0, import_react6.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", disabled: busy, onClick: () => save({ on: true, from, to }) }, t("Save window")) + ) + ) : null, + error ? (0, import_react6.createElement)("div", { className: "bt-set-row" }, (0, import_react6.createElement)("span", { className: "bt-danger bt-set-hint", role: "alert" }, error)) : null, + quiet.on && unchecked ? (0, import_react6.createElement)("div", { className: "bt-set-row" }, (0, import_react6.createElement)("span", { className: "bt-set-hint" }, t("A repeat or cron task can still run inside quiet hours, because DSH decides its next run. FlyBot says so here rather than claiming a silence it cannot keep."))) : null + ) ); } -function SecretRow({ secret, roster, actions, run, open, onToggle }) { - const [confirm, setConfirm] = (0, import_react7.useState)(false); - (0, import_react7.useEffect)(() => { - if (!open) setConfirm(false); - }, [open]); - const asker = secret.requestedBy ? roster.byId[secret.requestedBy]?.name ?? t("a deleted Bot") : t("you"); - const allowed = secret.scope === "all" ? [] : secret.scope.map((id) => roster.byId[id]).filter(Boolean); - const meta = [t("Asked by {name}", { name: asker }), t("added {time}", { time: when(secret.createdAt) }), secret.lastUsedAt ? t("last used {time}", { time: when(secret.lastUsedAt) }) : t("not used yet")].join(" · "); - return (0, import_react7.createElement)( - "div", - { className: "bt-secret-item", "data-open": open ? "" : void 0 }, - (0, import_react7.createElement)( - "button", - { type: "button", className: "bt-set-row bt-secret-summary", "aria-expanded": open, onClick: onToggle }, - (0, import_react7.createElement)( - "span", - { className: "bt-set-copy" }, - (0, import_react7.createElement)("span", { className: "bt-secret-title" }, (0, import_react7.createElement)(KeyIcon), (0, import_react7.createElement)("code", { className: "bt-secret-name" }, secret.name), secret.purpose ? (0, import_react7.createElement)("span", { className: "bt-secret-purpose" }, secret.purpose) : null), - (0, import_react7.createElement)("span", { className: "bt-set-hint" }, meta) - ), - (0, import_react7.createElement)( - "span", - { className: "bt-set-control" }, - secret.scope === "all" ? (0, import_react7.createElement)("span", { className: "bt-tag" }, t("All my Bots")) : allowed.length === 0 ? (0, import_react7.createElement)("span", { className: "bt-tag" }, t("No Bot")) : (0, import_react7.createElement)("span", { className: "bt-secret-faces", title: allowed.map((bot) => bot.name).join(", ") }, allowed.slice(0, 5).map((bot) => (0, import_react7.createElement)(BotAvatar, { key: bot.id, bot, size: 20, badge: false, live: false }))) - ) +function SchedulesPage({ roster, actions, start }) { + const view = useSchedules(actions); + const [adding, setAdding] = (0, import_react6.useState)(Boolean(start)); + const [editing, setEditing] = (0, import_react6.useState)(null); + const locked = lockedWindow(roster); + const refused = schedulesReadFailure(view?.readFailure); + if (refused !== null) return (0, import_react6.createElement)("div", { className: "bt-set-hint bt-danger", role: "status" }, refused); + if (view === null) return (0, import_react6.createElement)("div", { className: "bt-set-hint" }, t("Loading…")); + if (!view.available) return (0, import_react6.createElement)(AutomationOff, { actions }); + const shown = (next) => { + publish(next); + setAdding(false); + setEditing(null); + }; + const edit = (task) => { + setAdding(false); + setEditing(task); + }; + const byChat = [ + ...roster.bots.map((bot) => [bot.id, (0, import_react6.createElement)(BotAvatar, { bot, size: 18, badge: false, live: false }), bot.name, view.tasks.filter((task) => task.botId === bot.id)]), + ...roster.rooms.map((room2) => [room2.id, (0, import_react6.createElement)(RoomAvatar, { room: room2, roster, size: 18 }), room2.name, view.tasks.filter((task) => task.botId === void 0 && task.roomId === room2.id)]) + ].filter(([, , , tasks]) => tasks.length > 0); + return [ + (0, import_react6.createElement)(QuietHoursCard, { key: "quiet", view, actions, locked }), + // A locked window draws no create control and opens no form (#2232): the header already says + // why, and a button the host refuses is the defect this page had. + locked ? null : adding ? (0, import_react6.createElement)(TaskForm, { key: "form", roster, actions, start, onDone: shown, onCancel: () => setAdding(false) }) : (0, import_react6.createElement)("div", { key: "add", className: "bt-actions" }, (0, import_react6.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm bt-with-icon", onClick: () => { + setEditing(null); + setAdding(true); + } }, (0, import_react6.createElement)(PlusIcon), t("New task"))), + byChat.length === 0 && !adding ? (0, import_react6.createElement)("div", { key: "none", className: "bt-set-card" }, (0, import_react6.createElement)("div", { className: "bt-set-row" }, (0, import_react6.createElement)("span", { className: "bt-set-hint" }, view.someHidden ? t("No scheduled tasks you can see. Another member's tasks stay hidden.") : t("No scheduled tasks yet.")))) : byChat.map(([id, avatar, name, tasks]) => (0, import_react6.createElement)( + "section", + { key: id, className: "bt-set-section" }, + (0, import_react6.createElement)("h3", { className: "bt-task-head" }, avatar, name), + (0, import_react6.createElement)(TaskList, { tasks, editing, roster, actions, locked, onEdit: edit, onDone: shown, onCancel: () => setEditing(null) }) + )) + ]; +} +function TaskList({ tasks, editing, roster, actions, locked, onEdit, onDone, onCancel, extra = [] }) { + const open = !locked && editing ? tasks.find((task) => task.id === editing.id && task.sessionId === editing.sessionId) : void 0; + const [copying, setCopying] = (0, import_react6.useState)(null); + const source = copying ? tasks.find((task) => task.id === copying.id && task.sessionId === copying.sessionId) : void 0; + const rows = tasks.filter((task) => task !== open && task !== source).map((task) => (0, import_react6.createElement)(TaskRow, { key: task.id, task, actions, roster, locked, onView: publish, onEdit, onCopy: actions.scheduleCreate ? setCopying : void 0 })); + return (0, import_react6.createElement)( + import_react6.Fragment, + null, + open ? (0, import_react6.createElement)(TaskForm, { key: `edit-${open.id}`, roster, actions, task: open, onDone, onCancel }) : null, + source ? (0, import_react6.createElement)(CopyRow, { key: `copy-${source.id}`, task: source, roster, actions, onView: (next) => { + setCopying(null); + onDone(next); + }, onCancel: () => setCopying(null) }) : null, + rows.length + extra.length > 0 ? (0, import_react6.createElement)("div", { className: "bt-set-card" }, ...rows, ...extra) : null + ); +} +function CopyRow({ task, roster, actions, onView, onCancel }) { + const others = [ + ...roster.bots.filter((bot) => bot.id !== task.botId).map((bot) => [bot.id, bot.name]), + ...roster.rooms.filter((room2) => room2.id !== task.roomId).map((room2) => [room2.id, room2.name]) + ]; + const [target, setTarget] = (0, import_react6.useState)(""); + const [busy, setBusy] = (0, import_react6.useState)(false); + const [error, setError] = (0, import_react6.useState)(""); + const submit = (event) => { + event.preventDefault(); + if (busy || target === "") return; + setBusy(true); + setError(""); + void actions.scheduleCreate({ target, copyFrom: { sessionId: task.sessionId, id: task.id } }).then(onView, (failure) => { + setBusy(false); + setError(hostText(failure?.message ?? String(failure))); + }); + }; + return (0, import_react6.createElement)( + "form", + { className: "bt-set-row bt-task-copy", onSubmit: submit }, + (0, import_react6.createElement)("span", { className: "bt-set-copy" }, (0, import_react6.createElement)("span", { className: "bt-bs-label" }, t("Copy “{name}” to", { name: task.title }))), + (0, import_react6.createElement)( + "select", + { className: "bt-select bt-task-bot", value: target, "aria-label": t("Copy to"), onChange: (event) => setTarget(event.target.value) }, + (0, import_react6.createElement)("option", { value: "" }, t("Pick a Bot or group chat")), + others.map(([id, name]) => (0, import_react6.createElement)("option", { key: id, value: id }, name)) ), - open ? (0, import_react7.createElement)( + (0, import_react6.createElement)("button", { type: "submit", className: "bt-soft bt-soft-sm", disabled: busy || target === "" }, busy ? t("Copying…") : t("Copy")), + (0, import_react6.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: onCancel }, t("Cancel")), + error ? (0, import_react6.createElement)("span", { className: "bt-danger bt-set-hint", role: "alert" }, error) : null + ); +} +function BotSchedules({ bot, roster, actions }) { + const view = useSchedules(actions); + const [editing, setEditing] = (0, import_react6.useState)(null); + const locked = lockedWindow(roster); + const refused = schedulesReadFailure(view?.readFailure); + if (refused !== null) { + return (0, import_react6.createElement)( + "section", + { className: "bt-set-section" }, + (0, import_react6.createElement)("h3", null, t("Scheduled tasks")), + (0, import_react6.createElement)("div", { className: "bt-set-card" }, (0, import_react6.createElement)( + "div", + { className: "bt-set-row bt-bs-row" }, + (0, import_react6.createElement)("span", { className: "bt-set-copy" }, (0, import_react6.createElement)("span", { className: "bt-set-hint bt-danger", role: "status" }, refused)) + )) + ); + } + if (view === null) return null; + const tasks = view.available ? view.tasks.filter((task) => task.botId === bot.id) : []; + return (0, import_react6.createElement)( + "section", + { className: "bt-set-section" }, + (0, import_react6.createElement)("h3", null, t("Scheduled tasks")), + !view.available ? (0, import_react6.createElement)( "div", - { className: "bt-secret-edit" }, - (0, import_react7.createElement)(ScopeEditor, { key: JSON.stringify(secret.scope), secret, roster, onSave: (scope) => run(actions.secretUpdate(secret.name, scope)) }), - (0, import_react7.createElement)(ValueEditor, { secret, onSave: (value) => run(actions.secretSet({ name: secret.name, value })) }), - (0, import_react7.createElement)( + { className: "bt-set-card" }, + (0, import_react6.createElement)( "div", - { className: "bt-secret-row-actions" }, - confirm ? [ - (0, import_react7.createElement)("span", { key: "q", className: "bt-set-hint" }, t("Delete {name}? Bots lose it at once.", { name: secret.name })), - (0, import_react7.createElement)("button", { key: "no", type: "button", className: "bt-soft bt-soft-sm", onClick: () => setConfirm(false) }, t("Keep")), - (0, import_react7.createElement)("button", { key: "yes", type: "button", className: "bt-soft bt-soft-sm bt-secret-danger", onClick: () => run(actions.secretDelete(secret.name)) }, t("Delete")) - ] : (0, import_react7.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm bt-secret-danger", onClick: () => setConfirm(true) }, (0, import_react7.createElement)(TrashIcon), t("Delete")) + { className: "bt-set-row bt-bs-row" }, + (0, import_react6.createElement)("span", { className: "bt-set-copy" }, (0, import_react6.createElement)("span", { className: "bt-set-hint" }, t("Switch on Automation tasks on the Plugins page."))), + (0, import_react6.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: () => openPlugins(actions) }, t("Open Plugins")) ) - ) : null + ) : (0, import_react6.createElement)(TaskList, { + tasks, + editing, + roster, + actions, + locked, + onEdit: locked ? void 0 : setEditing, + onDone: (next) => { + publish(next); + setEditing(null); + }, + onCancel: () => setEditing(null), + extra: locked ? [] : [(0, import_react6.createElement)("button", { key: "new", type: "button", className: "bt-set-row bt-bs-row bt-gs-add", onClick: () => actions.openSettings("schedules", bot.id) }, (0, import_react6.createElement)(PlusIcon), t("New task"))] + }) ); } -function AddSecret({ actions, run, onDone }) { - const [name, setName] = (0, import_react7.useState)(""); - const [purpose, setPurpose] = (0, import_react7.useState)(""); - const [value, setValue] = (0, import_react7.useState)(""); - const ready = /^[A-Za-z][\w-]{0,63}$/.test(name.trim()) && value.trim().length >= MIN_VALUE; - const save = () => { - if (!ready) return; - void run(actions.secretSet({ name, purpose, value, scope: "all" })).then((ok) => { - if (ok) { - setValue(""); - onDone(); - } - }); - }; - return (0, import_react7.createElement)( - "div", - { className: "bt-set-row bt-set-paste bt-secret-add" }, - (0, import_react7.createElement)("input", { className: "bt-input", "aria-label": t("Key name"), placeholder: t("NAME, e.g. GITHUB_TOKEN"), value: name, spellCheck: false, autoComplete: "off", autoFocus: true, onChange: (event) => setName(event.target.value.toUpperCase()) }), - (0, import_react7.createElement)("input", { className: "bt-input", "aria-label": t("What it is for"), placeholder: t("What it is for"), value: purpose, autoComplete: "off", onChange: (event) => setPurpose(event.target.value) }), - (0, import_react7.createElement)("label", { className: "bt-q-field bt-secret-field" }, (0, import_react7.createElement)(MaskedInput, { value, onChange: setValue, onEnter: save, label: t("Value") })), - (0, import_react7.createElement)( +function SummaryLines({ tasks, actions }) { + const shown = tasks.slice(0, 3); + const hidden = tasks.length - shown.length; + return (0, import_react6.createElement)( + import_react6.Fragment, + null, + (0, import_react6.createElement)("div", { className: "bt-section-title" }, hidden > 0 ? t("Scheduled tasks · {count}", { count: tasks.length }) : t("Scheduled tasks")), + (0, import_react6.createElement)( "div", - { className: "bt-secret-row-actions" }, - (0, import_react7.createElement)("span", { className: "bt-set-hint" }, t("New keys are for all your Bots; change who may use it afterwards.")), - (0, import_react7.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: onDone }, t("Cancel")), - (0, import_react7.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", disabled: !ready, onClick: save }, t("Save key")) + { className: "bt-task-list" }, + shown.map((task) => (0, import_react6.createElement)( + "button", + { key: task.id, type: "button", className: "bt-task-line", onClick: () => actions.openSettings("schedules") }, + (0, import_react6.createElement)(ClockIcon), + (0, import_react6.createElement)("span", { className: "bt-task-line-title" }, task.title), + (0, import_react6.createElement)("span", { className: "bt-task-line-when" }, nextText(task)) + )), + hidden > 0 ? (0, import_react6.createElement)("button", { key: "more", type: "button", className: "bt-more", onClick: () => actions.openSettings("schedules") }, t("{count} more not shown", { count: hidden })) : null ) ); } -function SecretsPage({ roster, actions, run, Section }) { - const [open, setOpen] = (0, import_react7.useState)(null); - const [adding, setAdding] = (0, import_react7.useState)(false); - const secrets = roster.secrets ?? []; - return [ - (0, import_react7.createElement)( - "p", - { key: "about", className: "bt-secret-about" }, - t("Keys your Bots asked for. Bots never see the values: they use them as "), - (0, import_react7.createElement)("code", null, "$DSH_SECRET_NAME"), - t(" in their shell, and every value is replaced by "), - (0, import_react7.createElement)("code", null, "[secret:NAME]"), - t(" before anything goes to a model.") - ), - (0, import_react7.createElement)( - Section, - { key: "list", title: t("Secrets · {count}", { count: secrets.length }) }, - secrets.length === 0 && !adding ? (0, import_react7.createElement)("div", { className: "bt-set-row" }, (0, import_react7.createElement)("span", { className: "bt-set-hint" }, t("No keys yet. When a Bot needs one, it shows a card in its chat."))) : null, - secrets.map((secret) => (0, import_react7.createElement)(SecretRow, { key: secret.name, secret, roster, actions, run, open: open === secret.name, onToggle: () => setOpen(open === secret.name ? null : secret.name) })), - adding ? (0, import_react7.createElement)(AddSecret, { actions, run, onDone: () => setAdding(false) }) : null - ), - adding ? null : (0, import_react7.createElement)("div", { key: "add", className: "bt-actions" }, (0, import_react7.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: () => setAdding(true) }, t("Add a key"))) - ]; +function ScheduleSummary({ bot, roster, actions }) { + const view = useSchedules(actions); + if (view === null || view.readFailure !== void 0) return null; + const tasks = view.available ? view.tasks.filter((task) => task.botId === bot.id) : []; + if (lockedWindow(roster) && tasks.length === 0) return null; + return (0, import_react6.createElement)( + "div", + null, + tasks.length === 0 ? (0, import_react6.createElement)("div", { className: "bt-section-title" }, t("Scheduled tasks")) : (0, import_react6.createElement)(SummaryLines, { tasks, actions }), + tasks.length === 0 ? (0, import_react6.createElement)("button", { type: "button", className: "bt-more", onClick: () => actions.openSettings("schedules", bot.id) }, view.available === false ? t("Switch on Automation tasks on the Plugins page.") : view.someHidden ? t("No scheduled tasks you can see. Add one") : t("No scheduled tasks yet. Add one")) : null + ); } -var SECRET_CSS = ` -.bt-secret-head{align-items:center;gap:10px} -.bt-secret-who{position:relative;flex:none;display:inline-flex} -.bt-secret-lock{position:absolute;right:-5px;bottom:-4px;width:16px;height:16px;border-radius:50%;background:var(--bt-main);color:var(--bt-ink-2);display:inline-flex;align-items:center;justify-content:center;box-shadow:0 0 0 1.5px var(--bt-bubble)} -.bt-secret-lock svg{width:11px;height:11px} -.bt-secret-name{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:12px;line-height:16px;padding:1px 6px;border-radius:5px;background:var(--bt-hover);color:var(--bt-ink)} -.bt-secret-body{display:flex;flex-direction:column;gap:8px} -.bt-secret-field{align-items:center;min-height:36px} -.bt-secret-input{flex:1;width:100%;min-width:0;margin:0;padding:0;border:0;outline:none;background:transparent;color:var(--bt-ink);font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:14px;line-height:20px;letter-spacing:.04em} -.bt-secret-input[data-masked]{-webkit-text-security:disc} -.bt-secret-input::placeholder{color:var(--bt-ink-3);font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",system-ui,sans-serif;letter-spacing:0} -.bt-secret-scope{position:relative;display:inline-grid;grid-template-columns:1fr 1fr;align-self:flex-start;padding:2px;border-radius:999px;background:var(--bt-hover)} -.bt-secret-glide{position:absolute;top:2px;bottom:2px;left:2px;width:calc(50% - 2px);border-radius:999px;background:var(--bt-main);box-shadow:0 1px 3px rgba(20,20,20,.08),inset 0 0 0 .5px color-mix(in srgb,var(--bt-ink) 6%,transparent);transition:${spring(SPRING_SHAPE, "transform")}} -.bt-secret-glide[data-at="1"]{transform:translateX(100%)} -.bt-secret-seg{position:relative;z-index:1;height:28px;padding:0 14px;border:0;border-radius:999px;background:none;color:var(--bt-ink-2);font:inherit;font-size:13px;white-space:nowrap;cursor:pointer;transition:color .16s ease} -.bt-secret-seg[aria-checked=true]{color:var(--bt-ink);font-weight:500} -.bt-secret-seg:focus-visible{outline:2px solid var(--bt-accent);outline-offset:-2px} -/* A key card asks the user to act, so the process group carrying it stays open in every - work-details mode (same override as .bt-group-replies in styles.js). */ -[data-step-process]:has(.bt-secret)>div:first-child{display:none!important} -[data-step-process]:has(.bt-secret)>[data-step-process-body]{display:block!important;content-visibility:visible!important;max-height:none!important;overflow:visible!important;mask-image:none!important;scrollbar-gutter:auto!important} -.bt-secret-ok{display:flex;align-items:center;gap:4px;margin-top:6px;animation:bt-secret-in .22s cubic-bezier(.22,1,.36,1)} -.bt-secret-ok svg{width:13px;height:13px;color:#16a34a} -@keyframes bt-secret-in{from{opacity:0;transform:translateY(-2px)}} -.bt-secret-saved{display:flex;align-items:center;gap:6px;font-size:13px;line-height:18px;color:var(--bt-ink-2)} -.bt-secret-note{font-size:12px;line-height:17px;color:var(--bt-ink-3)} -.bt-secret-note code,.bt-secret-about code{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:11.5px} -.bt-secret-warn{display:flex;align-items:center;gap:6px;padding:6px 10px;border-radius:8px;font-size:12px;line-height:16px;color:#b25e09;background:color-mix(in srgb,#f59e0b 12%,transparent)} -.bt-secret-error{font-size:12px;line-height:16px;color:#e02135} -.bt-secret .bt-q-btn:disabled{opacity:.5;cursor:default} -.bt-secret-done .bt-q-head{align-items:center} -.bt-secret-done .bt-q-title{font-size:13px;line-height:18px;color:var(--bt-ink-2);font-weight:500} -.bt-secret-scope-tag{font-weight:400;color:var(--bt-ink-3)} -.bt-secret-check{color:var(--bt-green,#22a06b);animation:bt-secret-pop ${SPRING_POP.duration}ms ${SPRING_POP.easing} backwards} -.bt-secret-done{animation:bt-secret-settle .32s cubic-bezier(.22,1,.36,1) backwards} -@keyframes bt-secret-pop{from{opacity:0;transform:scale(.4)}} -@keyframes bt-secret-settle{from{opacity:.4;transform:scale(.985)}} -.bt-secret-event{display:inline-flex;align-items:center;gap:5px} -.bt-secret-dock{flex-direction:column;align-items:center} -.bt-secret-dock .bt-q{max-width:min(550px,calc(100% - 32px))} -.bt-secret-about{margin:0 0 12px;padding:0 4px;font-size:12px;line-height:17px;color:var(--bt-ink-2)} -.bt-secret-title{display:flex;align-items:center;gap:6px;min-width:0;color:var(--bt-ink)} -.bt-secret-title svg{flex:none;color:var(--bt-ink-2)} -.bt-secret-purpose{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--bt-ink-2)} -.bt-secret-faces{display:inline-flex} -.bt-secret-faces>*+*{margin-left:-6px} -.bt-secret-edit{display:flex;flex-direction:column;gap:14px;padding:4px 12px 14px;animation:bt-q-in ${SPRING_SHAPE.duration}ms ${SPRING_SHAPE.easing} backwards,bt-veil-in .2s ease-out backwards} -.bt-secret-edit-block{display:flex;flex-direction:column;gap:8px} -.bt-secret-edit-label{font-size:12px;line-height:16px;font-weight:600;color:var(--bt-ink-2)} -.bt-secret-radios{display:flex;gap:16px;font-size:13px} -.bt-secret-radio{display:inline-flex;align-items:center;gap:6px;cursor:pointer} -.bt-secret-bots{display:flex;flex-wrap:wrap;gap:6px} -.bt-secret-bot{display:inline-flex;align-items:center;gap:6px;height:30px;padding:0 10px 0 5px;border-radius:999px;border:.5px solid var(--bt-line);background:var(--bt-card);color:var(--bt-ink-2);font:inherit;font-size:12px;cursor:pointer;transition:background-color .12s ease,color .12s ease,border-color .12s ease} -.bt-secret-bot[aria-pressed=true]{background:var(--bt-active);color:var(--bt-ink);border-color:var(--bt-line-2)} -.bt-secret-bot svg{width:13px;height:13px} -.bt-secret-inline{display:flex;gap:8px;align-items:center} -.bt-secret-row-actions{display:flex;align-items:center;justify-content:flex-end;gap:8px;flex-wrap:wrap} -.bt-secret-row-actions .bt-set-hint{flex:1} -.bt-secret-danger{color:#e02135;display:inline-flex;align-items:center;gap:4px} -.bt-secret-danger svg{width:13px;height:13px} -.bt-secret-add .bt-input{height:34px} -@media (prefers-reduced-motion:reduce){.bt-secret-glide{transition:none}.bt-secret-check,.bt-secret-done,.bt-secret-ok,.bt-secret-edit{animation:none}} +function RoomSchedules({ room: room2, actions }) { + const view = useSchedules(actions); + if (view === null || view.readFailure !== void 0 || room2.origin === "face-to-face") return null; + const tasks = view.available ? view.tasks.filter((task) => task.botId === void 0 && task.roomId === room2.id) : []; + if (tasks.length === 0) return null; + return (0, import_react6.createElement)("div", null, (0, import_react6.createElement)(SummaryLines, { tasks, actions })); +} +var SCHEDULE_CSS = ` +.bt-task{gap:10px} +.bt-task-copy{flex-wrap:wrap;gap:8px} +.bt-task-copy .bt-task-bot{flex:1;min-width:140px;max-width:100%} +.bt-task[data-done]{opacity:.6} +.bt-task[data-busy]{opacity:.4} +.bt-task-title{font-size:13px;line-height:18px;font-weight:500;overflow-wrap:anywhere} +.bt-task-prompt{font-size:12px;line-height:16px;color:var(--bt-ink-2);display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden} +.bt-task-next{flex:none;font-size:12px;line-height:16px;color:var(--bt-ink-3);font-variant-numeric:tabular-nums;white-space:nowrap} +.bt-task-remove{display:inline-flex;align-items:center;justify-content:center;min-width:26px;height:26px;color:var(--bt-ink-3)} +.bt-task-remove svg{width:14px;height:14px} +.bt-task-tool{display:inline-flex;align-items:center;justify-content:center;min-width:26px;height:26px;color:var(--bt-ink-3)} +.bt-task-tool svg{width:14px;height:14px} +.bt-task-pause{height:26px;color:var(--bt-ink-2)} +.bt-task-last{font-size:12px;line-height:16px;color:var(--bt-ink-3);overflow-wrap:anywhere} +.bt-task-last[data-failed]{color:#c21d2e} +.bt-task[data-paused] .bt-task-title,.bt-task[data-paused] .bt-task-prompt{color:var(--bt-ink-3)} +.bt-task-remove[data-confirm]{background:rgba(224,33,53,.1);color:#e02135} +.bt-task-head{display:flex;align-items:center;gap:6px} +.bt-task-form{animation:bt-q-in .4s cubic-bezier(.22,1,.36,1),bt-veil-in .2s ease-out} +.bt-task-bot{width:auto;min-width:160px;max-width:58%} +.bt-task-when{display:flex;align-items:center;flex-wrap:wrap;gap:8px} +.bt-task-when .bt-input{width:auto;height:32px} +.bt-task-time{min-width:110px} +.bt-task-count{width:72px!important} +.bt-task-unit{width:auto;height:32px} +.bt-task-every{font-size:13px;color:var(--bt-ink-2)} +.bt-task-days{display:flex;gap:4px} +.bt-task-day{width:30px;height:30px;border-radius:50%;border:1px solid var(--bt-line);background:none;color:var(--bt-ink-2);font:inherit;font-size:12px;cursor:pointer;transition:background-color .12s ease,color .12s ease,border-color .12s ease} +.bt-task-day[aria-pressed=true]{background:var(--bt-user);border-color:var(--bt-user);color:var(--bt-user-ink)} +.bt-task-foot{justify-content:flex-end;gap:8px} +.bt-task-foot .bt-send{height:28px;font-size:12px} +.bt-task-off{align-items:center;gap:8px;padding:28px 20px;text-align:center} +.bt-task-off>*+*{border-top:0} +.bt-task-off-mark{width:44px;height:44px;border-radius:50%;background:var(--bt-hover);display:flex;align-items:center;justify-content:center;color:var(--bt-ink-2)} +.bt-task-off-mark svg{width:20px;height:20px} +.bt-task-off-title{font-size:14px;line-height:20px;font-weight:600} +.bt-task-off .bt-set-hint{max-width:380px} +.bt-task-list{display:flex;flex-direction:column;gap:2px} +.bt-task-line{display:flex;align-items:center;gap:8px;width:100%;min-width:0;padding:6px 8px;border:0;border-radius:8px;background:none;color:var(--bt-ink);font:inherit;font-size:13px;text-align:left;cursor:pointer;transition:background-color .12s ease} +.bt-task-line:hover{background:var(--bt-hover)} +.bt-task-line svg{flex:none;color:var(--bt-ink-3)} +.bt-task-line-title{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} +.bt-task-line-when{flex:none;font-size:12px;color:var(--bt-ink-3)} +@media (prefers-reduced-motion:reduce){.bt-task-form{animation:none}} `; -// src/client/face-seats.js -function faceSeats(room2, roster, { left = false } = {}) { - const records = (room2.presentedParticipants ?? room2.participants ?? []).filter((p) => p.kind !== "human"); - const seat = (p) => { - const local = p.remote ? void 0 : roster.byId[p.id]; - return { - key: p.handle || p.id, - local, - record: p, - person: local ? seatOwnerName(p.ownerName, p.handle) || cleanPeerName(roster.self?.displayName, "") : ownerNameOf(p), - bot: local ? local.name : remoteBotName(p, "Bot"), - mine: Boolean(local) +// src/client/questions.js +var import_react7 = require("react"); +var keyLetter = (index) => String.fromCharCode(65 + index); +var optionsOf = (question) => (question?.options ?? []).map((option) => typeof option === "string" ? { label: option } : option); +var isTypingTarget = (node) => node instanceof HTMLInputElement || node instanceof HTMLTextAreaElement || node instanceof HTMLSelectElement || node instanceof HTMLElement && node.isContentEditable; +function useOptionKeys(count, onPick, ownRef) { + const pick2 = (0, import_react7.useRef)(onPick); + pick2.current = onPick; + (0, import_react7.useEffect)(() => { + if (count === 0) return void 0; + const onKey = (event) => { + if (event.defaultPrevented || event.metaKey || event.ctrlKey || event.altKey || event.shiftKey || event.key.length !== 1) return; + if (isTypingTarget(event.target) || isTypingTarget(document.activeElement)) return; + if (document.querySelector('[role="dialog"], [aria-modal="true"]') !== null) return; + const index = event.key.toUpperCase().charCodeAt(0) - 65; + if (index >= 0 && index < count) { + event.preventDefault(); + pick2.current(index); + } else if (index === count && ownRef.current) { + event.preventDefault(); + ownRef.current.focus(); + } }; - }; - const seats = records.filter((p) => p.leftSeq === void 0).map(seat); - for (const id of room2.members ?? []) { - const bot = roster.byId[id]; - if (!bot || seats.some((item) => item.local?.id === id)) continue; - seats.push({ key: id, local: bot, record: { id }, person: cleanPeerName(roster.self?.displayName, ""), bot: bot.name, mine: true }); - } - if (!left) return seats; - const gone = records.filter((p) => p.leftSeq !== void 0 && !seats.some((item) => item.key === (p.handle || p.id))).map((item) => ({ ...seat(item), left: true })); - return [...seats, ...gone]; -} -function faceSeatLabel(seat) { - if (!seat.person) return seat.bot; - return `${seat.bot} (${seat.person})`; -} -function faceBotLabel(room2, roster, bot) { - if (room2?.origin !== "face-to-face" || !bot) return bot?.name; - const seat = faceSeats(room2, roster, { left: true }).find((item) => item.local?.id === bot.id || item.record?.id !== void 0 && item.record.id === bot.id); - return seat ? faceSeatLabel(seat) : bot.name; -} -function facePersonKey(seat) { - if (seat.mine) return ":you"; - const record = seat.record ?? {}; - const home = String(record.ownerHome || record.home || "").trim().toLowerCase(); - if (home) return `home:${home}`; - const parts = String(record.handle || "").trim().toLowerCase().replace(/^@+/, "").split("."); - if (parts.length >= 3 && parts.at(-1) === "home") return `home:${parts.slice(-2).join(".")}`; - return `name:${(ownerNameOf(record) || seat.person || seat.key).toLowerCase()}`; + document.addEventListener("keydown", onKey, true); + return () => document.removeEventListener("keydown", onKey, true); + }, [count, ownRef]); } -function facePeople(room2, roster) { - const people2 = []; - for (const seat of faceSeats(room2, roster)) { - const key = facePersonKey(seat); - const known = people2.find((person) => person.key === key); - if (known) { - known.seats.push(seat); - continue; +function QuestionView({ question, onAnswer, onDismiss, ownAnswer = false, raised = false, locked = false }) { + const [own, setOwn] = (0, import_react7.useState)(""); + const [picked, setPicked] = (0, import_react7.useState)([]); + const ownRef = (0, import_react7.useRef)(null); + const titleId = (0, import_react7.useId)(); + const options = optionsOf(question); + const multi = question.multiSelect === true; + const custom = own.trim(); + const canSubmit = multi ? picked.length > 0 || custom !== "" : custom !== ""; + const choose = (label) => { + if (multi) { + setPicked((current) => current.includes(label) ? current.filter((item) => item !== label) : [...current, label]); + return; } - people2.push({ key, name: seat.person || (seat.mine ? cleanPeerName(roster.self?.displayName, "") : "") || t("Someone"), mine: seat.mine, seats: [seat] }); + onAnswer({ selected: [label] }); + }; + const submit = () => { + if (!canSubmit) return; + onAnswer({ selected: multi ? picked : [], ...custom ? { custom } : {} }); + }; + useOptionKeys(locked ? 0 : options.length, (index) => choose(options[index].label), ownRef); + const ownLetter = options.length > 0 ? keyLetter(options.length) : void 0; + const field = ownAnswer && !locked ? (0, import_react7.createElement)("textarea", { + ref: ownRef, + rows: 1, + value: own, + autoComplete: "off", + spellCheck: false, + className: "bt-q-input", + "aria-label": t("Your own answer"), + placeholder: t("Type your own answer"), + "aria-keyshortcuts": ownLetter?.toLowerCase(), + onChange: (event) => setOwn(event.target.value), + onKeyDown: (event) => { + if (event.key !== "Enter" || event.shiftKey || event.nativeEvent.isComposing) return; + event.preventDefault(); + submit(); + } + }) : null; + const rows = options.map((option, optionIndex) => { + const selected = multi && picked.includes(option.label); + const body = [ + (0, import_react7.createElement)("span", { className: "bt-key", "aria-hidden": true }, keyLetter(optionIndex)), + (0, import_react7.createElement)( + "span", + { className: "bt-q-body" }, + (0, import_react7.createElement)("span", { className: "bt-q-label" }, option.label), + option.description ? (0, import_react7.createElement)("span", { className: "bt-q-desc" }, option.description) : null + ), + selected && !raised ? (0, import_react7.createElement)("span", { className: "bt-q-check", title: t("Selected") }, (0, import_react7.createElement)(CheckIcon)) : null + ]; + return locked ? (0, import_react7.createElement)("div", { key: option.label, className: "bt-q-opt" }, ...body) : (0, import_react7.createElement)("button", { + key: option.label, + type: "button", + className: "bt-q-opt", + "aria-pressed": multi ? selected : void 0, + "aria-keyshortcuts": keyLetter(optionIndex).toLowerCase(), + onClick: () => choose(option.label) + }, ...body); + }); + if (raised && field) { + rows.push((0, import_react7.createElement)( + "label", + { key: "own", className: "bt-q-opt bt-q-ownrow", "data-filled": custom !== "" ? "" : void 0 }, + ownLetter ? (0, import_react7.createElement)("span", { className: "bt-key", "aria-hidden": true }, ownLetter) : null, + (0, import_react7.createElement)("span", { className: "bt-q-body" }, field) + )); } - return [...people2.filter((person) => person.mine), ...people2.filter((person) => !person.mine)]; -} -function facePersonLabel(person) { - return person.mine ? t("{name} (you)", { name: person.name }) : person.name; -} - -// src/client/held-reply.js -var import_react8 = require("react"); -var call3 = (endpoint, payload) => call(`flybot/${endpoint}`, payload); -var Boundary; -var boundary = () => Boundary ??= class HeldReplyBoundary extends import_react8.Component { - constructor(props) { - super(props); - this.state = { failed: false }; - } - static getDerivedStateFromError() { - return { failed: true }; - } - componentDidCatch(error) { - console.error(`flybot: ${this.props.id} failed`, error); - } - render() { - return this.state.failed ? this.props.fallback : this.props.children; - } -}; -function useInlineEntry(policy) { - const slots = registry(); - const [, setVersion] = (0, import_react8.useState)(0); - (0, import_react8.useEffect)(() => slots.subscribe(() => setVersion(slots.version())), [slots]); - return slots.entries("flybot.approvals").find((entry) => entry.inline === true && entry.policy === policy); -} -function HeldReplyActions({ held, actions }) { - const custom = useInlineEntry(held?.by); - const [busy, setBusy] = (0, import_react8.useState)(false); - const [error, setError] = (0, import_react8.useState)(""); - const waiting = isApprovalWaiting(held?.status); - (0, import_react8.useEffect)(() => { - if (!waiting) return void 0; - claimApprovals(`held-reply:${held.id}`, [held.id]); - return () => claimApprovals(`held-reply:${held.id}`, []); - }, [held?.id, waiting]); - if (!held?.id) return null; - const decide = async (decision) => { - setBusy(true); - setError(""); - try { - await call3("decide", { id: held.id, decision }); - } catch (failure) { - setError(failure?.message ?? String(failure)); - } finally { - setBusy(false); - } - }; - if (!waiting) { - const failed = held.status === "failed"; - return (0, import_react8.createElement)( + let foot = null; + if (!locked && raised && multi) { + foot = (0, import_react7.createElement)( "div", - { className: "bt-held-note", "data-state": held.status, title: failed && held.error ? held.error : void 0 }, - (0, import_react8.createElement)("span", null, held.status === "approved" ? t("Sent to {to}", { to: held.toName }) : held.status === "auto" ? t("Auto-sent to {to}", { to: held.toName }) : failed ? t("Not sent: delivery failed") : t("Not sent")), - // #968: the terminal answer comes from one predicate, not from `status === 'failed'` here and a - // different field elsewhere. A failure that can never succeed draws no button on any surface. - failed && canRetry(held) ? (0, import_react8.createElement)("button", { type: "button", className: "bt-held-retry", disabled: busy, onClick: () => decide("retry") }, t("Retry")) : null, - error ? (0, import_react8.createElement)("span", { className: "bt-flybot-approval-error", role: "alert" }, error) : null + { className: "bt-q-foot" }, + (0, import_react7.createElement)("button", { type: "button", className: "bt-q-btn", onClick: onDismiss }, t("Skip")), + (0, import_react7.createElement)("button", { type: "submit", className: "bt-q-submit", disabled: !canSubmit }, t("Submit")) ); + } else if (!locked && (raised || multi) && canSubmit) { + foot = (0, import_react7.createElement)("div", { className: "bt-q-foot" }, (0, import_react7.createElement)("button", { type: "submit", className: "bt-q-submit" }, t("Submit"))); } - const plain = (0, import_react8.createElement)( - "div", - { className: "bt-held-card", role: "region", "aria-label": t("Waiting for your approval") }, - (0, import_react8.createElement)("div", { className: "bt-held-head" }, t("Waiting for your approval")), - (0, import_react8.createElement)("div", { className: "bt-flybot-approval-origin" }, t("{from} → {to}", { from: held.from ?? t("A Bot"), to: held.toName })), - (0, import_react8.createElement)( + return (0, import_react7.createElement)( + locked ? "div" : "form", + { + className: raised ? "bt-q bt-q-raised" : "bt-q", + "aria-labelledby": titleId, + ...locked ? {} : { onSubmit: (event) => { + event.preventDefault(); + submit(); + } } + }, + (0, import_react7.createElement)( "div", - { className: "bt-flybot-approval-actions" }, - (0, import_react8.createElement)("button", { type: "button", disabled: busy, onClick: () => decide("reject") }, t("Reject")), - (0, import_react8.createElement)("button", { type: "button", className: "bt-flybot-approve", disabled: busy, onClick: () => decide("approve") }, t("Approve")) + { className: "bt-q-head" }, + (0, import_react7.createElement)( + "div", + { className: "bt-q-text" }, + (0, import_react7.createElement)("p", { className: "bt-q-title", id: titleId }, question.header ? `${question.header} · ` : "", question.question), + question.detail ? (0, import_react7.createElement)("div", { className: "bt-q-detail" }, question.detail) : null, + raised && multi ? (0, import_react7.createElement)("div", { className: "bt-q-detail" }, t("Choose all that apply")) : null + ), + locked ? null : (0, import_react7.createElement)("button", { type: "button", className: "bt-q-x", "aria-label": t("Dismiss question"), title: t("Dismiss"), onClick: onDismiss }, (0, import_react7.createElement)(CloseIcon)) ), - error ? (0, import_react8.createElement)("div", { className: "bt-flybot-approval-error", role: "alert" }, error) : null - ); - return (0, import_react8.createElement)( - "div", - { className: "bt-held-actions", "data-flybot": "held-reply-actions" }, - custom ? (0, import_react8.createElement)(boundary(), { id: custom.id, fallback: plain }, (0, import_react8.createElement)(custom.component, { held, decide, call: call3, actions })) : plain + rows.length > 0 ? (0, import_react7.createElement)("div", { className: "bt-q-list", role: "group" }, rows) : null, + field && !raised ? (0, import_react7.createElement)( + "div", + { className: "bt-q-custom" }, + (0, import_react7.createElement)("label", { className: "bt-q-field" }, field), + !multi && canSubmit ? (0, import_react7.createElement)("button", { type: "submit", className: "bt-q-submit" }, t("Submit")) : null + ) : null, + foot ); } - -// src/client/turn-reasoning.js -function turnReasoning(steps, current) { - const members = steps.length ? steps : [current]; - if (members.find((step) => stepReasoning(step) !== "")?.step !== current.step) return ""; - return members.filter((step) => !thinkingLive(step)).map(stepReasoning).filter(Boolean).join("\n\n"); -} -var texts = (step) => step.blocks.filter((block) => block.kind === "text" && block.text.trim() !== ""); -var thinkingLive = (step) => step.status === "running" && texts(step).length === 0 && step.blocks.some((block) => block.kind === "reasoning"); -var stepReasoning = (step) => step.blocks.filter((block) => block.kind === "reasoning").map((block) => block.text).join("\n").trim(); - -// src/client/cells.js -var import_react9 = require("react"); -var import_react_dom = require("react-dom"); -var import_dsh_client_ui_primitives = require("@deepseek-ai/dsh-client-ui-primitives"); -var mdLabels = () => ({ code: { copyLabel: t("Copy"), copiedLabel: t("Copied") }, footnotes: t("Footnotes") }); -function Bubbles({ texts: texts2, roster, selfId, actions, streaming, sessionId, msgKey, msgKeys, useUi, lead, mentions }) { - const onClick = (event) => { - const target = mentionTarget(event); - if (target === null) return; - event.preventDefault(); - event.stopPropagation(); - actions.openSession(target); - }; - const reactions = useUi ? useUi((state) => msgKey || msgKeys ? msgKeys ? state.reactions ?? null : state.reactions?.[msgKey] ?? null : null) : null; - const reactKey = (index) => msgKeys ? msgKeys[index] : msgKey; - const reactIndex = (index) => msgKeys ? 0 : index; - const shown = (texts2 ?? []).filter((text2) => String(text2 ?? "").trim() !== ""); - if (shown.length === 0) return null; - return (0, import_react9.createElement)( +function SettledQuestion({ question, answer }) { + const options = optionsOf(question); + const prompt = question?.question ?? ""; + const chosen = [...answer.selected ?? [], ...answer.custom ? [answer.custom] : []]; + if (chosen.length === 0) { + return (0, import_react7.createElement)( + "div", + { className: "bt-q bt-q-settled bt-q-dismissed", role: "group", "aria-label": prompt }, + (0, import_react7.createElement)( + "div", + { className: "bt-q-head" }, + (0, import_react7.createElement)("div", { className: "bt-q-text" }, (0, import_react7.createElement)("p", { className: "bt-q-title" }, prompt)), + (0, import_react7.createElement)("span", { className: "bt-q-badge" }, t("Dismissed")) + ) + ); + } + return (0, import_react7.createElement)( "div", - { className: "bt-astack", "data-lead": lead ? "" : void 0, onClickCapture: onClick }, - shown.map((text2, index) => { - const key = reactKey(index); - const picked = key ? (msgKeys ? reactions?.[key] : reactions)?.[reactIndex(index)] ?? [] : []; - const last = index === shown.length - 1; - const live = streaming && last; - return (0, import_react9.createElement)( + { className: "bt-q bt-q-settled", role: "group", "aria-label": prompt }, + prompt ? (0, import_react7.createElement)("div", { className: "bt-q-head" }, (0, import_react7.createElement)("div", { className: "bt-q-text" }, (0, import_react7.createElement)("p", { className: "bt-q-title" }, prompt))) : null, + (0, import_react7.createElement)("div", { className: "bt-q-list", role: "group", "aria-label": t("Your answer") }, chosen.map((label, index) => { + const optionIndex = options.findIndex((option) => option.label === label); + return (0, import_react7.createElement)( "div", - { key: index, className: "bt-msg" }, - (0, import_react9.createElement)( - "div", - { className: "bt-msg-line" }, - lead && last ? lead : null, - (0, import_react9.createElement)( - "div", - { className: "bt-bubble" }, - (0, import_react9.createElement)(import_dsh_client_ui_primitives.MarkdownText, { text: linkMentions(text2, roster.bots, selfId), streaming: live, labels: mdLabels(), variant: "compact", fileMentions: mentions }) - ), - live ? null : (0, import_react9.createElement)(MessageTools, { text: text2, name: roster.byId[selfId]?.name ?? t("Bot"), sessionId, react: key ? (emoji) => actions.toggleReaction(key, reactIndex(index), emoji) : void 0, actions }) - ), - picked.length ? (0, import_react9.createElement)("div", { className: "bt-reacts" }, picked.map((emoji) => (0, import_react9.createElement)("button", { - key: emoji, - type: "button", - className: "bt-react", - "aria-label": t("Remove reaction {emoji}", { emoji }), - onClick: () => actions.toggleReaction(key, reactIndex(index), emoji) - }, emoji))) : null + { key: `${label}-${index}`, className: "bt-q-opt" }, + optionIndex >= 0 ? (0, import_react7.createElement)("span", { className: "bt-key", "aria-hidden": true }, keyLetter(optionIndex)) : null, + (0, import_react7.createElement)("span", { className: "bt-q-body" }, (0, import_react7.createElement)("span", { className: "bt-q-label" }, label)), + (0, import_react7.createElement)("span", { className: "bt-q-check", title: t("Selected") }, (0, import_react7.createElement)(CheckIcon)) ); - }) + })) ); } -var WAITING_OTHERS_NOTICE = "Waiting for the owner's approval"; -var REACTIONS = ["👍", "❤️", "😂", "😮", "😢", "🙏"]; -function leaveNoticeCoveredByClose(room2, text2) { - if (room2?.origin !== "face-to-face" || room2.face?.status !== "closed") return false; - const match = String(text2 ?? "").trim().match(/^(.+) left(?: the room)?$/); - if (!match) return false; - const who = match[1]; - for (const participant of room2.participants ?? []) { - if (participant?.remote !== true || participant.leftSeq === void 0) continue; - const name = String(participant.ownerName || participant.displayName || participant.name || "").trim(); - if (name && (who === name || who.endsWith(` (${name})`))) return true; - } - return false; -} -function useDismiss(open, close, refs) { - (0, import_react9.useEffect)(() => { - if (!open) return void 0; - const onDown = (event) => { - if (refs.some((ref) => ref.current?.contains(event.target))) return; - close(); - }; - const onKey = (event) => { - if (event.key === "Escape") { - event.stopPropagation(); - close(); - } - }; - window.addEventListener("mousedown", onDown); - window.addEventListener("keydown", onKey, true); - return () => { - window.removeEventListener("mousedown", onDown); - window.removeEventListener("keydown", onKey, true); - }; - }, [open]); -} -function MessageTools({ text: text2, name, sessionId, react, actions }) { - const [panel, setPanel] = (0, import_react9.useState)(null); - const [place, setPlace] = (0, import_react9.useState)(null); - const [copied, setCopied] = (0, import_react9.useState)(false); - const tools = (0, import_react9.useRef)(null); - const pop = (0, import_react9.useRef)(null); - const close = () => setPanel(null); - useDismiss(panel !== null, close, [tools, pop]); - const open = (kind, event) => { - if (panel === kind) { - close(); +function QuestionCard({ matched }) { + const pending = matched; + const questions = pending.questions ?? []; + const [index, setIndex] = (0, import_react7.useState)(0); + const [answers, setAnswers] = (0, import_react7.useState)([]); + const question = questions[index]; + if (question === void 0) return null; + const onAnswer = (answer) => { + const next = [...answers, { id: question.id, ...answer }]; + if (index + 1 < questions.length) { + setAnswers(next); + setIndex(index + 1); return; } - const rect = event.currentTarget.getBoundingClientRect(); - const below = rect.bottom + 220 < window.innerHeight; - setPlace(kind === "react" ? { left: rect.left + rect.width / 2, top: rect.top - 6, "--bt-origin": "bottom center" } : { left: Math.min(rect.left, window.innerWidth - 208), ...below ? { top: rect.bottom + 4 } : { bottom: window.innerHeight - rect.top + 4, "--bt-origin": "bottom left" } }); - setPanel(kind); + pending.answer({ answers: next }); }; - const copy2 = () => { - void navigator.clipboard?.writeText(text2).then(() => { - setCopied(true); - setTimeout(() => setCopied(false), 1e3); - }); + return (0, import_react7.createElement)(QuestionView, { key: index, question, onAnswer, onDismiss: () => pending.dismiss(), ownAnswer: true, raised: true }); +} +function PendingQuestion({ sessionId, question, actions, roster, inline }) { + const [answered, setAnswered] = (0, import_react7.useState)(null); + if (answered === question.id) return null; + const locked = lockedWindow(roster); + const onAnswer = (answer) => { + setAnswered(question.id); + void actions.answerQuestion(sessionId, question.id, { selected: answer.selected ?? [], custom: answer.custom ?? "" }).catch(() => setAnswered(null)); }; - const download2 = () => { - const url = URL.createObjectURL(new Blob([text2], { type: "text/markdown" })); - const link = Object.assign(document.createElement("a"), { href: url, download: `${name}-message.md` }); - link.click(); - setTimeout(() => URL.revokeObjectURL(url), 1e3); + const onDismiss = () => { + setAnswered(question.id); + void actions.dismissQuestion(sessionId); }; - const item = (label, Icon, run) => (0, import_react9.createElement)("button", { key: label, type: "button", role: "menuitem", onClick: () => { - close(); - run(); - } }, (0, import_react9.createElement)(Icon), label); - return (0, import_react9.createElement)( + return (0, import_react7.createElement)( "div", - { ref: tools, className: "bt-tools", "data-open": panel !== null || void 0 }, - react ? (0, import_react9.createElement)("button", { type: "button", className: "bt-tool", "aria-label": t("Add reaction"), title: t("Add reaction"), "aria-expanded": panel === "react", onClick: (event) => open("react", event) }, (0, import_react9.createElement)(SmileIcon)) : null, - sessionId ? (0, import_react9.createElement)("button", { type: "button", className: "bt-tool", "aria-label": t("Reply"), title: t("Reply"), onClick: () => actions.quote(sessionId, text2) }, (0, import_react9.createElement)(ReplyIcon)) : null, - (0, import_react9.createElement)("button", { type: "button", className: "bt-tool", "aria-label": t("More message actions"), title: t("More"), "aria-haspopup": "menu", "aria-expanded": panel === "more", onClick: (event) => open("more", event) }, copied ? (0, import_react9.createElement)(CheckIcon) : (0, import_react9.createElement)(DotsIcon)), - panel === "react" ? (0, import_react_dom.createPortal)((0, import_react9.createElement)( - "div", - { ref: pop, className: "bt-react-strip", role: "menu", "aria-label": t("Reactions"), style: place }, - REACTIONS.map((emoji) => (0, import_react9.createElement)("button", { key: emoji, type: "button", role: "menuitem", "aria-label": emoji, onClick: () => { - close(); - react(emoji); - } }, emoji)) - ), document.body) : null, - panel === "more" ? (0, import_react_dom.createPortal)((0, import_react9.createElement)( - "div", - { ref: pop, className: "bt-menu", role: "menu", "aria-label": t("Message actions"), style: place }, - item(t("Copy"), CopyIcon, copy2), - item(t("Download"), DownloadIcon, download2) - ), document.body) : null + { className: inline ? "bt-q-inline" : "bt-q-dock" }, + ...locked ? [(0, import_react7.createElement)("div", { className: "bt-hint bt-read-only", role: "status" }, hostText(roster.readOnly))] : [], + (0, import_react7.createElement)(QuestionView, { key: question.id, question, onAnswer, onDismiss, ownAnswer: question.allowCustom === true, raised: !inline, locked }) ); } -var PEEK_MAX = 160; -var CUT = "…"; -var MARKUP = [/^\s{0,3}(?:`{3,}|~{3,})/, /^\s{0,3}([-*_])(?:\s*\1){2,}\s*$/]; -var isMarkup = (line2) => MARKUP.some((pattern) => pattern.test(line2)); -var graphemes = (line2) => typeof Intl !== "undefined" && typeof Intl.Segmenter === "function" ? [...new Intl.Segmenter().segment(line2)].map((part) => part.segment) : [...line2]; -var clip = (line2) => { - if (line2.length <= PEEK_MAX) return line2; - let kept = ""; - for (const part of graphemes(line2)) { - if (kept.length + part.length > PEEK_MAX - CUT.length) break; - kept += part; +function QuestionDock({ sessionId, useRoster, useCards, useSession, actions }) { + const roster = useRoster((value) => value); + const question = roster.questions?.[sessionId]; + const drawn = useCards?.((state) => state[sessionId]?.[question?.callId] > 0) === true; + const loading = useSession?.(historyLoading) === true; + if (question === void 0 || question.callId && (drawn || loading)) return null; + return (0, import_react7.createElement)(PendingQuestion, { sessionId, question, actions, roster, inline: false }); +} +function AnsweredCell({ node }) { + const data = node.data ?? {}; + const questions = Array.isArray(data.questions) ? data.questions : []; + if (questions.length === 0) { + return data.text ? (0, import_react7.createElement)("div", { className: "bt-urow" }, (0, import_react7.createElement)("div", { className: "bt-ububble" }, data.text)) : null; } - return kept + CUT; -}; -var onlyMarkup = (lines) => lines.length === 1 ? t("{n} line of reasoning", { n: lines.length }) : t("{n} lines of reasoning", { n: lines.length }); -var peekLine = (text2) => { - const lines = text2.split("\n").map((part) => part.replace(/\s+/g, " ").trim()).filter((part) => part !== ""); - const prose = lines.find((line2) => !isMarkup(line2)); - return prose === void 0 ? onlyMarkup(lines) : clip(prose); -}; -function ThinkingRow({ text: text2 }) { - if (typeof text2 !== "string" || text2.trim() === "") return null; - const peek = peekLine(text2); - return (0, import_react9.createElement)( - "details", - { className: "bt-think" }, - (0, import_react9.createElement)( - "summary", - null, - (0, import_react9.createElement)("span", { className: "bt-think-caret", "aria-hidden": true }, "›"), - t("Thought it through"), - peek ? (0, import_react9.createElement)("span", { className: "bt-think-peek" }, peek) : null - ), - (0, import_react9.createElement)("div", { className: "bt-think-text" }, text2) - ); -} -function useFileMentions({ node, openFile, fileMentions, useTurnData }) { - const tail = useTurnData?.("turn-tail"); - const location2 = node.location; - const turn = location2?.kind === "turn" || location2?.kind === "step" ? location2.turn : void 0; - const seq = node.data.finalNode?.seq; - return (0, import_react9.useMemo)(() => { - if (typeof fileMentions !== "function" || turn?.status !== "closed" || seq === void 0) return void 0; - return tail?.closing?.finalNode.seq === seq ? fileMentions({ turn, seq, openFile }) : void 0; - }, [fileMentions, openFile, seq, tail, turn]); -} -function useClosingText(node, useTurnData) { - const tail = useTurnData?.("turn-tail"); - const seq = node.data.finalNode?.seq; - if (!tail?.closing || seq === void 0) return true; - return tail.closing.finalNode?.seq === seq; + const answers = Array.isArray(data.answers) ? data.answers : []; + return (0, import_react7.createElement)("div", { className: "bt-q-inline" }, questions.map((question) => (0, import_react7.createElement)(SettledQuestion, { + key: question.id, + question, + answer: answers.find((answer) => answer.id === question.id) ?? { selected: [] } + }))); } -function friendLead(bot, texts2) { - if (bot?.remote === void 0 || texts2.length === 0) return void 0; - if (texts2.every((text2) => /^Not sent:/.test(text2.trim()))) return void 0; - return (0, import_react9.createElement)("span", { className: "bt-lead", title: bot.name, "data-flybot": "friend-lead" }, (0, import_react9.createElement)(BotMark, { bot, size: 22 })); + +// src/client/secret-card.js +var import_react8 = require("react"); +var MIN_VALUE = 4; +var NONE = {}; +var KeyIcon = () => (0, import_react8.createElement)( + "svg", + { width: 15, height: 15, viewBox: "0 0 20 20", fill: "none", stroke: "currentColor", strokeWidth: 1.5, strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": true }, + (0, import_react8.createElement)("path", { d: "M7 9.5a3.5 3.5 0 1 1 0 .01z" }), + (0, import_react8.createElement)("path", { d: "M10.4 10.6L17 10.6M14.5 10.6v2.4M16.6 10.6v1.8" }) +); +var LockIcon = () => (0, import_react8.createElement)( + "svg", + { width: 14, height: 14, viewBox: "0 0 20 20", fill: "none", stroke: "currentColor", strokeWidth: 1.5, strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": true }, + (0, import_react8.createElement)("path", { d: "M6.5 9V7a3.5 3.5 0 0 1 7 0v2" }), + (0, import_react8.createElement)("rect", { x: 4.5, y: 9, width: 11, height: 8, rx: 2 }) +); +var WarnIcon = () => (0, import_react8.createElement)( + "svg", + { width: 14, height: 14, viewBox: "0 0 20 20", fill: "none", stroke: "currentColor", strokeWidth: 1.5, strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": true }, + (0, import_react8.createElement)("path", { d: "M10 3.5l7 12.5H3z" }), + (0, import_react8.createElement)("path", { d: "M10 8.5v3.5M10 14.2h.01" }) +); +var MASK_BY_CSS = typeof CSS !== "undefined" && typeof CSS.supports === "function" && CSS.supports("-webkit-text-security", "disc"); +var insecure = () => typeof location !== "undefined" && location.protocol === "http:" && !/^(localhost|127(?:\.\d{1,3}){3}|\[::1\])$/.test(location.hostname); +var scopeLabel = (scope, roster) => { + if (scope === "all") return t("All my Bots"); + const names = (scope ?? []).map((id) => roster.byId[id]?.name).filter(Boolean); + return names.length === 0 ? t("No Bot") : names.join(t(" and ")); +}; +var when2 = (time) => Number.isFinite(time) ? new Date(time).toLocaleString(dateLocale(), { month: "short", day: "numeric", hour: "2-digit", minute: "2-digit" }) : ""; +function MaskedInput({ value, onChange, onEnter, label, autoFocus = false }) { + return (0, import_react8.createElement)("input", { + className: "bt-secret-input", + type: MASK_BY_CSS ? "text" : "password", + "data-masked": MASK_BY_CSS ? "" : void 0, + value, + autoFocus, + autoComplete: MASK_BY_CSS ? "off" : "new-password", + autoCorrect: "off", + autoCapitalize: "off", + spellCheck: false, + "data-1p-ignore": "", + "data-lpignore": "true", + "data-bwignore": "", + "data-form-type": "other", + "aria-label": label, + placeholder: t("Paste or type the key"), + onChange: (event) => onChange(event.target.value), + onKeyDown: (event) => { + if (event.key !== "Enter" || event.nativeEvent.isComposing) return; + event.preventDefault(); + onEnter?.(); + } + }); } -function AssistantCell({ node, groupPart, sessionId, openFile, fileMentions, useTurnData, useRoster, useUi, useTurnSteps, useTurnContext, actions }) { - const roster = useRoster((value) => value); - const data = node.data; - const steps = useTurnSteps(String(data.turn)); - const contexts = useTurnContext(String(data.turn)); - const mentions = useFileMentions({ node, openFile, fileMentions, useTurnData }); - const held = groupPart === "reasoning" ? void 0 : heldReplyOf(roster.byId[sessionId], sessionId, data.turn); - const closing = useClosingText(node, useTurnData); - const hidden = Boolean(roster.roomsById[sessionId]) || isHiddenTurn(roster, sessionId, data.turn); - const person = typedBy(roster, sessionId, data.turn); - const texts2 = groupPart === "reasoning" ? [] : data.blocks.filter((block) => block.kind === "text" && block.text.trim() !== "").map((block, index) => index === 0 ? stripTrace(block.text) : block.text).filter((text2) => text2.trim() !== ""); - const running = data.status === "running"; - useLiveNote(actions, sessionId, `text:${node.key ?? data.seq}`, !hidden && running && texts2.length > 0 ? { text: true } : null); - if (hidden) return null; - const reasoning = groupPart === "response" ? "" : turnReasoning(steps, data); - const relays = relayPart(data, groupPart) ? relaysBefore(contexts, steps, data) : []; - if (texts2.length === 0 && !reasoning && relays.length === 0) return null; - const selfId = roster.byId[sessionId]?.id ?? sessionId; - const bubbles = texts2.length ? person ? (0, import_react9.createElement)(PersonMessage, { name: person, texts: texts2, roster, selfId, actions, sessionId, msgKey: `${sessionId}:${data.turn}:${node.key ?? data.seq ?? ""}`, useUi }) : (0, import_react9.createElement)(Bubbles, { texts: texts2, roster, selfId, actions, streaming: running, sessionId, msgKey: `${sessionId}:${data.turn}:${node.key ?? data.seq ?? ""}`, useUi, mentions, lead: friendLead(roster.byId[sessionId], texts2) }) : null; - return (0, import_react9.createElement)( - import_react9.Fragment, - null, - relays.map((relay) => { - const source = relay.source; - const sender = roster.byId[source.senderSessionId] ?? { id: source.senderSessionId, name: source.senderName ?? t("a Bot"), color: "gray" }; - return (0, import_react9.createElement)(EventLine, { - key: relay.seq, - prefix: source.role === "stopped" ? t("Stopped by") : source.role === "reply" ? t("Reply from") : t("Message from"), - bots: [sender], - onOpen: () => { - if (sender.id) actions.openExchange(selfId, sender.id); - }, - whole: true - }); - }), - reasoning ? (0, import_react9.createElement)(ThinkingRow, { text: reasoning }) : null, - held && bubbles ? (0, import_react9.createElement)(HeldReply, { held, roster, sessionId, actions, closing }, bubbles) : bubbles +function ScopeSwitch({ value, onChange, bot }) { + const options = [["all", t("All my Bots")], ["bot", t("Only {name}", { name: bot.name })]]; + return (0, import_react8.createElement)( + "div", + { className: "bt-secret-scope", role: "radiogroup", "aria-label": t("Who may use it") }, + (0, import_react8.createElement)("span", { className: "bt-secret-glide", "data-at": value === "all" ? 0 : 1, "aria-hidden": true }), + options.map(([id, text2]) => (0, import_react8.createElement)("button", { + key: id, + type: "button", + role: "radio", + "aria-checked": value === id, + className: "bt-secret-seg", + onClick: () => onChange(id) + }, text2)) ); } -function HeldReply({ held, roster, sessionId, actions, closing, children }) { - const bot = roster.byId[sessionId]; - const chat = (roster.bots ?? []).find((item) => item.remote?.handle === held.to); - const toName = cleanPeerName(held.toOwner) || (chat ? friendBotOwner(chat) : ownerNameOf({ handle: held.to })) || held.to || t("a friend"); - return (0, import_react9.createElement)( +function SecretHead({ bot, title, detail, name, onClose }) { + return (0, import_react8.createElement)( "div", - { className: "bt-held-reply", "data-state": held.status, "data-held": isApprovalWaiting(held.status) ? "" : void 0 }, - children, - closing ? (0, import_react9.createElement)(HeldReplyActions, { held: { ...held, toName, from: bot?.name }, actions }) : null + { className: "bt-q-head bt-secret-head" }, + (0, import_react8.createElement)("span", { className: "bt-secret-who" }, (0, import_react8.createElement)(BotMark, { bot, size: 28 }), (0, import_react8.createElement)("span", { className: "bt-secret-lock" }, (0, import_react8.createElement)(LockIcon))), + (0, import_react8.createElement)( + "div", + { className: "bt-q-text" }, + (0, import_react8.createElement)("p", { className: "bt-q-title" }, title), + (0, import_react8.createElement)("div", { className: "bt-q-detail" }, (0, import_react8.createElement)("code", { className: "bt-secret-name" }, name), detail ? ` · ${detail}` : "") + ), + onClose ? (0, import_react8.createElement)("button", { type: "button", className: "bt-q-x", "aria-label": t("Cancel the request"), title: t("Cancel"), onClick: onClose }, (0, import_react8.createElement)(CloseIcon)) : null ); } -function PersonMessage({ name, texts: texts2, roster, selfId, actions, sessionId, msgKey, useUi }) { - if (!(texts2 ?? []).some((text2) => String(text2 ?? "").trim() !== "")) return null; - const lead = (0, import_react9.createElement)("span", { className: "bt-lead", title: name }, (0, import_react9.createElement)(PersonMark, { name })); - return (0, import_react9.createElement)( +function SecretCard({ bot, request, roster, actions }) { + const [value, setValue] = (0, import_react8.useState)(""); + const [scope, setScope] = (0, import_react8.useState)("all"); + const [replacing, setReplacing] = (0, import_react8.useState)(false); + const [busy, setBusy] = (0, import_react8.useState)(false); + const [error, setError] = (0, import_react8.useState)(""); + const noteId = (0, import_react8.useId)(); + const secret = (roster.secrets ?? []).find((entry) => entry.name === request.name); + const run = (promise) => { + setBusy(true); + setError(""); + return Promise.resolve(promise).then(() => setValue(""), (failure) => setError(failure?.message ?? String(failure))).finally(() => setBusy(false)); + }; + const cancel = () => run(actions.secretCancel(request.id)); + const long = value.trim().length >= MIN_VALUE; + const save = () => { + if (long && !busy) void run(actions.secretSet({ requestId: request.id, value, scope })); + }; + const allow = () => { + if (!busy) void run(actions.secretAllow(request.id)); + }; + const filling = request.kind === "fill" || replacing; + const warn = insecure() ? (0, import_react8.createElement)("div", { className: "bt-secret-warn", role: "note" }, (0, import_react8.createElement)(WarnIcon), t("This page is not on HTTPS: the key crosses the network unencrypted.")) : null; + return (0, import_react8.createElement)( "div", - { className: "bt-group-msg bt-person-msg", "data-person": name }, - (0, import_react9.createElement)("span", { className: "bt-group-name" }, name), - (0, import_react9.createElement)(Bubbles, { texts: texts2, roster, selfId, actions, sessionId, msgKey, useUi, lead }) + { className: "bt-q bt-secret", role: "group", "aria-describedby": noteId, "data-kind": request.kind }, + (0, import_react8.createElement)(SecretHead, { + bot, + title: request.kind === "allow" ? t("Let {bot} use {name}?", { bot: bot.name, name: request.name }) : t("{name} needs a key", { name: bot.name }), + detail: request.purpose, + name: request.name, + onClose: busy ? void 0 : cancel + }), + request.kind === "allow" ? (0, import_react8.createElement)("div", { className: "bt-secret-saved" }, (0, import_react8.createElement)(KeyIcon), t("Saved for {scope}", { scope: scopeLabel(secret?.scope, roster) })) : null, + filling ? (0, import_react8.createElement)( + "div", + { className: "bt-secret-body" }, + (0, import_react8.createElement)("label", { className: "bt-q-field bt-secret-field" }, (0, import_react8.createElement)(MaskedInput, { value, onChange: setValue, onEnter: save, label: t("Value of {name}", { name: request.name }), autoFocus: replacing })), + request.kind === "fill" ? (0, import_react8.createElement)(ScopeSwitch, { value: scope, onChange: setScope, bot }) : null + ) : null, + (0, import_react8.createElement)( + "div", + { className: "bt-secret-note", id: noteId }, + t("{bot} never sees the value. It reaches {bot}'s shell as ", { bot: bot.name }), + (0, import_react8.createElement)("code", null, `$DSH_SECRET_${request.name}`), + ".", + filling && value !== "" && !long ? t(" At least {count} characters.", { count: MIN_VALUE }) : "" + ), + warn, + error ? (0, import_react8.createElement)("div", { className: "bt-secret-error", role: "alert" }, error) : null, + (0, import_react8.createElement)( + "div", + { className: "bt-q-foot" }, + request.kind === "allow" && !replacing ? (0, import_react8.createElement)("button", { type: "button", className: "bt-q-btn", disabled: busy, onClick: () => setReplacing(true) }, t("Replace value")) : null, + (0, import_react8.createElement)("button", { type: "button", className: "bt-q-btn", disabled: busy, onClick: cancel }, t("Cancel")), + filling ? (0, import_react8.createElement)("button", { type: "button", className: "bt-q-submit", disabled: !long || busy, onClick: save }, busy ? t("Saving…") : t("Save")) : (0, import_react8.createElement)("button", { type: "button", className: "bt-q-submit", disabled: busy, onClick: allow }, busy ? t("Allowing…") : t("Allow")) + ) ); } -function EventLine({ prefix, bots, joiner = t("and"), onOpen, whole = false }) { - if (whole && bots.length === 1) { - return (0, import_react9.createElement)( +function SettledSecret({ bot, request, roster }) { + const canceled = request.status === "canceled"; + const text2 = canceled ? t("{bot} asked for {name}", { bot: bot.name, name: request.name }) : request.status === "allowed" ? t("Allowed {bot} to use {name}", { bot: bot.name, name: request.name }) : t("Saved {name}", { name: request.name }); + return (0, import_react8.createElement)( + "div", + { className: "bt-q bt-q-settled bt-secret bt-secret-done", role: "group", "aria-label": text2, "data-status": request.status }, + (0, import_react8.createElement)( "div", - { className: "bt-event" }, - (0, import_react9.createElement)( - "button", - { type: "button", className: "bt-event-whole", title: bots[0].name, onClick: () => onOpen?.(bots[0]) }, - (0, import_react9.createElement)("span", null, prefix), - (0, import_react9.createElement)(BotMark, { bot: bots[0], size: 16 }), - (0, import_react9.createElement)("span", { className: "bt-event-name" }, bots[0].name) - ) - ); - } - const parts = []; - parts.push((0, import_react9.createElement)("span", { key: "p" }, prefix)); - bots.forEach((bot, index) => { - if (index > 0) parts.push((0, import_react9.createElement)("span", { key: `j${index}` }, joiner)); - parts.push((0, import_react9.createElement)( - "button", - { key: bot.id ?? index, type: "button", onClick: () => onOpen?.(bot) }, - (0, import_react9.createElement)(BotMark, { bot, size: 16 }), - bot.name - )); - }); - return (0, import_react9.createElement)("div", { className: "bt-event" }, parts); -} -var heldForm = (text2) => String(text2 ?? "").replace(/\s+/g, " ").trim().replace(/^(to (the )?face[ -]?room|to the room)\s*:\s*/i, "").trim(); -var sameName = (a, b) => String(a ?? "").replace(/\s+/g, " ").trim().toLowerCase() === String(b ?? "").replace(/\s+/g, " ").trim().toLowerCase(); -function isHeldLine(room2, text2, bot) { - if (room2?.origin !== "face-to-face") return false; - const line2 = heldForm(text2); - if (!line2) return false; - return (room2.pendingApprovals ?? []).some((item) => { - if (item?.kind !== "face_room_message") return false; - if (bot !== void 0 && String(item.from ?? "").trim() !== "" && !sameName(item.from, bot.name)) return false; - const held = heldForm(item.text); - return held !== "" && (held === line2 || held.length >= 799 && line2.startsWith(held)); - }); + { className: "bt-q-head" }, + (0, import_react8.createElement)("span", { className: "bt-secret-who" }, (0, import_react8.createElement)(BotMark, { bot, size: 22 })), + (0, import_react8.createElement)("div", { className: "bt-q-text" }, (0, import_react8.createElement)( + "p", + { className: "bt-q-title" }, + text2, + canceled ? null : (0, import_react8.createElement)("span", { className: "bt-secret-scope-tag" }, ` · ${scopeLabel(request.scope, roster)}`) + )), + canceled ? (0, import_react8.createElement)("span", { className: "bt-q-badge" }, t("Canceled")) : (0, import_react8.createElement)("span", { className: "bt-q-check bt-secret-check", title: t("Done") }, (0, import_react8.createElement)(CheckIcon)) + ) + ); } -var HELD_SENDING_MS = 6e4; -function heldLineState(room2, { seq, botId, text: text2 } = {}, now = Date.now()) { - if (room2?.origin !== "face-to-face" || !Array.isArray(room2.heldLines)) return void 0; - const said = String(text2 ?? "").trim(); - const entry = room2.heldLines.filter((item) => seq !== void 0 && item?.seq !== void 0 ? item.seq === seq : item?.botId === botId && String(item?.text ?? "").trim() === said).at(-1); - if (entry === void 0) return void 0; - if (entry.status === "rejected") return "rejected"; - if (entry.status === "sending" && now - (Number(entry.at) || 0) > HELD_SENDING_MS) return void 0; - if (entry.status !== "pending" && entry.status !== "sending") return void 0; - return room2.face?.status === "left" || room2.face?.status === "closed" ? "rejected" : "held"; +function RequestView({ bot, request, roster, actions }) { + return request.status === "pending" ? (0, import_react8.createElement)(SecretCard, { key: `${request.id}:${request.kind}`, bot, request, roster, actions }) : (0, import_react8.createElement)(SettledSecret, { bot, request, roster }); } -function GroupMessage({ bot, text: text2, room: room2, roster, actions, msgKey, useUi, seq }) { - if (String(text2 ?? "").trim() === "") return null; - const held = heldLineState(room2, { seq, botId: bot.id, text: text2 }); - if (held === "rejected") { - return (0, import_react9.createElement)( - "div", - { className: "bt-event bt-not-sent", "data-flybot": "held-not-sent", role: "status" }, - (0, import_react9.createElement)(BotMark, { bot, size: 16 }), - (0, import_react9.createElement)("span", null, t("{name} · Not sent", { name: bot.name })) - ); +function SecretToolCell({ sessionId, callId, name, done, content, roster, actions }) { + const bot = roster.byId[sessionId]; + if (!bot) return null; + const request = (roster.secretRequests?.[bot.id] ?? []).find((entry) => entry.callId === callId && (entry.sessionId ?? sessionId) === sessionId); + if (request) return (0, import_react8.createElement)("div", { className: "bt-q-inline" }, (0, import_react8.createElement)(RequestView, { bot, request, roster, actions })); + if (done && /already saved/.test(contentText(content))) { + return (0, import_react8.createElement)("div", { className: "bt-event" }, (0, import_react8.createElement)("span", { className: "bt-secret-event" }, (0, import_react8.createElement)(KeyIcon), t("{bot} is using {name}", { bot: bot.name, name: name ?? t("a saved key") }))); } - if (held === "held" || isHeldLine(room2, text2, bot)) return null; - const lead = (0, import_react9.createElement)( - "button", - { type: "button", className: "bt-lead", "aria-label": t("Open {name}'s chat", { name: bot.name }), onClick: () => actions.openSession(bot.id) }, - (0, import_react9.createElement)(BotMark, { bot, size: 22 }) - ); - const name = (0, import_react9.createElement)( - "span", - { className: "bt-group-name", style: { color: inkText(colorOf(bot)) } }, - faceBotLabel(room2, roster, bot) ?? bot.name, - room2?.admin === bot.id ? (0, import_react9.createElement)("span", { className: "bt-group-role" }, t("Admin")) : null - ); - return (0, import_react9.createElement)( - "div", - { className: "bt-group-msg" }, - name, - (0, import_react9.createElement)(Bubbles, { texts: [text2], roster, selfId: bot.id, actions, sessionId: room2?.id, msgKey, useUi, lead }) - ); -} -function remoteSpeaker(source, roster, sessionId) { - const handle = source.remote.handle; - const member = (roster.remoteBots ?? []).find((bot2) => bot2.handle === handle); - const chat = (roster.bots ?? []).find((bot2) => bot2.remote?.handle === handle); - const room2 = roster.roomsById?.[sessionId]; - const seat = faceSeatLook(room2, handle); - const faceSeat = room2?.origin === "face-to-face" ? faceSeats(room2, roster, { left: true }).find((item) => !item.mine && normRemoteHandle(item.record?.handle) === normRemoteHandle(handle)) : void 0; - const name = faceSeat ? faceSeatLabel(faceSeat) : remoteBotTitle({ - displayName: chat?.name ?? member?.name ?? source.senderName ?? handle, - ownerName: member?.homeName || member?.owner || chat?.remote?.homeName || chat?.remote?.owner, - handle, - isChief: member?.isChief ?? chat?.remote?.isChief - }); - const bot = seat ? lookBotForRemote({ handle, displayName: name, ...seat }, roster) : chat ?? lookBotForRemote({ - handle, - displayName: name, - color: member?.color, - avatar: member?.avatar ?? (member?.shape ? { shape: member.shape } : void 0) - }, roster); - const carrier = cleanCarrier(chat?.name ?? member?.name); - return { bot, member, name, handle, carrier, chatId: chat ? currentPart(chat) : void 0 }; + return null; } -function faceSeatLook(room2, handle) { - if (room2?.origin !== "face-to-face" || !handle) return void 0; - const seats = (room2.participants ?? []).filter((p) => p?.remote && (p.handle === handle || p.id === handle)); - const seat = seats.find((p) => p.leftSeq === void 0) ?? seats.at(-1); - const look = {}; - if (typeof seat?.color === "string" && seat.color) look.color = seat.color; - if (seat?.avatar && typeof seat.avatar === "object") look.avatar = seat.avatar; - return Object.keys(look).length ? look : void 0; +function SecretDock({ sessionId, useRoster, useCards, useSession, actions }) { + const roster = useRoster((value) => value); + const shown = useCards?.((state) => state[sessionId]) ?? NONE; + const loading = useSession?.(historyLoading) === true; + const bot = roster.byId[sessionId]; + if (!bot || currentPart(bot) !== sessionId) return null; + const drawn = (entry) => entry.callId && entry.sessionId === sessionId && (loading || shown[entry.callId] > 0); + const waiting = (roster.secretRequests?.[bot.id] ?? []).filter((entry) => entry.status === "pending" && !drawn(entry)); + if (waiting.length === 0) return null; + return (0, import_react8.createElement)("div", { className: "bt-q-dock bt-secret-dock" }, waiting.map((request) => (0, import_react8.createElement)(SecretCard, { key: `${request.id}:${request.kind}`, bot, request, roster, actions }))); } -var cleanCarrier = (value) => typeof value === "string" && value.trim() && !/[@()]/.test(value) ? value.trim() : ""; -function RemoteMessage({ source, text: text2, roster, actions, sessionId, msgKey, useUi }) { - const room2 = roster.roomsById?.[sessionId]; - const face = room2?.origin === "face-to-face"; - const { bot, member, name, handle, carrier, chatId } = remoteSpeaker(source, roster, sessionId); - const files = Array.isArray(source.attachments) ? source.attachments : []; - const words = stripTrace(text2); - if (String(words ?? "").trim() === "" && files.length === 0) return null; - const person = typeof source.remote.typedBy === "string" ? source.remote.typedBy : ""; - if (person) { - return (0, import_react9.createElement)( - "div", - { className: "bt-group-msg bt-person-msg bt-remote-msg", "data-person": person, "data-remote": handle }, - // One short header: the person, and the Bot that carried the line only when it is known (#275). - (0, import_react9.createElement)( - "span", - { className: "bt-group-name", title: handle }, - person, - carrier ? (0, import_react9.createElement)("span", { className: "bt-remote-handle" }, t("via {name}", { name: carrier })) : null - ), - String(words ?? "").trim() === "" ? null : (0, import_react9.createElement)(Bubbles, { texts: [words], roster, selfId: bot.id, actions, sessionId, msgKey, useUi, lead: (0, import_react9.createElement)("span", { className: "bt-lead", title: person }, (0, import_react9.createElement)(PersonMark, { name: person })) }), - files.length ? (0, import_react9.createElement)(RemoteFiles, { files, plugin: member?.plugin ?? "social", botId: roster.byId[sessionId]?.id ?? sessionId }) : null - ); - } - const mark = (0, import_react9.createElement)(BotMark, { bot, size: 22 }); - const lead = chatId ? (0, import_react9.createElement)("button", { type: "button", className: "bt-lead", title: handle, "aria-label": t("Open {name}'s chat", { name }), onClick: () => actions.openSession(chatId) }, mark) : (0, import_react9.createElement)("span", { className: "bt-lead", title: handle }, mark); - return (0, import_react9.createElement)( +var scopeMode = (scope) => scope === "all" ? "all" : scope.length === 0 ? "none" : "some"; +function ScopeEditor({ secret, roster, onSave }) { + const [mode, setMode] = (0, import_react8.useState)(scopeMode(secret.scope)); + const [picked, setPicked] = (0, import_react8.useState)(secret.scope === "all" ? [] : secret.scope); + const toggle = (id) => setPicked((list) => list.includes(id) ? list.filter((item) => item !== id) : [...list, id]); + const next = mode === "all" ? "all" : mode === "none" ? [] : picked; + const same2 = next === "all" ? secret.scope === "all" : secret.scope !== "all" && [...next].sort().join() === [...secret.scope].sort().join(); + return (0, import_react8.createElement)( "div", - { className: "bt-group-msg bt-remote-msg", "data-remote": handle }, - // A face room's header is the one name alone; the handle is on hover only (#457). - face ? (0, import_react9.createElement)("span", { className: "bt-group-name", style: { color: inkText(colorOf(bot)) }, title: handle }, name) : (0, import_react9.createElement)( - "span", - { className: "bt-group-name", style: { color: inkText(colorOf(bot)) } }, - name, - (0, import_react9.createElement)("span", { className: "bt-remote-handle", title: source.remote.home ?? handle, "aria-label": t("Friend's Bot") }, (0, import_react9.createElement)(GlobeMark), handle) + { className: "bt-secret-edit-block" }, + (0, import_react8.createElement)("div", { className: "bt-secret-edit-label" }, t("Who may use it")), + (0, import_react8.createElement)( + "div", + { className: "bt-secret-radios", role: "radiogroup", "aria-label": t("Who may use {name}", { name: secret.name }) }, + [["all", t("All my Bots")], ["some", t("Chosen Bots")], ["none", t("No Bot")]].map(([id, text2]) => (0, import_react8.createElement)( + "label", + { key: id, className: "bt-secret-radio" }, + (0, import_react8.createElement)("input", { type: "radio", name: `scope-${secret.name}`, checked: mode === id, onChange: () => setMode(id) }), + text2 + )) ), - String(words ?? "").trim() === "" ? null : (0, import_react9.createElement)(Bubbles, { texts: [words], roster, selfId: bot.id, actions, sessionId, msgKey, useUi, lead }), - files.length ? (0, import_react9.createElement)(RemoteFiles, { files, plugin: member?.plugin ?? "social", botId: roster.byId[sessionId]?.id ?? sessionId }) : null - ); -} -function FirstContact({ source, roster, sessionId }) { - const person = typeof source.remote.typedBy === "string" ? source.remote.typedBy : ""; - const who = person || remoteSpeaker(source, roster).name; - const bot = roster.byId[sessionId]?.name ?? t("your Bot"); - return (0, import_react9.createElement)("div", { className: "bt-event", "data-first-contact": source.remote.handle }, (0, import_react9.createElement)("span", null, t("{name} is talking to {bot} through Bot Social", { name: who, bot }))); -} -function GlobeMark() { - return (0, import_react9.createElement)( - "svg", - { width: 10, height: 10, viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: 2, strokeLinecap: "round", "aria-hidden": true }, - (0, import_react9.createElement)("circle", { cx: 12, cy: 12, r: 9 }), - (0, import_react9.createElement)("path", { d: "M3 12h18M12 3a14 14 0 0 1 0 18M12 3a14 14 0 0 0 0 18" }) + mode === "some" ? (0, import_react8.createElement)("div", { className: "bt-secret-bots" }, roster.bots.map((bot) => (0, import_react8.createElement)("button", { + key: bot.id, + type: "button", + className: "bt-secret-bot", + "aria-pressed": picked.includes(bot.id), + onClick: () => toggle(bot.id) + }, (0, import_react8.createElement)(BotAvatar, { bot, size: 20, badge: false, live: false }), bot.name, picked.includes(bot.id) ? (0, import_react8.createElement)(CheckIcon) : null))) : null, + (0, import_react8.createElement)( + "div", + { className: "bt-secret-row-actions" }, + (0, import_react8.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", disabled: same2, onClick: () => onSave(next) }, t("Save access")) + ) ); } -function RemoteFiles({ files, plugin, botId }) { - const [busy, setBusy] = (0, import_react9.useState)(""); - const [error, setError] = (0, import_react9.useState)(""); - const download2 = async (file) => { - setBusy(file.id); - setError(""); - try { - const got = await call(`flybot/${plugin}/attachment`, { id: file.id, botId }); - const value = got?.value ?? got; - const bytes = Uint8Array.from(atob(value.content_base64), (c) => c.charCodeAt(0)); - const url = URL.createObjectURL(new Blob([bytes], { type: "application/octet-stream" })); - const link = Object.assign(document.createElement("a"), { href: url, download: value.name ?? file.name }); - link.click(); - setTimeout(() => URL.revokeObjectURL(url), 1e4); - } catch (reason) { - setError(String(reason?.message ?? reason)); - } finally { - setBusy(""); - } +function ValueEditor({ secret, onSave }) { + const [value, setValue] = (0, import_react8.useState)(""); + const [replaced, setReplaced] = (0, import_react8.useState)(false); + const long = value.trim().length >= MIN_VALUE; + const save = () => { + if (!long) return; + void Promise.resolve(onSave(value)).then((ok) => { + if (ok) { + setValue(""); + setReplaced(true); + } + }); }; - return (0, import_react9.createElement)( + const edit = (next) => { + setValue(next); + setReplaced(false); + }; + return (0, import_react8.createElement)( "div", - { className: "bt-file-chips" }, - files.map((file) => (0, import_react9.createElement)( - "button", - { key: file.id, type: "button", className: "bt-file-chip", disabled: busy === file.id, title: `Download ${file.name} (untrusted file from a remote Bot)`, onClick: () => download2(file) }, - `📎 ${file.name} · ${fileSize(file.size)}` - )), - error ? (0, import_react9.createElement)("span", { className: "bt-file-error", role: "alert" }, error) : null + { className: "bt-secret-edit-block" }, + (0, import_react8.createElement)("div", { className: "bt-secret-edit-label" }, t("Replace the value")), + (0, import_react8.createElement)( + "div", + { className: "bt-secret-inline" }, + (0, import_react8.createElement)("label", { className: "bt-q-field bt-secret-field" }, (0, import_react8.createElement)(MaskedInput, { value, onChange: edit, onEnter: save, label: t("New value of {name}", { name: secret.name }) })), + (0, import_react8.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", disabled: !long, onClick: save }, t("Replace")) + ), + value !== "" && !long ? (0, import_react8.createElement)("div", { className: "bt-set-hint" }, t("At least {count} characters.", { count: MIN_VALUE })) : replaced ? (0, import_react8.createElement)("div", { className: "bt-set-hint bt-secret-ok", role: "status" }, (0, import_react8.createElement)(CheckIcon), t("Replaced. Bots get the new value from their next command.")) : null ); } -function GroupEvent({ prefix, room: room2, name, roster, actions }) { - return (0, import_react9.createElement)( +function SecretRow({ secret, roster, actions, run, open, onToggle }) { + const [confirm, setConfirm] = (0, import_react8.useState)(false); + (0, import_react8.useEffect)(() => { + if (!open) setConfirm(false); + }, [open]); + const asker = secret.requestedBy ? roster.byId[secret.requestedBy]?.name ?? t("a deleted Bot") : t("you"); + const allowed = secret.scope === "all" ? [] : secret.scope.map((id) => roster.byId[id]).filter(Boolean); + const meta = [t("Asked by {name}", { name: asker }), t("added {time}", { time: when2(secret.createdAt) }), secret.lastUsedAt ? t("last used {time}", { time: when2(secret.lastUsedAt) }) : t("not used yet")].join(" · "); + return (0, import_react8.createElement)( "div", - { className: "bt-event" }, - (0, import_react9.createElement)("span", null, prefix), - room2 ? (0, import_react9.createElement)("button", { type: "button", onClick: () => actions.openSession(room2.id) }, (0, import_react9.createElement)(RoomAvatar, { room: room2, roster, size: 16 }), room2.name) : name ? (0, import_react9.createElement)("span", null, name) : null + { className: "bt-secret-item", "data-open": open ? "" : void 0 }, + (0, import_react8.createElement)( + "button", + { type: "button", className: "bt-set-row bt-secret-summary", "aria-expanded": open, onClick: onToggle }, + (0, import_react8.createElement)( + "span", + { className: "bt-set-copy" }, + (0, import_react8.createElement)("span", { className: "bt-secret-title" }, (0, import_react8.createElement)(KeyIcon), (0, import_react8.createElement)("code", { className: "bt-secret-name" }, secret.name), secret.purpose ? (0, import_react8.createElement)("span", { className: "bt-secret-purpose" }, secret.purpose) : null), + (0, import_react8.createElement)("span", { className: "bt-set-hint" }, meta) + ), + (0, import_react8.createElement)( + "span", + { className: "bt-set-control" }, + secret.scope === "all" ? (0, import_react8.createElement)("span", { className: "bt-tag" }, t("All my Bots")) : allowed.length === 0 ? (0, import_react8.createElement)("span", { className: "bt-tag" }, t("No Bot")) : (0, import_react8.createElement)("span", { className: "bt-secret-faces", title: allowed.map((bot) => bot.name).join(", ") }, allowed.slice(0, 5).map((bot) => (0, import_react8.createElement)(BotAvatar, { key: bot.id, bot, size: 20, badge: false, live: false }))) + ) + ), + open ? (0, import_react8.createElement)( + "div", + { className: "bt-secret-edit" }, + (0, import_react8.createElement)(ScopeEditor, { key: JSON.stringify(secret.scope), secret, roster, onSave: (scope) => run(actions.secretUpdate(secret.name, scope)) }), + (0, import_react8.createElement)(ValueEditor, { secret, onSave: (value) => run(actions.secretSet({ name: secret.name, value })) }), + (0, import_react8.createElement)( + "div", + { className: "bt-secret-row-actions" }, + confirm ? [ + (0, import_react8.createElement)("span", { key: "q", className: "bt-set-hint" }, t("Delete {name}? Bots lose it at once.", { name: secret.name })), + (0, import_react8.createElement)("button", { key: "no", type: "button", className: "bt-soft bt-soft-sm", onClick: () => setConfirm(false) }, t("Keep")), + (0, import_react8.createElement)("button", { key: "yes", type: "button", className: "bt-soft bt-soft-sm bt-secret-danger", onClick: () => run(actions.secretDelete(secret.name)) }, t("Delete")) + ] : (0, import_react8.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm bt-secret-danger", onClick: () => setConfirm(true) }, (0, import_react8.createElement)(TrashIcon), t("Delete")) + ) + ) : null ); } -function MemoryEvent({ sessionId, scope, result, roster, actions, findBot }) { - if (!/^(Saved to|Updated in|Removed from) /.test(result)) return null; - const self = roster.byId[sessionId]; - const team = /^(team|团队|共享)$/i.test(String(scope ?? "").trim()); - const other = team || !scope ? void 0 : findBot(scope); - const target = other && other.id !== self?.id ? other : self; - if (!target) return null; - const label = team ? t("Team memory updated") : target === self ? t("Memory updated") : t("Updated {name}'s memory", { name: target.name }); - return (0, import_react9.createElement)( +function AddSecret({ actions, run, onDone }) { + const [name, setName] = (0, import_react8.useState)(""); + const [purpose, setPurpose] = (0, import_react8.useState)(""); + const [value, setValue] = (0, import_react8.useState)(""); + const ready = /^[A-Za-z][\w-]{0,63}$/.test(name.trim()) && value.trim().length >= MIN_VALUE; + const save = () => { + if (!ready) return; + void run(actions.secretSet({ name, purpose, value, scope: "all" })).then((ok) => { + if (ok) { + setValue(""); + onDone(); + } + }); + }; + return (0, import_react8.createElement)( "div", - { className: "bt-event" }, - (0, import_react9.createElement)("button", { type: "button", title: result, onClick: () => actions.openMemory(target.id) }, (0, import_react9.createElement)(MemoryIcon), label) + { className: "bt-set-row bt-set-paste bt-secret-add" }, + (0, import_react8.createElement)("input", { className: "bt-input", "aria-label": t("Key name"), placeholder: t("NAME, e.g. GITHUB_TOKEN"), value: name, spellCheck: false, autoComplete: "off", autoFocus: true, onChange: (event) => setName(event.target.value.toUpperCase()) }), + (0, import_react8.createElement)("input", { className: "bt-input", "aria-label": t("What it is for"), placeholder: t("What it is for"), value: purpose, autoComplete: "off", onChange: (event) => setPurpose(event.target.value) }), + (0, import_react8.createElement)("label", { className: "bt-q-field bt-secret-field" }, (0, import_react8.createElement)(MaskedInput, { value, onChange: setValue, onEnter: save, label: t("Value") })), + (0, import_react8.createElement)( + "div", + { className: "bt-secret-row-actions" }, + (0, import_react8.createElement)("span", { className: "bt-set-hint" }, t("New keys are for all your Bots; change who may use it afterwards.")), + (0, import_react8.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: onDone }, t("Cancel")), + (0, import_react8.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", disabled: !ready, onClick: save }, t("Save key")) + ) ); } -function PartLine({ time }) { - return (0, import_react9.createElement)("div", { className: "bt-part-line", role: "separator" }, Number.isFinite(time) ? (0, import_react9.createElement)("span", null, dayLabel(time)) : null); +function SecretsPage({ roster, actions, run, Section }) { + const [open, setOpen] = (0, import_react8.useState)(null); + const [adding, setAdding] = (0, import_react8.useState)(false); + const secrets = roster.secrets ?? []; + return [ + (0, import_react8.createElement)( + "p", + { key: "about", className: "bt-secret-about" }, + t("Keys your Bots asked for. Bots never see the values: they use them as "), + (0, import_react8.createElement)("code", null, "$DSH_SECRET_NAME"), + t(" in their shell, and every value is replaced by "), + (0, import_react8.createElement)("code", null, "[secret:NAME]"), + t(" before anything goes to a model.") + ), + (0, import_react8.createElement)( + Section, + { key: "list", title: t("Secrets · {count}", { count: secrets.length }) }, + secrets.length === 0 && !adding ? (0, import_react8.createElement)("div", { className: "bt-set-row" }, (0, import_react8.createElement)("span", { className: "bt-set-hint" }, t("No keys yet. When a Bot needs one, it shows a card in its chat."))) : null, + secrets.map((secret) => (0, import_react8.createElement)(SecretRow, { key: secret.name, secret, roster, actions, run, open: open === secret.name, onToggle: () => setOpen(open === secret.name ? null : secret.name) })), + adding ? (0, import_react8.createElement)(AddSecret, { actions, run, onDone: () => setAdding(false) }) : null + ), + adding ? null : (0, import_react8.createElement)("div", { key: "add", className: "bt-actions" }, (0, import_react8.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: () => setAdding(true) }, t("Add a key"))) + ]; } -var scrollParent = (node) => { - for (let at = node.parentElement; at; at = at.parentElement) { - if (/(auto|scroll)/.test(getComputedStyle(at).overflowY)) return at; - } - return null; -}; -function EarlierParts({ sessionId, roster, actions, useUi }) { - const [view, setView] = (0, import_react9.useState)({ items: [], cursor: void 0, loading: true, failed: false, startedAt: null }); - const top = (0, import_react9.useRef)(null); - const busy = (0, import_react9.useRef)(false); - const alive = (0, import_react9.useRef)(true); - const restore = (0, import_react9.useRef)(null); - const load = (cursor) => { - if (busy.current) return; - busy.current = true; - setView((current) => ({ ...current, loading: true, failed: false })); - actions.earlier(sessionId, cursor).then((page) => { - busy.current = false; - if (!alive.current) return; - const scroller = top.current && scrollParent(top.current); - if (scroller) restore.current = { scroller, fromEnd: scroller.scrollHeight - scroller.scrollTop }; - setView((current) => ({ items: [...page.items, ...current.items], cursor: page.cursor, loading: false, failed: false, startedAt: page.startedAt ?? current.startedAt })); - }, () => { - busy.current = false; - if (alive.current) setView((current) => ({ ...current, loading: false, failed: true })); - }); - }; - (0, import_react9.useEffect)(() => { - alive.current = true; - load(void 0); - return () => { - alive.current = false; - }; - }, [sessionId]); - (0, import_react9.useLayoutEffect)(() => { - const pending = restore.current; - restore.current = null; - if (pending) pending.scroller.scrollTop = pending.scroller.scrollHeight - pending.fromEnd; - }, [view.items]); - (0, import_react9.useEffect)(() => { - const node = top.current; - if (!node || view.loading || view.failed || !view.cursor || typeof IntersectionObserver !== "function") return void 0; - const observer = new IntersectionObserver((entries2) => { - if (entries2.some((entry) => entry.isIntersecting)) load(view.cursor); - }, { root: scrollParent(node), rootMargin: "600px 0px 0px 0px" }); - observer.observe(node); - return () => observer.disconnect(); - }, [view.cursor, view.loading, view.failed]); - const selfId = roster.byId[sessionId]?.id; - const keyOf = (item) => `${item.sessionId ?? sessionId}:${item.turn ?? ""}:${item.seq ?? item.time ?? ""}`; - const rows = []; - for (const item of view.items) { - if (item.kind === "bot") { - if (String(item.text ?? "").trim() === "") continue; - const last = rows.at(-1); - if (last?.kind === "bot") { - last.texts.push(item.text); - last.msgKeys.push(keyOf(item)); - } else rows.push({ kind: "bot", texts: [item.text], msgKeys: [keyOf(item)] }); +var SECRET_CSS = ` +.bt-secret-head{align-items:center;gap:10px} +.bt-secret-who{position:relative;flex:none;display:inline-flex} +.bt-secret-lock{position:absolute;right:-5px;bottom:-4px;width:16px;height:16px;border-radius:50%;background:var(--bt-main);color:var(--bt-ink-2);display:inline-flex;align-items:center;justify-content:center;box-shadow:0 0 0 1.5px var(--bt-bubble)} +.bt-secret-lock svg{width:11px;height:11px} +.bt-secret-name{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:12px;line-height:16px;padding:1px 6px;border-radius:5px;background:var(--bt-hover);color:var(--bt-ink)} +.bt-secret-body{display:flex;flex-direction:column;gap:8px} +.bt-secret-field{align-items:center;min-height:36px} +.bt-secret-input{flex:1;width:100%;min-width:0;margin:0;padding:0;border:0;outline:none;background:transparent;color:var(--bt-ink);font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:14px;line-height:20px;letter-spacing:.04em} +.bt-secret-input[data-masked]{-webkit-text-security:disc} +.bt-secret-input::placeholder{color:var(--bt-ink-3);font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",system-ui,sans-serif;letter-spacing:0} +.bt-secret-scope{position:relative;display:inline-grid;grid-template-columns:1fr 1fr;align-self:flex-start;padding:2px;border-radius:999px;background:var(--bt-hover)} +.bt-secret-glide{position:absolute;top:2px;bottom:2px;left:2px;width:calc(50% - 2px);border-radius:999px;background:var(--bt-main);box-shadow:0 1px 3px rgba(20,20,20,.08),inset 0 0 0 .5px color-mix(in srgb,var(--bt-ink) 6%,transparent);transition:${spring(SPRING_SHAPE, "transform")}} +.bt-secret-glide[data-at="1"]{transform:translateX(100%)} +.bt-secret-seg{position:relative;z-index:1;height:28px;padding:0 14px;border:0;border-radius:999px;background:none;color:var(--bt-ink-2);font:inherit;font-size:13px;white-space:nowrap;cursor:pointer;transition:color .16s ease} +.bt-secret-seg[aria-checked=true]{color:var(--bt-ink);font-weight:500} +.bt-secret-seg:focus-visible{outline:2px solid var(--bt-accent);outline-offset:-2px} +/* A key card asks the user to act, so the process group carrying it stays open in every + work-details mode (same override as .bt-group-replies in styles.js). */ +[data-step-process]:has(.bt-secret)>div:first-child{display:none!important} +[data-step-process]:has(.bt-secret)>[data-step-process-body]{display:block!important;content-visibility:visible!important;max-height:none!important;overflow:visible!important;mask-image:none!important;scrollbar-gutter:auto!important} +.bt-secret-ok{display:flex;align-items:center;gap:4px;margin-top:6px;animation:bt-secret-in .22s cubic-bezier(.22,1,.36,1)} +.bt-secret-ok svg{width:13px;height:13px;color:#16a34a} +@keyframes bt-secret-in{from{opacity:0;transform:translateY(-2px)}} +.bt-secret-saved{display:flex;align-items:center;gap:6px;font-size:13px;line-height:18px;color:var(--bt-ink-2)} +.bt-secret-note{font-size:12px;line-height:17px;color:var(--bt-ink-3)} +.bt-secret-note code,.bt-secret-about code{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:11.5px} +.bt-secret-warn{display:flex;align-items:center;gap:6px;padding:6px 10px;border-radius:8px;font-size:12px;line-height:16px;color:#b25e09;background:color-mix(in srgb,#f59e0b 12%,transparent)} +.bt-secret-error{font-size:12px;line-height:16px;color:#e02135} +.bt-secret .bt-q-btn:disabled{opacity:.5;cursor:default} +.bt-secret-done .bt-q-head{align-items:center} +.bt-secret-done .bt-q-title{font-size:13px;line-height:18px;color:var(--bt-ink-2);font-weight:500} +.bt-secret-scope-tag{font-weight:400;color:var(--bt-ink-3)} +.bt-secret-check{color:var(--bt-green,#22a06b);animation:bt-secret-pop ${SPRING_POP.duration}ms ${SPRING_POP.easing} backwards} +.bt-secret-done{animation:bt-secret-settle .32s cubic-bezier(.22,1,.36,1) backwards} +@keyframes bt-secret-pop{from{opacity:0;transform:scale(.4)}} +@keyframes bt-secret-settle{from{opacity:.4;transform:scale(.985)}} +.bt-secret-event{display:inline-flex;align-items:center;gap:5px} +.bt-secret-dock{flex-direction:column;align-items:center} +.bt-secret-dock .bt-q{max-width:min(550px,calc(100% - 32px))} +.bt-secret-about{margin:0 0 12px;padding:0 4px;font-size:12px;line-height:17px;color:var(--bt-ink-2)} +.bt-secret-title{display:flex;align-items:center;gap:6px;min-width:0;color:var(--bt-ink)} +.bt-secret-title svg{flex:none;color:var(--bt-ink-2)} +.bt-secret-purpose{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--bt-ink-2)} +.bt-secret-faces{display:inline-flex} +.bt-secret-faces>*+*{margin-left:-6px} +.bt-secret-edit{display:flex;flex-direction:column;gap:14px;padding:4px 12px 14px;animation:bt-q-in ${SPRING_SHAPE.duration}ms ${SPRING_SHAPE.easing} backwards,bt-veil-in .2s ease-out backwards} +.bt-secret-edit-block{display:flex;flex-direction:column;gap:8px} +.bt-secret-edit-label{font-size:12px;line-height:16px;font-weight:600;color:var(--bt-ink-2)} +.bt-secret-radios{display:flex;gap:16px;font-size:13px} +.bt-secret-radio{display:inline-flex;align-items:center;gap:6px;cursor:pointer} +.bt-secret-bots{display:flex;flex-wrap:wrap;gap:6px} +.bt-secret-bot{display:inline-flex;align-items:center;gap:6px;height:30px;padding:0 10px 0 5px;border-radius:999px;border:.5px solid var(--bt-line);background:var(--bt-card);color:var(--bt-ink-2);font:inherit;font-size:12px;cursor:pointer;transition:background-color .12s ease,color .12s ease,border-color .12s ease} +.bt-secret-bot[aria-pressed=true]{background:var(--bt-active);color:var(--bt-ink);border-color:var(--bt-line-2)} +.bt-secret-bot svg{width:13px;height:13px} +.bt-secret-inline{display:flex;gap:8px;align-items:center} +.bt-secret-row-actions{display:flex;align-items:center;justify-content:flex-end;gap:8px;flex-wrap:wrap} +.bt-secret-row-actions .bt-set-hint{flex:1} +.bt-secret-danger{color:#e02135;display:inline-flex;align-items:center;gap:4px} +.bt-secret-danger svg{width:13px;height:13px} +.bt-secret-add .bt-input{height:34px} +@media (prefers-reduced-motion:reduce){.bt-secret-glide{transition:none}.bt-secret-check,.bt-secret-done,.bt-secret-ok,.bt-secret-edit{animation:none}} +`; + +// src/client/face-seats.js +function faceSeats(room2, roster, { left = false } = {}) { + const records = (room2.presentedParticipants ?? room2.participants ?? []).filter((p) => p.kind !== "human"); + const seat = (p) => { + const local = p.remote ? void 0 : roster.byId[p.id]; + return { + key: p.handle || p.id, + local, + record: p, + person: local ? seatOwnerName(p.ownerName, p.handle) || cleanPeerName(roster.self?.displayName, "") : ownerNameOf(p), + bot: local ? local.name : remoteBotName(p, "Bot"), + mine: Boolean(local) + }; + }; + const seats = records.filter((p) => p.leftSeq === void 0).map(seat); + for (const id of room2.members ?? []) { + const bot = roster.byId[id]; + if (!bot || seats.some((item) => item.local?.id === id)) continue; + seats.push({ key: id, local: bot, record: { id }, person: cleanPeerName(roster.self?.displayName, ""), bot: bot.name, mine: true }); + } + if (!left) return seats; + const gone = records.filter((p) => p.leftSeq !== void 0 && !seats.some((item) => item.key === (p.handle || p.id))).map((item) => ({ ...seat(item), left: true })); + return [...seats, ...gone]; +} +function faceSeatLabel(seat) { + if (!seat.person) return seat.bot; + return `${seat.bot} (${seat.person})`; +} +function faceBotLabel(room2, roster, bot) { + if (room2?.origin !== "face-to-face" || !bot) return bot?.name; + const seat = faceSeats(room2, roster, { left: true }).find((item) => item.local?.id === bot.id || item.record?.id !== void 0 && item.record.id === bot.id); + return seat ? faceSeatLabel(seat) : bot.name; +} +function facePersonKey(seat) { + if (seat.mine) return ":you"; + const record = seat.record ?? {}; + const home = String(record.ownerHome || record.home || "").trim().toLowerCase(); + if (home) return `home:${home}`; + const parts = String(record.handle || "").trim().toLowerCase().replace(/^@+/, "").split("."); + if (parts.length >= 3 && parts.at(-1) === "home") return `home:${parts.slice(-2).join(".")}`; + return `name:${(ownerNameOf(record) || seat.person || seat.key).toLowerCase()}`; +} +function facePeople(room2, roster) { + const people2 = []; + for (const seat of faceSeats(room2, roster)) { + const key = facePersonKey(seat); + const known = people2.find((person) => person.key === key); + if (known) { + known.seats.push(seat); continue; } - rows.push(item); + people2.push({ key, name: seat.person || (seat.mine ? cleanPeerName(roster.self?.displayName, "") : "") || t("Someone"), mine: seat.mine, seats: [seat] }); } - return (0, import_react9.createElement)( + return [...people2.filter((person) => person.mine), ...people2.filter((person) => !person.mine)]; +} +function facePersonLabel(person) { + return person.mine ? t("{name} (you)", { name: person.name }) : person.name; +} + +// src/client/held-reply.js +var import_react9 = require("react"); +var call3 = (endpoint, payload) => call(`flybot/${endpoint}`, payload); +var Boundary; +var boundary = () => Boundary ??= class HeldReplyBoundary extends import_react9.Component { + constructor(props) { + super(props); + this.state = { failed: false }; + } + static getDerivedStateFromError() { + return { failed: true }; + } + componentDidCatch(error) { + console.error(`flybot: ${this.props.id} failed`, error); + } + render() { + return this.state.failed ? this.props.fallback : this.props.children; + } +}; +function useInlineEntry(policy) { + const slots = registry(); + const [, setVersion] = (0, import_react9.useState)(0); + (0, import_react9.useEffect)(() => slots.subscribe(() => setVersion(slots.version())), [slots]); + return slots.entries("flybot.approvals").find((entry) => entry.inline === true && entry.policy === policy); +} +function HeldReplyActions({ held, actions }) { + const custom = useInlineEntry(held?.by); + const [busy, setBusy] = (0, import_react9.useState)(false); + const [error, setError] = (0, import_react9.useState)(""); + const waiting = isApprovalWaiting(held?.status); + (0, import_react9.useEffect)(() => { + if (!waiting) return void 0; + claimApprovals(`held-reply:${held.id}`, [held.id]); + return () => claimApprovals(`held-reply:${held.id}`, []); + }, [held?.id, waiting]); + if (!held?.id) return null; + const decide = async (decision) => { + setBusy(true); + setError(""); + try { + await call3("decide", { id: held.id, decision }); + } catch (failure) { + setError(failure?.message ?? String(failure)); + } finally { + setBusy(false); + } + }; + if (!waiting) { + const failed = held.status === "failed"; + return (0, import_react9.createElement)( + "div", + { className: "bt-held-note", "data-state": held.status, title: failed && held.error ? held.error : void 0 }, + (0, import_react9.createElement)("span", null, held.status === "approved" ? t("Sent to {to}", { to: held.toName }) : held.status === "auto" ? t("Auto-sent to {to}", { to: held.toName }) : failed ? t("Not sent: delivery failed") : t("Not sent")), + // #968: the terminal answer comes from one predicate, not from `status === 'failed'` here and a + // different field elsewhere. A failure that can never succeed draws no button on any surface. + failed && canRetry(held) ? (0, import_react9.createElement)("button", { type: "button", className: "bt-held-retry", disabled: busy, onClick: () => decide("retry") }, t("Retry")) : null, + error ? (0, import_react9.createElement)("span", { className: "bt-flybot-approval-error", role: "alert" }, error) : null + ); + } + const plain = (0, import_react9.createElement)( "div", - { className: "bt-earlier", role: "feed", "aria-label": t("Earlier messages"), "aria-busy": view.loading }, + { className: "bt-held-card", role: "region", "aria-label": t("Waiting for your approval") }, + (0, import_react9.createElement)("div", { className: "bt-held-head" }, t("Waiting for your approval")), + (0, import_react9.createElement)("div", { className: "bt-flybot-approval-origin" }, t("{from} → {to}", { from: held.from ?? t("A Bot"), to: held.toName })), (0, import_react9.createElement)( "div", - { ref: top, className: "bt-earlier-top" }, - view.loading ? (0, import_react9.createElement)(Shimmer, null, t("Loading earlier messages…")) : view.failed ? (0, import_react9.createElement)("button", { type: "button", className: "bt-soft", onClick: () => load(view.cursor) }, t("Couldn’t load earlier messages · Retry")) : view.cursor ? (0, import_react9.createElement)("button", { type: "button", className: "bt-soft", onClick: () => load(view.cursor) }, t("Load earlier")) : null + { className: "bt-flybot-approval-actions" }, + (0, import_react9.createElement)("button", { type: "button", disabled: busy, onClick: () => decide("reject") }, t("Reject")), + (0, import_react9.createElement)("button", { type: "button", className: "bt-flybot-approve", disabled: busy, onClick: () => decide("approve") }, t("Approve")) ), - rows.map((row, index) => { - const key = rows.length - index; - switch (row.kind) { - case "divider": - return (0, import_react9.createElement)(PartLine, { key, time: row.time }); - case "user": - return (0, import_react9.createElement)("div", { key, className: "bt-urow" }, (0, import_react9.createElement)( - "div", - { className: "bt-ububble" }, - row.text ? (0, import_react9.createElement)(import_dsh_client_ui_primitives.MarkdownText, { text: row.text, labels: mdLabels(), variant: "compact" }) : null, - row.images ? (0, import_react9.createElement)("span", { className: "bt-uimages" }, row.images === 1 ? t("1 image") : t("{count} images", { count: row.images })) : null - )); - case "answer": - return (0, import_react9.createElement)(AnsweredCell, { key, node: { data: row } }); - case "bot": - return (0, import_react9.createElement)(Bubbles, { key, texts: row.texts, msgKeys: row.msgKeys, roster, selfId, actions, sessionId, useUi }); - case "event": { - if (row.role === "report") return (0, import_react9.createElement)(GroupEvent, { key, prefix: t("Replies from"), room: roster.roomsById[row.roomId], name: t("a group chat"), roster, actions }); - const other = roster.byId[row.botId] ?? { id: row.botId, name: row.name ?? t("a Bot"), color: "gray" }; - const prefix = { stopped: t("Stopped by"), reply: t("Reply from") }[row.role] ?? t("Message from"); - return (0, import_react9.createElement)(EventLine, { key, prefix, bots: [other], onOpen: () => { - if (selfId && other.id) actions.openExchange(selfId, other.id); - }, whole: true }); - } - default: - return null; - } - }), - (0, import_react9.createElement)(PartLine, { time: view.startedAt }) + error ? (0, import_react9.createElement)("div", { className: "bt-flybot-approval-error", role: "alert" }, error) : null + ); + return (0, import_react9.createElement)( + "div", + { className: "bt-held-actions", "data-flybot": "held-reply-actions" }, + custom ? (0, import_react9.createElement)(boundary(), { id: custom.id, fallback: plain }, (0, import_react9.createElement)(custom.component, { held, decide, call: call3, actions })) : plain ); } -function TriggerCell({ node, sessionId, useRoster, useSessions, useTurnClock, useUi, actions }) { - const roster = useRoster((value) => value); - const data = node.data; - const source = data.source !== null && typeof data.source === "object" ? data.source : {}; - const selfId = roster.byId[sessionId]?.id ?? sessionId; - const deferred = source.kind === "bot" && source.deferred === true; - const relayed = isRelayContext(data); - const turn = node.location?.turn?.turn ?? data.turn; - (0, import_react9.useLayoutEffect)(() => { - if (relayed) actions.noteTurnPeer(sessionId, turn, source.senderSessionId); - }, [relayed, sessionId, turn, source.senderSessionId]); - const order = useSessions((list) => { - const outline = list.projectionsBySession?.[sessionId]?.values?.turnOutline ?? list.byId[sessionId]?.projectionValues?.turnOutline; - return Array.isArray(outline) ? outline.map((entry) => entry.turn).join(",") : ""; - }); - const peers = useTurnClock((state) => { - if (!relayed) return ""; - const turns = state[sessionId] ?? {}; - return Object.keys(turns).filter((key) => turns[key].peer !== void 0).map((key) => `${key}:${turns[key].peer}`).join(","); - }); - const visit = useUi((state) => state.visits?.[sessionId]); - const hiddenTurns = roster.exchangeTurns[sessionId]; - const unreadOpening = useTurnClock((state) => { - const first = openingTurn(state[sessionId], order, hiddenTurns); - return first?.turn === turn && unseenSince(visit, first.start) ? clockLabel(first.start) : ""; - }); - const body = triggerBody(); - return unreadOpening ? (0, import_react9.createElement)(import_react9.Fragment, null, (0, import_react9.createElement)("div", { className: "bt-open-new" }, (0, import_react9.createElement)(NewDivider), (0, import_react9.createElement)("div", { className: "bt-time-sep", role: "separator" }, unreadOpening)), body) : body; - function triggerBody() { - if (source.form === "remote-chat") return null; - if (source.shown === true) return null; - if (source.remote?.handle) { - const message = (0, import_react9.createElement)(RemoteMessage, { source, text: remoteMessageText(stripHeader(contentText(data.content))), roster, actions, sessionId, msgKey: `${sessionId}:${data.turn}:remote`, useUi }); - return source.remote.firstContact === true ? (0, import_react9.createElement)(import_react9.Fragment, null, (0, import_react9.createElement)(FirstContact, { source, roster, sessionId }), message) : message; - } - if (source.kind === "bot") { - if (source.handoff === true) return roster.roomsById[sessionId] ? null : (0, import_react9.createElement)(EarlierParts, { sessionId, roster, actions, useUi }); - if (source.role === "room" || source.role === "kickoff") return null; - if (source.role === "mention") return (0, import_react9.createElement)("div", { className: "bt-event" }, (0, import_react9.createElement)("span", null, contentText(data.content))); - if (source.role === "secret" || deferred) return null; - if (source.role === "post") { - if (source.failed !== void 0 || source.resumed === true || source.stopped === true) return null; - if (source.notice === true || source.senderSessionId === "face-notice") return null; - const room2 = roster.roomsById[sessionId]; - const poster = roster.byId[source.senderSessionId] ?? { id: source.senderSessionId, name: source.senderName ?? t("a Bot"), color: "gray" }; - return (0, import_react9.createElement)(GroupMessage, { bot: poster, text: stripHeader(contentText(data.content)), room: room2, roster, actions, msgKey: `${sessionId}:${data.turn}:post`, useUi }); - } - if (source.role === "report") { - return (0, import_react9.createElement)(GroupEvent, { prefix: t("Replies from"), room: roster.roomsById[source.roomId], name: t("a group chat"), roster, actions }); - } - if (source.role === "resume" || source.resumed === true) return (0, import_react9.createElement)("div", { className: "bt-event" }, t("Picked up its work after a restart")); - if (source.role === "retry" || source.retried === true) return (0, import_react9.createElement)("div", { className: "bt-event" }, t("Asked to make the tool call it wrote as text")); - const run = relayed ? commRunOf( - order.split(",").filter(Boolean).map(Number), - Object.fromEntries(peers.split(",").filter(Boolean).map((pair) => [Number(pair.slice(0, pair.indexOf(":"))), pair.slice(pair.indexOf(":") + 1)])), - turn, - hiddenTurns - ) : null; - if (run === "folded") return null; - const sender = roster.byId[source.senderSessionId] ?? { id: source.senderSessionId, name: source.senderName ?? t("a Bot"), color: "gray" }; - const count = run?.count ?? (isHiddenTurn(roster, sessionId, turn) ? 2 : 1); - const bots = [sender]; - for (const peer of (run?.peerIds ?? []).map((id) => roster.byId[id]).filter(Boolean)) { - if (!bots.some((bot) => bot.id === peer.id)) bots.push(peer); + +// src/client/turn-reasoning.js +function turnReasoning(steps, current) { + const members = steps.length ? steps : [current]; + if (members.find((step) => stepReasoning(step) !== "")?.step !== current.step) return ""; + return members.filter((step) => !thinkingLive(step)).map(stepReasoning).filter(Boolean).join("\n\n"); +} +var texts = (step) => step.blocks.filter((block) => block.kind === "text" && block.text.trim() !== ""); +var thinkingLive = (step) => step.status === "running" && texts(step).length === 0 && step.blocks.some((block) => block.kind === "reasoning"); +var stepReasoning = (step) => step.blocks.filter((block) => block.kind === "reasoning").map((block) => block.text).join("\n").trim(); + +// src/client/cells.js +var import_react10 = require("react"); +var import_react_dom = require("react-dom"); +var import_dsh_client_ui_primitives = require("@deepseek-ai/dsh-client-ui-primitives"); +var mdLabels = () => ({ code: { copyLabel: t("Copy"), copiedLabel: t("Copied") }, footnotes: t("Footnotes") }); +function Bubbles({ texts: texts2, roster, selfId, actions, streaming, sessionId, msgKey, msgKeys, useUi, lead, mentions }) { + const onClick = (event) => { + const target = mentionTarget(event); + if (target === null) return; + event.preventDefault(); + event.stopPropagation(); + actions.openSession(target); + }; + const reactions = useUi ? useUi((state) => msgKey || msgKeys ? msgKeys ? state.reactions ?? null : state.reactions?.[msgKey] ?? null : null) : null; + const reactKey = (index) => msgKeys ? msgKeys[index] : msgKey; + const reactIndex = (index) => msgKeys ? 0 : index; + const shown = (texts2 ?? []).filter((text2) => String(text2 ?? "").trim() !== ""); + if (shown.length === 0) return null; + return (0, import_react10.createElement)( + "div", + { className: "bt-astack", "data-lead": lead ? "" : void 0, onClickCapture: onClick }, + shown.map((text2, index) => { + const key = reactKey(index); + const picked = key ? (msgKeys ? reactions?.[key] : reactions)?.[reactIndex(index)] ?? [] : []; + const last = index === shown.length - 1; + const live = streaming && last; + return (0, import_react10.createElement)( + "div", + { key: index, className: "bt-msg" }, + (0, import_react10.createElement)( + "div", + { className: "bt-msg-line" }, + lead && last ? lead : null, + (0, import_react10.createElement)( + "div", + { className: "bt-bubble" }, + (0, import_react10.createElement)(import_dsh_client_ui_primitives.MarkdownText, { text: linkMentions(text2, roster.bots, selfId), streaming: live, labels: mdLabels(), variant: "compact", fileMentions: mentions }) + ), + live ? null : (0, import_react10.createElement)(MessageTools, { text: text2, name: roster.byId[selfId]?.name ?? t("Bot"), sessionId, react: key ? (emoji) => actions.toggleReaction(key, reactIndex(index), emoji) : void 0, actions }) + ), + picked.length ? (0, import_react10.createElement)("div", { className: "bt-reacts" }, picked.map((emoji) => (0, import_react10.createElement)("button", { + key: emoji, + type: "button", + className: "bt-react", + "aria-label": t("Remove reaction {emoji}", { emoji }), + onClick: () => actions.toggleReaction(key, reactIndex(index), emoji) + }, emoji))) : null + ); + }) + ); +} +var WAITING_OTHERS_NOTICE = "Waiting for the owner's approval"; +var REACTIONS = ["👍", "❤️", "😂", "😮", "😢", "🙏"]; +function leaveNoticeCoveredByClose(room2, text2) { + if (room2?.origin !== "face-to-face" || room2.face?.status !== "closed") return false; + const match = String(text2 ?? "").trim().match(/^(.+) left(?: the room)?$/); + if (!match) return false; + const who = match[1]; + for (const participant of room2.participants ?? []) { + if (participant?.remote !== true || participant.leftSeq === void 0) continue; + const name = String(participant.ownerName || participant.displayName || participant.name || "").trim(); + if (name && (who === name || who.endsWith(` (${name})`))) return true; + } + return false; +} +function useDismiss(open, close, refs) { + (0, import_react10.useEffect)(() => { + if (!open) return void 0; + const onDown = (event) => { + if (refs.some((ref) => ref.current?.contains(event.target))) return; + close(); + }; + const onKey = (event) => { + if (event.key === "Escape") { + event.stopPropagation(); + close(); } - return (0, import_react9.createElement)(EventLine, { - prefix: source.role === "stopped" ? t("Stopped by") : count > 1 ? t("{count} messages with", { count }) : t("Message from"), - bots, - onOpen: (bot) => actions.openExchange(selfId, bot.id), - whole: true - }); - } - if (source.kind === "schedule") { - const prompts = reminderPrompts(contentText(data.content)); - return (0, import_react9.createElement)("div", { className: "bt-event" }, prompts.length > 0 ? t("Scheduled task: {text}", { text: prompts.join(" · ").slice(0, 80) }) : t("Routine started")); + }; + window.addEventListener("mousedown", onDown); + window.addEventListener("keydown", onKey, true); + return () => { + window.removeEventListener("mousedown", onDown); + window.removeEventListener("keydown", onKey, true); + }; + }, [open]); +} +function MessageTools({ text: text2, name, sessionId, react, actions }) { + const [panel, setPanel] = (0, import_react10.useState)(null); + const [place, setPlace] = (0, import_react10.useState)(null); + const [copied, setCopied] = (0, import_react10.useState)(false); + const tools = (0, import_react10.useRef)(null); + const pop = (0, import_react10.useRef)(null); + const close = () => setPanel(null); + useDismiss(panel !== null, close, [tools, pop]); + const open = (kind, event) => { + if (panel === kind) { + close(); + return; } - if (source.kind === "user-question-reply") return null; - return (0, import_react9.createElement)("div", { className: "bt-event" }, contentText(data.content).slice(0, 80) || t("Update")); + const rect = event.currentTarget.getBoundingClientRect(); + const below = rect.bottom + 220 < window.innerHeight; + setPlace(kind === "react" ? { left: rect.left + rect.width / 2, top: rect.top - 6, "--bt-origin": "bottom center" } : { left: Math.min(rect.left, window.innerWidth - 208), ...below ? { top: rect.bottom + 4 } : { bottom: window.innerHeight - rect.top + 4, "--bt-origin": "bottom left" } }); + setPanel(kind); + }; + const copy2 = () => { + void navigator.clipboard?.writeText(text2).then(() => { + setCopied(true); + setTimeout(() => setCopied(false), 1e3); + }); + }; + const download2 = () => { + const url = URL.createObjectURL(new Blob([text2], { type: "text/markdown" })); + const link = Object.assign(document.createElement("a"), { href: url, download: `${name}-message.md` }); + link.click(); + setTimeout(() => URL.revokeObjectURL(url), 1e3); + }; + const item = (label, Icon, run) => (0, import_react10.createElement)("button", { key: label, type: "button", role: "menuitem", onClick: () => { + close(); + run(); + } }, (0, import_react10.createElement)(Icon), label); + return (0, import_react10.createElement)( + "div", + { ref: tools, className: "bt-tools", "data-open": panel !== null || void 0 }, + react ? (0, import_react10.createElement)("button", { type: "button", className: "bt-tool", "aria-label": t("Add reaction"), title: t("Add reaction"), "aria-expanded": panel === "react", onClick: (event) => open("react", event) }, (0, import_react10.createElement)(SmileIcon)) : null, + sessionId ? (0, import_react10.createElement)("button", { type: "button", className: "bt-tool", "aria-label": t("Reply"), title: t("Reply"), onClick: () => actions.quote(sessionId, text2) }, (0, import_react10.createElement)(ReplyIcon)) : null, + (0, import_react10.createElement)("button", { type: "button", className: "bt-tool", "aria-label": t("More message actions"), title: t("More"), "aria-haspopup": "menu", "aria-expanded": panel === "more", onClick: (event) => open("more", event) }, copied ? (0, import_react10.createElement)(CheckIcon) : (0, import_react10.createElement)(DotsIcon)), + panel === "react" ? (0, import_react_dom.createPortal)((0, import_react10.createElement)( + "div", + { ref: pop, className: "bt-react-strip", role: "menu", "aria-label": t("Reactions"), style: place }, + REACTIONS.map((emoji) => (0, import_react10.createElement)("button", { key: emoji, type: "button", role: "menuitem", "aria-label": emoji, onClick: () => { + close(); + react(emoji); + } }, emoji)) + ), document.body) : null, + panel === "more" ? (0, import_react_dom.createPortal)((0, import_react10.createElement)( + "div", + { ref: pop, className: "bt-menu", role: "menu", "aria-label": t("Message actions"), style: place }, + item(t("Copy"), CopyIcon, copy2), + item(t("Download"), DownloadIcon, download2) + ), document.body) : null + ); +} +var PEEK_MAX = 160; +var CUT = "…"; +var MARKUP = [/^\s{0,3}(?:`{3,}|~{3,})/, /^\s{0,3}([-*_])(?:\s*\1){2,}\s*$/]; +var isMarkup = (line2) => MARKUP.some((pattern) => pattern.test(line2)); +var graphemes = (line2) => typeof Intl !== "undefined" && typeof Intl.Segmenter === "function" ? [...new Intl.Segmenter().segment(line2)].map((part) => part.segment) : [...line2]; +var clip = (line2) => { + if (line2.length <= PEEK_MAX) return line2; + let kept = ""; + for (const part of graphemes(line2)) { + if (kept.length + part.length > PEEK_MAX - CUT.length) break; + kept += part; } + return kept + CUT; +}; +var onlyMarkup = (lines) => lines.length === 1 ? t("{n} line of reasoning", { n: lines.length }) : t("{n} lines of reasoning", { n: lines.length }); +var peekLine = (text2) => { + const lines = text2.split("\n").map((part) => part.replace(/\s+/g, " ").trim()).filter((part) => part !== ""); + const prose = lines.find((line2) => !isMarkup(line2)); + return prose === void 0 ? onlyMarkup(lines) : clip(prose); +}; +function ThinkingRow({ text: text2 }) { + if (typeof text2 !== "string" || text2.trim() === "") return null; + const peek = peekLine(text2); + return (0, import_react10.createElement)( + "details", + { className: "bt-think" }, + (0, import_react10.createElement)( + "summary", + null, + (0, import_react10.createElement)("span", { className: "bt-think-caret", "aria-hidden": true }, "›"), + t("Thought it through"), + peek ? (0, import_react10.createElement)("span", { className: "bt-think-peek" }, peek) : null + ), + (0, import_react10.createElement)("div", { className: "bt-think-text" }, text2) + ); } -function NewDivider() { - return (0, import_react9.createElement)("div", { className: "bt-new-sep", role: "separator", "aria-label": t("New messages") }, (0, import_react9.createElement)("span", null, t("New"))); +function useFileMentions({ node, openFile, fileMentions, useTurnData }) { + const tail = useTurnData?.("turn-tail"); + const location2 = node.location; + const turn = location2?.kind === "turn" || location2?.kind === "step" ? location2.turn : void 0; + const seq = node.data.finalNode?.seq; + return (0, import_react10.useMemo)(() => { + if (typeof fileMentions !== "function" || turn?.status !== "closed" || seq === void 0) return void 0; + return tail?.closing?.finalNode.seq === seq ? fileMentions({ turn, seq, openFile }) : void 0; + }, [fileMentions, openFile, seq, tail, turn]); } -function TurnProcessCell({ node, sessionId, turnProcess, useRoster, useUi, useTurnClock, actions }) { - const open = turnProcess === void 0 || !turnProcess.foldable || turnProcess.open; - (0, import_react9.useEffect)(() => { - if (!open) turnProcess.setOpen(true); - }, [open, turnProcess]); - const turn = node?.location?.turn?.turn ?? node?.data?.turn; - const start = node?.location?.turn?.start?.time; - const hidden = useRoster((roster) => isHiddenTurn(roster, sessionId, turn)); - (0, import_react9.useEffect)(() => { - actions.noteTurnTime(sessionId, turn, "first", start); - }, [sessionId, turn, start]); - const visit = useUi((state) => state.visits?.[sessionId]); - const fresh = useTurnClock((state) => { - const own = state[sessionId]?.[turn]; - return !hidden && typeof visit?.seen === "number" && own?.first !== void 0 && own.first <= visit.seen && own.end !== void 0 && own.end > visit.seen && own.end <= visit.entered; - }); - return fresh ? (0, import_react9.createElement)(NewDivider) : null; +function useClosingText(node, useTurnData) { + const tail = useTurnData?.("turn-tail"); + const seq = node.data.finalNode?.seq; + if (!tail?.closing || seq === void 0) return true; + return tail.closing.finalNode?.seq === seq; } -function Nothing() { - return null; +function friendLead(bot, texts2) { + if (bot?.remote === void 0 || texts2.length === 0) return void 0; + if (texts2.every((text2) => /^Not sent:/.test(text2.trim()))) return void 0; + return (0, import_react10.createElement)("span", { className: "bt-lead", title: bot.name, "data-flybot": "friend-lead" }, (0, import_react10.createElement)(BotMark, { bot, size: 22 })); } -var HIDDEN_REMOVED_TOOLS = /* @__PURE__ */ new Set(["ask_user_question"]); -var isConnectorTool = (name) => typeof name === "string" && /^mcp__(github|composio)__/.test(name); -var SILENT_REGISTRATION_TOOLS = /* @__PURE__ */ new Set([ - "list_bots", - "message_bot", - "stop_bot", - "create_bot", - "update_bot", - "archive_bot", - "list_models", - "bot_schedules", - "create_group", - "update_group", - "delete_group", - "post_to_group", - "ask_user", - "track_tasks", - "request_secret", - "list_secrets", - "group_relay", - "read_own_chat", - "read_group_chat", - "remember", - "forget", - "recall", - "read_browser", - "list_capabilities", - "save_capability", - "delete_capability", - "attach_capability", - "detach_capability", - "track_links", - "project_state", - "project_digest", - "meeting" -]); -var isSilentRegistration = (name) => typeof name === "string" && SILENT_REGISTRATION_TOOLS.has(name); -var isHiddenNotice = (block) => block?.type === "tool-removal" && (HIDDEN_REMOVED_TOOLS.has(block.toolName) || isConnectorTool(block.toolName) || isPluginTool(block.toolName) || isSilentRegistration(block.toolName)) || block?.type === "tool-addition" && (isConnectorTool(block.toolName) || isPluginTool(block.toolName) || isSilentRegistration(block.toolName)); -function isHiddenToolRemoval(node) { - const content = node?.data?.content; - if (!Array.isArray(content) || content.length === 0) return false; - return content.every(isHiddenNotice); +function AssistantCell({ node, groupPart, sessionId, openFile, fileMentions, useTurnData, useRoster, useUi, useTurnSteps, useTurnContext, actions }) { + const roster = useRoster((value) => value); + const data = node.data; + const steps = useTurnSteps(String(data.turn)); + const contexts = useTurnContext(String(data.turn)); + const mentions = useFileMentions({ node, openFile, fileMentions, useTurnData }); + const held = groupPart === "reasoning" ? void 0 : heldReplyOf(roster.byId[sessionId], sessionId, data.turn); + const closing = useClosingText(node, useTurnData); + const hidden = Boolean(roster.roomsById[sessionId]) || isHiddenTurn(roster, sessionId, data.turn); + const person = typedBy(roster, sessionId, data.turn); + const texts2 = groupPart === "reasoning" ? [] : data.blocks.filter((block) => block.kind === "text" && block.text.trim() !== "").map((block, index) => index === 0 ? stripTrace(block.text) : block.text).filter((text2) => text2.trim() !== ""); + const running = data.status === "running"; + useLiveNote(actions, sessionId, `text:${node.key ?? data.seq}`, !hidden && running && texts2.length > 0 ? { text: true } : null); + if (hidden) return null; + const reasoning = groupPart === "response" ? "" : turnReasoning(steps, data); + const relays = relayPart(data, groupPart) ? relaysBefore(contexts, steps, data) : []; + if (texts2.length === 0 && !reasoning && relays.length === 0) return null; + const selfId = roster.byId[sessionId]?.id ?? sessionId; + const bubbles = texts2.length ? person ? (0, import_react10.createElement)(PersonMessage, { name: person, texts: texts2, roster, selfId, actions, sessionId, msgKey: `${sessionId}:${data.turn}:${node.key ?? data.seq ?? ""}`, useUi }) : (0, import_react10.createElement)(Bubbles, { texts: texts2, roster, selfId, actions, streaming: running, sessionId, msgKey: `${sessionId}:${data.turn}:${node.key ?? data.seq ?? ""}`, useUi, mentions, lead: friendLead(roster.byId[sessionId], texts2) }) : null; + return (0, import_react10.createElement)( + import_react10.Fragment, + null, + relays.map((relay) => { + const source = relay.source; + const sender = roster.byId[source.senderSessionId] ?? { id: source.senderSessionId, name: source.senderName ?? t("a Bot"), color: "gray" }; + return (0, import_react10.createElement)(EventLine, { + key: relay.seq, + prefix: source.role === "stopped" ? t("Stopped by") : source.role === "reply" ? t("Reply from") : t("Message from"), + bots: [sender], + onOpen: () => { + if (sender.id) actions.openExchange(selfId, sender.id); + }, + whole: true + }); + }), + reasoning ? (0, import_react10.createElement)(ThinkingRow, { text: reasoning }) : null, + held && bubbles ? (0, import_react10.createElement)(HeldReply, { held, roster, sessionId, actions, closing }, bubbles) : bubbles + ); } -function ContextCell({ node, useRoster }) { - useRoster?.((roster) => (roster.pluginTools ?? []).join("\n")); - if (isHiddenToolRemoval(node)) return null; - const content = Array.isArray(node?.data?.content) ? node.data.content : []; - const parts = []; - for (const block of content) { - if (isHiddenNotice(block)) continue; - if (block?.type === "tool-removal" && block.toolName) parts.push(`Tool removed: ${block.toolName}`); - else if (block?.type === "tool-addition" && block.toolName) parts.push(`Tool added: ${block.toolName}`); - } - if (parts.length === 0) return null; - return (0, import_react9.createElement)("div", { className: "bt-event" }, parts.join(" · ")); +function HeldReply({ held, roster, sessionId, actions, closing, children }) { + const bot = roster.byId[sessionId]; + const chat = (roster.bots ?? []).find((item) => item.remote?.handle === held.to); + const toName = cleanPeerName(held.toOwner) || (chat ? friendBotOwner(chat) : ownerNameOf({ handle: held.to })) || held.to || t("a friend"); + return (0, import_react10.createElement)( + "div", + { className: "bt-held-reply", "data-state": held.status, "data-held": isApprovalWaiting(held.status) ? "" : void 0 }, + children, + closing ? (0, import_react10.createElement)(HeldReplyActions, { held: { ...held, toName, from: bot?.name }, actions }) : null + ); } -var SEPARATOR_GAP_MS = 30 * 60 * 1e3; -function clockLabel(time, now = Date.now()) { - const date = new Date(time); - const midnight = (value) => new Date(value.getFullYear(), value.getMonth(), value.getDate()).getTime(); - const days = Math.round((midnight(new Date(now)) - midnight(date)) / 864e5); - const clock4 = date.toLocaleTimeString(dateLocale(), { hour: "numeric", minute: "2-digit" }); - if (days === 0) return t("Today {time}", { time: clock4 }); - if (days === 1) return t("Yesterday {time}", { time: clock4 }); - const sameYear = date.getFullYear() === new Date(now).getFullYear(); - return `${date.toLocaleDateString(dateLocale(), { weekday: "short", month: "short", day: "numeric", ...sameYear ? {} : { year: "numeric" } })} ${clock4}`; +function PersonMessage({ name, texts: texts2, roster, selfId, actions, sessionId, msgKey, useUi }) { + if (!(texts2 ?? []).some((text2) => String(text2 ?? "").trim() !== "")) return null; + const lead = (0, import_react10.createElement)("span", { className: "bt-lead", title: name }, (0, import_react10.createElement)(PersonMark, { name })); + return (0, import_react10.createElement)( + "div", + { className: "bt-group-msg bt-person-msg", "data-person": name }, + (0, import_react10.createElement)("span", { className: "bt-group-name" }, name), + (0, import_react10.createElement)(Bubbles, { texts: texts2, roster, selfId, actions, sessionId, msgKey, useUi, lead }) + ); } -function nextTurnStart(turns, turn) { - let next; - for (const key in turns) { - const candidate = Number(key); - if (candidate > turn && turns[key].first !== void 0 && (next === void 0 || candidate < next)) next = candidate; +function EventLine({ prefix, bots, joiner = t("and"), onOpen, whole = false }) { + if (whole && bots.length === 1) { + return (0, import_react10.createElement)( + "div", + { className: "bt-event" }, + (0, import_react10.createElement)( + "button", + { type: "button", className: "bt-event-whole", title: bots[0].name, onClick: () => onOpen?.(bots[0]) }, + (0, import_react10.createElement)("span", null, prefix), + (0, import_react10.createElement)(BotMark, { bot: bots[0], size: 16 }), + (0, import_react10.createElement)("span", { className: "bt-event-name" }, bots[0].name) + ) + ); } - return next === void 0 ? void 0 : turns[next].first; + const parts = []; + parts.push((0, import_react10.createElement)("span", { key: "p" }, prefix)); + bots.forEach((bot, index) => { + if (index > 0) parts.push((0, import_react10.createElement)("span", { key: `j${index}` }, joiner)); + parts.push((0, import_react10.createElement)( + "button", + { key: bot.id ?? index, type: "button", onClick: () => onOpen?.(bot) }, + (0, import_react10.createElement)(BotMark, { bot, size: 16 }), + bot.name + )); + }); + return (0, import_react10.createElement)("div", { className: "bt-event" }, parts); } -function openingTurn(turns, firstTurns, hiddenTurns) { - if (!turns || !firstTurns) return null; - const start = nextTurnStart(turns, -Infinity); - if (start === void 0) return null; - const turn = Number(Object.keys(turns).filter((key) => turns[key].first === start)[0]); - const listed = firstTurns.split(",").map(Number); - const earlier = listed.filter((entry) => entry < turn); - return listed.includes(turn) && earlier.every((entry) => hiddenTurns?.includes(entry)) ? { turn, start } : null; +var heldForm = (text2) => String(text2 ?? "").replace(/\s+/g, " ").trim().replace(/^(to (the )?face[ -]?room|to the room)\s*:\s*/i, "").trim(); +var sameName = (a, b) => String(a ?? "").replace(/\s+/g, " ").trim().toLowerCase() === String(b ?? "").replace(/\s+/g, " ").trim().toLowerCase(); +function isHeldLine(room2, text2, bot) { + if (room2?.origin !== "face-to-face") return false; + const line2 = heldForm(text2); + if (!line2) return false; + return (room2.pendingApprovals ?? []).some((item) => { + if (item?.kind !== "face_room_message") return false; + if (bot !== void 0 && String(item.from ?? "").trim() !== "" && !sameName(item.from, bot.name)) return false; + const held = heldForm(item.text); + return held !== "" && (held === line2 || held.length >= 799 && line2.startsWith(held)); + }); } -var unseenSince = (visit, time) => typeof visit?.seen === "number" && time > visit.seen && time <= visit.entered; -function movedOnSince(visit, turns, hiddenTurns) { - if (typeof visit?.entered !== "number") return false; - for (const key in turns ?? {}) { - if (hiddenTurns?.includes(Number(key))) continue; - if (turns[key].first !== void 0 && turns[key].first > visit.entered) return true; +var HELD_SENDING_MS = 6e4; +function heldLineState(room2, { seq, botId, text: text2 } = {}, now = Date.now()) { + if (room2?.origin !== "face-to-face" || !Array.isArray(room2.heldLines)) return void 0; + const said = String(text2 ?? "").trim(); + const entry = room2.heldLines.filter((item) => seq !== void 0 && item?.seq !== void 0 ? item.seq === seq : item?.botId === botId && String(item?.text ?? "").trim() === said).at(-1); + if (entry === void 0) return void 0; + if (entry.status === "rejected") return "rejected"; + if (entry.status === "sending" && now - (Number(entry.at) || 0) > HELD_SENDING_MS) return void 0; + if (entry.status !== "pending" && entry.status !== "sending") return void 0; + return room2.face?.status === "left" || room2.face?.status === "closed" ? "rejected" : "held"; +} +function GroupMessage({ bot, text: text2, room: room2, roster, actions, msgKey, useUi, seq }) { + if (String(text2 ?? "").trim() === "") return null; + const held = heldLineState(room2, { seq, botId: bot.id, text: text2 }); + if (held === "rejected") { + return (0, import_react10.createElement)( + "div", + { className: "bt-event bt-not-sent", "data-flybot": "held-not-sent", role: "status" }, + (0, import_react10.createElement)(BotMark, { bot, size: 16 }), + (0, import_react10.createElement)("span", null, t("{name} · Not sent", { name: bot.name })) + ); } - return false; + if (held === "held" || isHeldLine(room2, text2, bot)) return null; + const lead = (0, import_react10.createElement)( + "button", + { type: "button", className: "bt-lead", "aria-label": t("Open {name}'s chat", { name: bot.name }), onClick: () => actions.openSession(bot.id) }, + (0, import_react10.createElement)(BotMark, { bot, size: 22 }) + ); + const name = (0, import_react10.createElement)( + "span", + { className: "bt-group-name", style: { color: inkText(colorOf(bot)) } }, + faceBotLabel(room2, roster, bot) ?? bot.name, + room2?.admin === bot.id ? (0, import_react10.createElement)("span", { className: "bt-group-role" }, t("Admin")) : null + ); + return (0, import_react10.createElement)( + "div", + { className: "bt-group-msg" }, + name, + (0, import_react10.createElement)(Bubbles, { texts: [text2], roster, selfId: bot.id, actions, sessionId: room2?.id, msgKey, useUi, lead }) + ); } -var settledVisit = (visit) => typeof visit?.seen === "number" && visit.seen < visit.entered ? { ...visit, seen: visit.entered } : visit; -function TurnTailCell({ turn: location2, sessionId, useTurnClock, useUi, actions }) { - const turn = location2.turn; - const time = location2.end.time; - (0, import_react9.useEffect)(() => { - actions.noteTurnTime(sessionId, turn, "end", time); - }, [sessionId, turn, time]); - const label = useTurnClock((state) => { - const start = nextTurnStart(state[sessionId] ?? {}, turn); - return start !== void 0 && start - time >= SEPARATOR_GAP_MS ? clockLabel(start) : ""; - }); - const visit = useUi((state) => state.visits?.[sessionId]); - const fresh = useTurnClock((state) => { - if (typeof visit?.seen !== "number" || time > visit.seen) return false; - const start = nextTurnStart(state[sessionId] ?? {}, turn); - return start !== void 0 && start > visit.seen && start <= visit.entered; +function remoteSpeaker(source, roster, sessionId) { + const handle = source.remote.handle; + const member = (roster.remoteBots ?? []).find((bot2) => bot2.handle === handle); + const chat = (roster.bots ?? []).find((bot2) => bot2.remote?.handle === handle); + const room2 = roster.roomsById?.[sessionId]; + const seat = faceSeatLook(room2, handle); + const faceSeat = room2?.origin === "face-to-face" ? faceSeats(room2, roster, { left: true }).find((item) => !item.mine && normRemoteHandle(item.record?.handle) === normRemoteHandle(handle)) : void 0; + const name = faceSeat ? faceSeatLabel(faceSeat) : remoteBotTitle({ + displayName: chat?.name ?? member?.name ?? source.senderName ?? handle, + ownerName: member?.homeName || member?.owner || chat?.remote?.homeName || chat?.remote?.owner, + handle, + isChief: member?.isChief ?? chat?.remote?.isChief }); - if (!label && !fresh) return null; - return (0, import_react9.createElement)( - import_react9.Fragment, - null, - label ? (0, import_react9.createElement)("div", { className: "bt-time-sep", role: "separator" }, label) : null, - fresh ? (0, import_react9.createElement)(NewDivider) : null + const bot = seat ? lookBotForRemote({ handle, displayName: name, ...seat }, roster) : chat ?? lookBotForRemote({ + handle, + displayName: name, + color: member?.color, + avatar: member?.avatar ?? (member?.shape ? { shape: member.shape } : void 0) + }, roster); + const carrier = cleanCarrier(chat?.name ?? member?.name); + return { bot, member, name, handle, carrier, chatId: chat ? currentPart(chat) : void 0 }; +} +function faceSeatLook(room2, handle) { + if (room2?.origin !== "face-to-face" || !handle) return void 0; + const seats = (room2.participants ?? []).filter((p) => p?.remote && (p.handle === handle || p.id === handle)); + const seat = seats.find((p) => p.leftSeq === void 0) ?? seats.at(-1); + const look = {}; + if (typeof seat?.color === "string" && seat.color) look.color = seat.color; + if (seat?.avatar && typeof seat.avatar === "object") look.avatar = seat.avatar; + return Object.keys(look).length ? look : void 0; +} +var cleanCarrier = (value) => typeof value === "string" && value.trim() && !/[@()]/.test(value) ? value.trim() : ""; +function RemoteMessage({ source, text: text2, roster, actions, sessionId, msgKey, useUi }) { + const room2 = roster.roomsById?.[sessionId]; + const face = room2?.origin === "face-to-face"; + const { bot, member, name, handle, carrier, chatId } = remoteSpeaker(source, roster, sessionId); + const files = Array.isArray(source.attachments) ? source.attachments : []; + const words = stripTrace(text2); + if (String(words ?? "").trim() === "" && files.length === 0) return null; + const person = typeof source.remote.typedBy === "string" ? source.remote.typedBy : ""; + if (person) { + return (0, import_react10.createElement)( + "div", + { className: "bt-group-msg bt-person-msg bt-remote-msg", "data-person": person, "data-remote": handle }, + // One short header: the person, and the Bot that carried the line only when it is known (#275). + (0, import_react10.createElement)( + "span", + { className: "bt-group-name", title: handle }, + person, + carrier ? (0, import_react10.createElement)("span", { className: "bt-remote-handle" }, t("via {name}", { name: carrier })) : null + ), + String(words ?? "").trim() === "" ? null : (0, import_react10.createElement)(Bubbles, { texts: [words], roster, selfId: bot.id, actions, sessionId, msgKey, useUi, lead: (0, import_react10.createElement)("span", { className: "bt-lead", title: person }, (0, import_react10.createElement)(PersonMark, { name: person })) }), + files.length ? (0, import_react10.createElement)(RemoteFiles, { files, plugin: member?.plugin ?? "social", botId: roster.byId[sessionId]?.id ?? sessionId }) : null + ); + } + const mark = (0, import_react10.createElement)(BotMark, { bot, size: 22 }); + const lead = chatId ? (0, import_react10.createElement)("button", { type: "button", className: "bt-lead", title: handle, "aria-label": t("Open {name}'s chat", { name }), onClick: () => actions.openSession(chatId) }, mark) : (0, import_react10.createElement)("span", { className: "bt-lead", title: handle }, mark); + return (0, import_react10.createElement)( + "div", + { className: "bt-group-msg bt-remote-msg", "data-remote": handle }, + // A face room's header is the one name alone; the handle is on hover only (#457). + face ? (0, import_react10.createElement)("span", { className: "bt-group-name", style: { color: inkText(colorOf(bot)) }, title: handle }, name) : (0, import_react10.createElement)( + "span", + { className: "bt-group-name", style: { color: inkText(colorOf(bot)) } }, + name, + (0, import_react10.createElement)("span", { className: "bt-remote-handle", title: source.remote.home ?? handle, "aria-label": t("Friend's Bot") }, (0, import_react10.createElement)(GlobeMark), handle) + ), + String(words ?? "").trim() === "" ? null : (0, import_react10.createElement)(Bubbles, { texts: [words], roster, selfId: bot.id, actions, sessionId, msgKey, useUi, lead }), + files.length ? (0, import_react10.createElement)(RemoteFiles, { files, plugin: member?.plugin ?? "social", botId: roster.byId[sessionId]?.id ?? sessionId }) : null ); } -function Shimmer({ children }) { - return (0, import_react9.createElement)("span", { className: "bt-shimmer" }, children); +function FirstContact({ source, roster, sessionId }) { + const person = typeof source.remote.typedBy === "string" ? source.remote.typedBy : ""; + const who = person || remoteSpeaker(source, roster).name; + const bot = roster.byId[sessionId]?.name ?? t("your Bot"); + return (0, import_react10.createElement)("div", { className: "bt-event", "data-first-contact": source.remote.handle }, (0, import_react10.createElement)("span", null, t("{name} is talking to {bot} through Bot Social", { name: who, bot }))); } -var PHRASES = { - thinking: ["Thinking about it", "Weighing the options", "Working out a plan", "Sorting out the details"], - working: ["On it", "Busy with it", "Moving ahead", "Taking care of it", "Making headway"] -}; -var TOOL_PHRASES = [ - [/web_?search|search_?web/i, "Searching online"], - [/fetch|browse|browser|web/i, "Reading a page"], - [/shell|bash|pwsh|powershell|exec|command|terminal/i, "Running shell commands"], - [/^(read|view|cat)\b|read_/i, "Opening a file"], - [/grep|glob|list_dir|^ls$|find|search/i, "Looking through files"], - [/write|edit|patch|apply|replace/i, "Writing changes"], - [/image|photo|draw/i, "Working on an image"] -]; -var GROUP_PHRASES = { create_group: "Creating a group", update_group: "Updating the group", delete_group: "Deleting the group", post_to_group: "Posting in the group" }; -function toolActivity(name, arg, findBot, self) { - switch (name) { - case "ask_user": - return null; - case "request_secret": - return null; - case "list_secrets": - return { label: t("Checking the keys"), state: "searching" }; - case "ask_user_question": - return { label: t("Waiting for your answer"), state: "alert" }; - case "group_relay": - return { relay: true, state: "orbit" }; - case "message_bot": { - const target = findBot(arg("to")); - return { label: target ? t("Asking {name}", { name: target.name }) : t("Asking another Bot"), state: "sending" }; - } - case "stop_bot": { - const target = findBot(arg("bot")); - return { label: target ? t("Stopping {name}", { name: target.name }) : t("Stopping another Bot"), state: "sending" }; - } - case "create_bot": - return { label: t("Creating {name}", { name: arg("name") ?? t("a Bot") }), state: "sending" }; - case "update_bot": { - const target = findBot(arg("name")) ?? findBot(arg("bot")) ?? (arg("bot") ? void 0 : self); - return { label: t("Updating {name}", { name: target?.name ?? t("a Bot") }), state: "sending" }; +function GlobeMark() { + return (0, import_react10.createElement)( + "svg", + { width: 10, height: 10, viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: 2, strokeLinecap: "round", "aria-hidden": true }, + (0, import_react10.createElement)("circle", { cx: 12, cy: 12, r: 9 }), + (0, import_react10.createElement)("path", { d: "M3 12h18M12 3a14 14 0 0 1 0 18M12 3a14 14 0 0 0 0 18" }) + ); +} +function RemoteFiles({ files, plugin, botId }) { + const [busy, setBusy] = (0, import_react10.useState)(""); + const [error, setError] = (0, import_react10.useState)(""); + const download2 = async (file) => { + setBusy(file.id); + setError(""); + try { + const got = await call(`flybot/${plugin}/attachment`, { id: file.id, botId }); + const value = got?.value ?? got; + const bytes = Uint8Array.from(atob(value.content_base64), (c) => c.charCodeAt(0)); + const url = URL.createObjectURL(new Blob([bytes], { type: "application/octet-stream" })); + const link = Object.assign(document.createElement("a"), { href: url, download: value.name ?? file.name }); + link.click(); + setTimeout(() => URL.revokeObjectURL(url), 1e4); + } catch (reason) { + setError(String(reason?.message ?? reason)); + } finally { + setBusy(""); } - case "list_bots": - return { label: t("Checking the team"), state: "searching" }; - case "read_group_chat": - return { label: t("Reading {name}", { name: arg("group") ?? t("a group chat") }), state: "searching" }; - case "read_own_chat": - return { label: t("Looking back through the chat"), state: "searching" }; - case "remember": - return { label: t("Saving to memory"), state: "working" }; - case "forget": - return { label: t("Updating memory"), state: "working" }; - case "recall": - return { label: t("Checking memory"), state: "searching" }; - default: - if (GROUP_PHRASES[name]) return { label: t(GROUP_PHRASES[name]), state: "sending" }; - const phrase = TOOL_PHRASES.find(([pattern]) => pattern.test(name))?.[1]; - return { label: phrase ? t(phrase) : null, state: toolState(name) }; - } + }; + return (0, import_react10.createElement)( + "div", + { className: "bt-file-chips" }, + files.map((file) => (0, import_react10.createElement)( + "button", + { key: file.id, type: "button", className: "bt-file-chip", disabled: busy === file.id, title: `Download ${file.name} (untrusted file from a remote Bot)`, onClick: () => download2(file) }, + `📎 ${file.name} · ${fileSize(file.size)}` + )), + error ? (0, import_react10.createElement)("span", { className: "bt-file-error", role: "alert" }, error) : null + ); } -function workPhrase(tool) { - if (!tool) return t("Thinking about it"); - return toolActivity(tool, () => void 0, () => void 0)?.label ?? t("Using {tool}", { tool }); +function GroupEvent({ prefix, room: room2, name, roster, actions }) { + return (0, import_react10.createElement)( + "div", + { className: "bt-event" }, + (0, import_react10.createElement)("span", null, prefix), + room2 ? (0, import_react10.createElement)("button", { type: "button", onClick: () => actions.openSession(room2.id) }, (0, import_react10.createElement)(RoomAvatar, { room: room2, roster, size: 16 }), room2.name) : name ? (0, import_react10.createElement)("span", null, name) : null + ); } -function workText(tool, step) { - return step ? t("{action}: {detail}", { action: workPhrase(tool), detail: step }) : `${workPhrase(tool)}…`; +function MemoryEvent({ sessionId, scope, result, roster, actions, findBot }) { + if (!/^(Saved to|Updated in|Removed from) /.test(result)) return null; + const self = roster.byId[sessionId]; + const team = /^(team|团队|共享)$/i.test(String(scope ?? "").trim()); + const other = team || !scope ? void 0 : findBot(scope); + const target = other && other.id !== self?.id ? other : self; + if (!target) return null; + const label = team ? t("Team memory updated") : target === self ? t("Memory updated") : t("Updated {name}'s memory", { name: target.name }); + return (0, import_react10.createElement)( + "div", + { className: "bt-event" }, + (0, import_react10.createElement)("button", { type: "button", title: result, onClick: () => actions.openMemory(target.id) }, (0, import_react10.createElement)(MemoryIcon), label) + ); } -function useLiveNote(actions, sessionId, id, entry) { - const key = entry ? JSON.stringify(entry) : ""; - (0, import_react9.useEffect)(() => { - if (!key) return void 0; - actions.noteLive(sessionId, id, JSON.parse(key)); - return () => actions.noteLive(sessionId, id, null); - }, [sessionId, id, key]); +function PartLine({ time }) { + return (0, import_react10.createElement)("div", { className: "bt-part-line", role: "separator" }, Number.isFinite(time) ? (0, import_react10.createElement)("span", null, dayLabel(time)) : null); } -function GroupReplies({ sessionId, callId, done, content, roster, actions, useUi }) { - const [live, setLive] = (0, import_react9.useState)({ replies: [] }); - (0, import_react9.useEffect)(() => { - if (done) return void 0; - let stop = false; - let timer; - let seen = ""; - const tick = async () => { - const result = await actions.roomProgress(sessionId); - if (stop) return; - const entry = (result ?? []).find((item) => item.callId === callId) ?? (result ?? [])[0]; - const key = entry ? JSON.stringify(entry.replies) : ""; - if (entry && key !== seen) { - seen = key; - setLive({ replies: entry.replies ?? [] }); - } - timer = setTimeout(tick, document.hidden ? 5e3 : 1e3); - }; - void tick(); +var scrollParent = (node) => { + for (let at = node.parentElement; at; at = at.parentElement) { + if (/(auto|scroll)/.test(getComputedStyle(at).overflowY)) return at; + } + return null; +}; +function EarlierParts({ sessionId, roster, actions, useUi }) { + const [view, setView] = (0, import_react10.useState)({ items: [], cursor: void 0, loading: true, failed: false, startedAt: null }); + const top = (0, import_react10.useRef)(null); + const busy = (0, import_react10.useRef)(false); + const alive = (0, import_react10.useRef)(true); + const restore = (0, import_react10.useRef)(null); + const load = (cursor) => { + if (busy.current) return; + busy.current = true; + setView((current) => ({ ...current, loading: true, failed: false })); + actions.earlier(sessionId, cursor).then((page) => { + busy.current = false; + if (!alive.current) return; + const scroller = top.current && scrollParent(top.current); + if (scroller) restore.current = { scroller, fromEnd: scroller.scrollHeight - scroller.scrollTop }; + setView((current) => ({ items: [...page.items, ...current.items], cursor: page.cursor, loading: false, failed: false, startedAt: page.startedAt ?? current.startedAt })); + }, () => { + busy.current = false; + if (alive.current) setView((current) => ({ ...current, loading: false, failed: true })); + }); + }; + (0, import_react10.useEffect)(() => { + alive.current = true; + load(void 0); return () => { - stop = true; - clearTimeout(timer); + alive.current = false; }; - }, [done, sessionId, callId]); - let replies = live.replies; - if (done) { - try { - replies = JSON.parse(contentText(content)).replies ?? []; - } catch { - replies = []; + }, [sessionId]); + (0, import_react10.useLayoutEffect)(() => { + const pending = restore.current; + restore.current = null; + if (pending) pending.scroller.scrollTop = pending.scroller.scrollHeight - pending.fromEnd; + }, [view.items]); + (0, import_react10.useEffect)(() => { + const node = top.current; + if (!node || view.loading || view.failed || !view.cursor || typeof IntersectionObserver !== "function") return void 0; + const observer = new IntersectionObserver((entries2) => { + if (entries2.some((entry) => entry.isIntersecting)) load(view.cursor); + }, { root: scrollParent(node), rootMargin: "600px 0px 0px 0px" }); + observer.observe(node); + return () => observer.disconnect(); + }, [view.cursor, view.loading, view.failed]); + const selfId = roster.byId[sessionId]?.id; + const keyOf = (item) => `${item.sessionId ?? sessionId}:${item.turn ?? ""}:${item.seq ?? item.time ?? ""}`; + const rows = []; + for (const item of view.items) { + if (item.kind === "bot") { + if (String(item.text ?? "").trim() === "") continue; + const last = rows.at(-1); + if (last?.kind === "bot") { + last.texts.push(item.text); + last.msgKeys.push(keyOf(item)); + } else rows.push({ kind: "bot", texts: [item.text], msgKeys: [keyOf(item)] }); + continue; } + rows.push(item); } - if (replies.length === 0) return null; - const room2 = roster.roomsById[sessionId]; - return (0, import_react9.createElement)( + return (0, import_react10.createElement)( "div", - { className: "bt-group-replies" }, - replies.map((reply, index) => { - const stored = roster.byId[reply.botId]; - const resolved = reply.remote ? stored : rosterBot(roster, reply.botId) ?? stored; - const bot = resolved ?? { id: reply.botId, name: reply.name, color: reply.color ?? "gray" }; - if (reply.kind === "user") { - const who = reply.name && reply.name !== "User" ? reply.name : reply.authorId; - if (reply.other) { - return (0, import_react9.createElement)(PersonMessage, { key: `user-${index}`, name: who, texts: [remoteMessageText(reply.text)], roster, selfId: sessionId, actions, sessionId, msgKey: callId ? `${sessionId}:${callId}:${index}` : void 0, useUi }); - } - return (0, import_react9.createElement)( - "div", - { key: `user-${index}`, className: "bt-urow", "data-author": reply.authorId, "data-author-name": who }, - who ? (0, import_react9.createElement)("span", { className: "bt-uauthor" }, who) : null, - (0, import_react9.createElement)( + { className: "bt-earlier", role: "feed", "aria-label": t("Earlier messages"), "aria-busy": view.loading }, + (0, import_react10.createElement)( + "div", + { ref: top, className: "bt-earlier-top" }, + view.loading ? (0, import_react10.createElement)(Shimmer, null, t("Loading earlier messages…")) : view.failed ? (0, import_react10.createElement)("button", { type: "button", className: "bt-soft", onClick: () => load(view.cursor) }, t("Couldn’t load earlier messages · Retry")) : view.cursor ? (0, import_react10.createElement)("button", { type: "button", className: "bt-soft", onClick: () => load(view.cursor) }, t("Load earlier")) : null + ), + rows.map((row, index) => { + const key = rows.length - index; + switch (row.kind) { + case "divider": + return (0, import_react10.createElement)(PartLine, { key, time: row.time }); + case "user": + return (0, import_react10.createElement)("div", { key, className: "bt-urow" }, (0, import_react10.createElement)( "div", { className: "bt-ububble" }, - (0, import_react9.createElement)(import_dsh_client_ui_primitives.MarkdownText, { text: remoteMessageText(reply.text), labels: mdLabels(), variant: "compact" }) - ), - room2?.origin === "face-to-face" ? (0, import_react9.createElement)("span", { className: "bt-lead", title: who || void 0 }, (0, import_react9.createElement)(PersonMark, { name: who || void 0 })) : null - ); - } - if (reply.kind === "failed") { - return (0, import_react9.createElement)( - "div", - { key: `${reply.botId}-${index}`, className: "bt-event bt-event-error", role: "status" }, - t("{name} could not answer: {reason}", { name: bot.name, reason: failureLabel(reply.code, reply.text) }) - ); - } - if (reply.kind === "lookup") { - return (0, import_react9.createElement)( - "div", - { key: `${reply.botId}-${index}`, className: "bt-event" }, - reply.text ? t("{name} looked up its own chat for “{text}”", { name: bot.name, text: reply.text }) : t("{name} looked up its own chat", { name: bot.name }) - ); - } - if (reply.kind === "missed") { - return (0, import_react9.createElement)("div", { key: `${reply.botId}-${index}`, className: "bt-event" }, t("{name} didn't answer in time", { name: bot.name })); - } - if (reply.kind === "resumed") { - return (0, import_react9.createElement)("div", { key: `${reply.botId}-${index}`, className: "bt-event" }, t("{name} was cut off by a restart and picked its work back up", { name: bot.name })); - } - if (reply.kind === "cut") { - return (0, import_react9.createElement)("div", { key: `${reply.botId}-${index}`, className: "bt-event" }, t("{name} was cut off by a restart again and stopped", { name: bot.name })); - } - if (reply.kind === "approval" || reply.kind === "working") { - return (0, import_react9.createElement)( - "div", - { key: `${reply.botId}-${index}`, className: "bt-event" }, - (0, import_react9.createElement)( - "button", - { type: "button", className: "bt-event-whole", onClick: () => actions.showDetails(sessionId) }, - (0, import_react9.createElement)(BotMark, { bot, size: 16 }), - (0, import_react9.createElement)("span", { className: "bt-event-name" }, t(reply.kind === "working" ? "{name} started on it" : "{name} paused for your approval", { name: bot.name })) - ) - ); - } - if (reply.kind === "stopped") { - return (0, import_react9.createElement)("div", { key: `${reply.botId}-${index}`, className: "bt-event" }, t("You stopped {name}'s work", { name: bot.name })); - } - if (reply.kind === "notice" && room2?.origin === "face-to-face" && String(reply.text ?? "").trim() === WAITING_OTHERS_NOTICE) return null; - if ((reply.kind === "notice" || reply.kind === "left") && leaveNoticeCoveredByClose(room2, reply.text)) return null; - if (reply.kind === "notice" || reply.kind === "joined" || reply.kind === "left" || reply.kind === "started") { - return (0, import_react9.createElement)("div", { key: `notice-${index}`, className: "bt-event", "data-flybot": "face-notice" }, remoteMessageText(reply.text)); + row.text ? (0, import_react10.createElement)(import_dsh_client_ui_primitives.MarkdownText, { text: row.text, labels: mdLabels(), variant: "compact" }) : null, + row.images ? (0, import_react10.createElement)("span", { className: "bt-uimages" }, row.images === 1 ? t("1 image") : t("{count} images", { count: row.images })) : null + )); + case "answer": + return (0, import_react10.createElement)(AnsweredCell, { key, node: { data: row } }); + case "bot": + return (0, import_react10.createElement)(Bubbles, { key, texts: row.texts, msgKeys: row.msgKeys, roster, selfId, actions, sessionId, useUi }); + case "event": { + if (row.role === "report") return (0, import_react10.createElement)(GroupEvent, { key, prefix: t("Replies from"), room: roster.roomsById[row.roomId], name: t("a group chat"), roster, actions }); + const other = roster.byId[row.botId] ?? { id: row.botId, name: row.name ?? t("a Bot"), color: "gray" }; + const prefix = { stopped: t("Stopped by"), reply: t("Reply from") }[row.role] ?? t("Message from"); + return (0, import_react10.createElement)(EventLine, { key, prefix, bots: [other], onOpen: () => { + if (selfId && other.id) actions.openExchange(selfId, other.id); + }, whole: true }); + } + default: + return null; } - const remote = reply.remote || reply.handle && !roster.byId[reply.botId]; - if (remote) { - return (0, import_react9.createElement)(RemoteMessage, { - key: `${reply.botId || reply.handle}-${index}`, - source: { remote: { handle: reply.handle || reply.botId }, senderName: reply.name || reply.who, senderSessionId: reply.botId }, - text: remoteMessageText(reply.text), - roster, - actions, - sessionId, - msgKey: callId ? `${sessionId}:${callId}:${index}` : void 0, - useUi - }); - } - return (0, import_react9.createElement)(GroupMessage, { key: `${reply.botId}-${index}`, bot, text: reply.text, room: room2, roster, actions, msgKey: callId ? `${sessionId}:${callId}:${index}` : void 0, useUi, seq: reply.seq }); - }) + }), + (0, import_react10.createElement)(PartLine, { time: view.startedAt }) ); } -var DRAWN = /* @__PURE__ */ new Set(["message_bot", "stop_bot", "create_bot", "update_bot", "group_relay", "create_group", "update_group", "delete_group", "post_to_group", "ask_user", "request_secret", "ask_user_question", "read_group_chat", "remember", "forget"]); -var CARDS = /* @__PURE__ */ new Set(["ask_user", "request_secret"]); -function callTree(root) { - const calls = []; - const seen = /* @__PURE__ */ new Set(); - const visit = (call4) => { - const id = call4.callId ?? call4; - if (seen.has(id)) return; - seen.add(id); - calls.push(call4); - for (const child of call4.subCalls ?? []) visit(child); - }; - visit(root); - return calls; -} -function useShownCards(actions, sessionId, callIds) { - const key = JSON.stringify(callIds); - (0, import_react9.useLayoutEffect)(() => { - const ids = JSON.parse(key); - if (ids.length === 0 || !actions.noteCards) return void 0; - actions.noteCards(sessionId, ids, 1); - return () => actions.noteCards(sessionId, ids, -1); - }, [sessionId, key]); -} -function renderCall(call4, { sessionId, roster, actions, useUi, findBot }) { - const tool = toolName(call4); - const done = call4.kind === "tool-result"; - const { text: arg, value: argValue } = toolArgs(call4); - const self = roster.byId[sessionId]; - switch (tool) { - case "message_bot": { - const target = findBot(arg("to")) ?? { name: arg("to") ?? "…", color: "gray" }; - if (done && call4.isError) return (0, import_react9.createElement)("div", { className: "bt-event" }, t("Couldn't message {name}", { name: target.name })); - return (0, import_react9.createElement)(EventLine, { prefix: t("Messaged"), bots: [target], onOpen: (bot) => bot.id && actions.openExchange(self?.id ?? sessionId, bot.id), whole: true }); - } - case "stop_bot": { - const target = findBot(arg("bot")) ?? { name: arg("bot") ?? "…", color: "gray" }; - if (done && call4.isError) return (0, import_react9.createElement)("div", { className: "bt-event" }, t("Couldn't stop {name}", { name: target.name })); - return (0, import_react9.createElement)(EventLine, { prefix: t("Stopped"), bots: [target], onOpen: (bot) => bot.id && actions.openExchange(self?.id ?? sessionId, bot.id), whole: true }); - } - case "create_bot": { - const created = findBot(arg("name")); - if (!done || created === void 0) return null; - return (0, import_react9.createElement)(EventLine, { prefix: t("Created"), bots: [created], onOpen: (bot) => actions.openSession(bot.id) }); - } - case "update_bot": { - const target = findBot(arg("name")) ?? findBot(arg("bot")) ?? (arg("bot") ? void 0 : self); - return done && target ? (0, import_react9.createElement)(EventLine, { prefix: t("Updated"), bots: [target], onOpen: (bot) => actions.openSession(bot.id) }) : null; - } - case "group_relay": - return (0, import_react9.createElement)(GroupReplies, { sessionId, callId: call4.callId, done, content: call4.content, roster, actions, useUi }); - case "create_group": - case "update_group": - case "delete_group": - case "post_to_group": { - const verbs = { - create_group: ["Created group", "Couldn't create the group"], - update_group: ["Updated group", "Couldn't update the group"], - delete_group: ["Deleted group", "Couldn't delete the group"], - post_to_group: ["Posted in", "Couldn't post in the group"] - }[tool]; - if (!done) return null; - const result = contentText(call4.content); - const named = /^(Created group|Updated|Nothing changed in|Deleted|Posted in) "([^"]+)"/.exec(result); - if (call4.isError || !named) return (0, import_react9.createElement)("div", { className: "bt-event", title: result }, t(verbs[1])); - const name = named[2]; - const room2 = roster.rooms.find((entry) => entry.name === name); - return (0, import_react9.createElement)(GroupEvent, { prefix: t(verbs[0]), room: tool === "delete_group" ? void 0 : room2, name, roster, actions }); - } - case "ask_user": { - const question = roster.questions?.[sessionId]; - if (!question || question.callId !== call4.callId) return (0, import_react9.createElement)("span", { className: "bt-q-gone", hidden: true }); - return (0, import_react9.createElement)(PendingQuestion, { sessionId, question, actions, inline: true }); +function TriggerCell({ node, sessionId, useRoster, useSessions, useTurnClock, useUi, actions }) { + const roster = useRoster((value) => value); + const data = node.data; + const source = data.source !== null && typeof data.source === "object" ? data.source : {}; + const selfId = roster.byId[sessionId]?.id ?? sessionId; + const deferred = source.kind === "bot" && source.deferred === true; + const relayed = isRelayContext(data); + const turn = node.location?.turn?.turn ?? data.turn; + (0, import_react10.useLayoutEffect)(() => { + if (relayed) actions.noteTurnPeer(sessionId, turn, source.senderSessionId); + }, [relayed, sessionId, turn, source.senderSessionId]); + const order = useSessions((list) => { + const outline = list.projectionsBySession?.[sessionId]?.values?.turnOutline ?? list.byId[sessionId]?.projectionValues?.turnOutline; + return Array.isArray(outline) ? outline.map((entry) => entry.turn).join(",") : ""; + }); + const peers = useTurnClock((state) => { + if (!relayed) return ""; + const turns = state[sessionId] ?? {}; + return Object.keys(turns).filter((key) => turns[key].peer !== void 0).map((key) => `${key}:${turns[key].peer}`).join(","); + }); + const visit = useUi((state) => state.visits?.[sessionId]); + const hiddenTurns = roster.exchangeTurns[sessionId]; + const unreadOpening = useTurnClock((state) => { + const first = openingTurn(state[sessionId], order, hiddenTurns); + return first?.turn === turn && unseenSince(visit, first.start) ? clockLabel(first.start) : ""; + }); + const body = triggerBody(); + return unreadOpening ? (0, import_react10.createElement)(import_react10.Fragment, null, (0, import_react10.createElement)("div", { className: "bt-open-new" }, (0, import_react10.createElement)(NewDivider), (0, import_react10.createElement)("div", { className: "bt-time-sep", role: "separator" }, unreadOpening)), body) : body; + function triggerBody() { + if (source.form === "remote-chat") return null; + if (source.shown === true) return null; + if (source.remote?.handle) { + const message = (0, import_react10.createElement)(RemoteMessage, { source, text: remoteMessageText(stripHeader(contentText(data.content))), roster, actions, sessionId, msgKey: `${sessionId}:${data.turn}:remote`, useUi }); + return source.remote.firstContact === true ? (0, import_react10.createElement)(import_react10.Fragment, null, (0, import_react10.createElement)(FirstContact, { source, roster, sessionId }), message) : message; } - case "request_secret": - return (0, import_react9.createElement)(SecretToolCell, { sessionId, callId: call4.callId, name: arg("name"), done, content: call4.content, roster, actions }); - case "ask_user_question": { - if (!done) return null; - let parsed = null; - try { - parsed = JSON.parse(contentText(call4.content)); - } catch { - parsed = null; + if (source.kind === "bot") { + if (source.handoff === true) return roster.roomsById[sessionId] ? null : (0, import_react10.createElement)(EarlierParts, { sessionId, roster, actions, useUi }); + if (source.role === "room" || source.role === "kickoff") return null; + if (source.role === "mention") return (0, import_react10.createElement)("div", { className: "bt-event" }, (0, import_react10.createElement)("span", null, contentText(data.content))); + if (source.role === "secret" || deferred) return null; + if (source.role === "post") { + if (source.failed !== void 0 || source.resumed === true || source.stopped === true) return null; + if (source.notice === true || source.senderSessionId === "face-notice") return null; + const room2 = roster.roomsById[sessionId]; + const poster = roster.byId[source.senderSessionId] ?? { id: source.senderSessionId, name: source.senderName ?? t("a Bot"), color: "gray" }; + return (0, import_react10.createElement)(GroupMessage, { bot: poster, text: stripHeader(contentText(data.content)), room: room2, roster, actions, msgKey: `${sessionId}:${data.turn}:post`, useUi }); } - const answer = parsed?.answers?.[0]; - const question = argValue("questions")?.[0]; - if (!answer) return null; - return (0, import_react9.createElement)("div", { className: "bt-q-inline" }, (0, import_react9.createElement)(SettledQuestion, { question, answer })); + if (source.role === "report") { + return (0, import_react10.createElement)(GroupEvent, { prefix: t("Replies from"), room: roster.roomsById[source.roomId], name: t("a group chat"), roster, actions }); + } + if (source.role === "resume" || source.resumed === true) return (0, import_react10.createElement)("div", { className: "bt-event" }, t("Picked up its work after a restart")); + if (source.role === "retry" || source.retried === true) return (0, import_react10.createElement)("div", { className: "bt-event" }, t("Asked to make the tool call it wrote as text")); + const run = relayed ? commRunOf( + order.split(",").filter(Boolean).map(Number), + Object.fromEntries(peers.split(",").filter(Boolean).map((pair) => [Number(pair.slice(0, pair.indexOf(":"))), pair.slice(pair.indexOf(":") + 1)])), + turn, + hiddenTurns + ) : null; + if (run === "folded") return null; + const sender = roster.byId[source.senderSessionId] ?? { id: source.senderSessionId, name: source.senderName ?? t("a Bot"), color: "gray" }; + const count = run?.count ?? (isHiddenTurn(roster, sessionId, turn) ? 2 : 1); + const bots = [sender]; + for (const peer of (run?.peerIds ?? []).map((id) => roster.byId[id]).filter(Boolean)) { + if (!bots.some((bot) => bot.id === peer.id)) bots.push(peer); + } + return (0, import_react10.createElement)(EventLine, { + prefix: source.role === "stopped" ? t("Stopped by") : count > 1 ? t("{count} messages with", { count }) : t("Message from"), + bots, + onOpen: (bot) => actions.openExchange(selfId, bot.id), + whole: true + }); } - case "read_group_chat": { - if (!done) return null; - const room2 = roster.rooms.find((entry) => entry.name === arg("group")); - return room2 ? (0, import_react9.createElement)(GroupEvent, { prefix: t("Read"), room: room2, roster, actions }) : null; + if (source.kind === "schedule") { + const prompts = reminderPrompts(contentText(data.content)); + return (0, import_react10.createElement)("div", { className: "bt-event" }, prompts.length > 0 ? t("Scheduled task: {text}", { text: prompts.join(" · ").slice(0, 80) }) : t("Routine started")); } - case "remember": - case "forget": - return done && !call4.isError ? (0, import_react9.createElement)(MemoryEvent, { sessionId, scope: arg("scope"), result: contentText(call4.content), roster, actions, findBot }) : null; - default: - return null; + if (source.kind === "user-question-reply") return null; + return (0, import_react10.createElement)("div", { className: "bt-event" }, contentText(data.content).slice(0, 80) || t("Update")); } } -function ToolCell({ node, sessionId, useRoster, useUi, actions }) { - const roster = useRoster((value) => value); - const root = node.data.root; - const turn = node.location?.turn?.turn ?? root.turn; - const hidden = isHiddenTurn(roster, sessionId, turn); - const findBot = (nameOrId) => roster.byId[nameOrId] ?? botByName(roster.bots, nameOrId); - const calls = hidden ? [] : callTree(root); - const drawn = calls.filter((call4) => DRAWN.has(toolName(call4))); - const running = root.kind === "tool-result" ? void 0 : calls.findLast((call4) => call4.kind !== "tool-result"); - const note = running ? toolActivity(toolName(running), toolArgs(running).text, findBot, roster.byId[sessionId]) : null; - useLiveNote(actions, sessionId, `tool:${root.callId ?? node.key}`, note); - useShownCards(actions, sessionId, drawn.filter((call4) => CARDS.has(toolName(call4)) && call4.callId).map((call4) => call4.callId)); - if (drawn.length === 0) return null; - const env = { sessionId, roster, actions, useUi, findBot }; - return (0, import_react9.createElement)(import_react9.Fragment, null, drawn.map((call4, index) => (0, import_react9.createElement)(import_react9.Fragment, { key: call4.callId ?? index }, renderCall(call4, env)))); -} - -// src/client/awaiting.js -var AWAIT_REPLY_MS = 10 * 60 * 1e3; -function awaitingReply(roster, sessionId, now = Date.now()) { - const at = roster?.remoteAwaiting?.[sessionId]; - if (typeof at !== "number" || roster?.byId?.[sessionId]?.remote === void 0) return false; - return now - at < AWAIT_REPLY_MS; -} - -// src/client/remote-approval-wait.js -var REMOTE_APPROVAL_LABEL = "Waiting for their approval"; -function remoteApprovalPhrase(remote, { tool, inputAt = 0 } = {}) { - if (!remote) return null; - if (tool && inputAt <= (tool.at ?? 0)) return null; - return { key: "remote-approval", label: REMOTE_APPROVAL_LABEL, state: "thinking" }; -} - -// src/client/activity-row-host.js -var RUNNING_HOST = "[data-chat-flow] [data-chat-running]"; -var TAIL_HOST = "tail"; -function findRunningHostIn(root) { - if (!root || typeof root.querySelector !== "function") return null; - if (typeof root.matches === "function" && root.matches("[data-chat-running]")) return root; - if (typeof Element !== "undefined" && root instanceof Element) return root.querySelector("[data-chat-running]"); - return root.querySelector(RUNNING_HOST); -} - -// src/client/activity-row.js -var import_react10 = require("react"); -var import_react_dom2 = require("react-dom"); -var PHRASE_HOLD_MS = 800; -var drawPhrase = (pool) => t(pool[Math.min(pool.length - 1, Math.floor(Math.random() * pool.length))]); -function elapsedLabel(ms) { - const minutes = Math.floor(ms / 6e4); - if (minutes < 60) return `${Math.max(1, minutes)}m`; - const hours = Math.floor(minutes / 60); - return minutes % 60 === 0 ? `${hours}h` : `${hours}h ${minutes % 60}m`; +function NewDivider() { + return (0, import_react10.createElement)("div", { className: "bt-new-sep", role: "separator", "aria-label": t("New messages") }, (0, import_react10.createElement)("span", null, t("New"))); } -var REMOTE_GRACE_MS = 1500; -function useAwaitingReply(roster, sessionId) { - const at = roster?.remoteAwaiting?.[sessionId]; - const [, tick] = (0, import_react10.useState)(0); - const shown = awaitingReply(roster, sessionId); +function TurnProcessCell({ node, sessionId, turnProcess, useRoster, useUi, useTurnClock, actions }) { + const open = turnProcess === void 0 || !turnProcess.foldable || turnProcess.open; (0, import_react10.useEffect)(() => { - if (!shown) return void 0; - const timer = setTimeout(() => tick((n) => n + 1), Math.max(0, at + AWAIT_REPLY_MS - Date.now()) + 50); - return () => clearTimeout(timer); - }, [shown, at]); - return shown; + if (!open) turnProcess.setOpen(true); + }, [open, turnProcess]); + const turn = node?.location?.turn?.turn ?? node?.data?.turn; + const start = node?.location?.turn?.start?.time; + const hidden = useRoster((roster) => isHiddenTurn(roster, sessionId, turn)); + (0, import_react10.useEffect)(() => { + actions.noteTurnTime(sessionId, turn, "first", start); + }, [sessionId, turn, start]); + const visit = useUi((state) => state.visits?.[sessionId]); + const fresh = useTurnClock((state) => { + const own = state[sessionId]?.[turn]; + return !hidden && typeof visit?.seen === "number" && own?.first !== void 0 && own.first <= visit.seen && own.end !== void 0 && own.end > visit.seen && own.end <= visit.entered; + }); + return fresh ? (0, import_react10.createElement)(NewDivider) : null; } -function findHost(where) { - if (where !== TAIL_HOST) return findRunningHostIn(document); - const flow = document.querySelector("[data-conversation-scroll] [data-chat-flow]") ?? document.querySelector("[data-chat-flow]"); - if (!flow) return null; - const running = findRunningHostIn(flow); - if (running) return running; - const tails = flow.querySelectorAll(":scope > * [data-turn-tail], :scope > [data-turn-tail]"); - return tails.length ? tails[tails.length - 1] : null; +function Nothing() { + return null; } -var lastTurnOnScreen = (kind) => { - let top = -1; - for (const node of document.querySelectorAll(`[data-chat-flow-kind${kind ? `="${kind}"` : ""}][data-chat-turn]`)) top = Math.max(top, Number(node.dataset.chatTurn)); - return top; -}; -function useReplyAfterSend(remote, sending) { - const sendTurn = (0, import_react10.useRef)(-1); - const wasSending = (0, import_react10.useRef)(false); - const [replied, setReplied] = (0, import_react10.useState)(false); - (0, import_react10.useEffect)(() => { - if (!remote) return void 0; - if (sending) { - wasSending.current = true; - sendTurn.current = -1; - setReplied(false); - return void 0; - } - if (wasSending.current) { - wasSending.current = false; - sendTurn.current = lastTurnOnScreen(); - } - if (sendTurn.current < 0) return void 0; - let frame = 0; - const check = () => { - frame = 0; - if (lastTurnOnScreen("assistant-step") > sendTurn.current) setReplied(true); - }; - const observer = new MutationObserver(() => { - if (!frame) frame = requestAnimationFrame(check); - }); - observer.observe(document.querySelector("[data-conversation-scroll]") ?? document.body, { childList: true, subtree: true }); - check(); - return () => { - cancelAnimationFrame(frame); - observer.disconnect(); - }; - }, [remote, sending]); - return replied; +var HIDDEN_REMOVED_TOOLS = /* @__PURE__ */ new Set(["ask_user_question"]); +var isConnectorTool = (name) => typeof name === "string" && /^mcp__(github|composio)__/.test(name); +var SILENT_REGISTRATION_TOOLS = /* @__PURE__ */ new Set([ + "list_bots", + "message_bot", + "stop_bot", + "create_bot", + "update_bot", + "archive_bot", + "list_models", + "bot_schedules", + "create_group", + "update_group", + "delete_group", + "post_to_group", + "ask_user", + "track_tasks", + "request_secret", + "list_secrets", + "group_relay", + "read_own_chat", + "read_group_chat", + "remember", + "forget", + "recall", + "read_browser", + "list_capabilities", + "save_capability", + "delete_capability", + "attach_capability", + "detach_capability", + "track_links", + "project_state", + "project_digest", + "meeting" +]); +var isSilentRegistration = (name) => typeof name === "string" && SILENT_REGISTRATION_TOOLS.has(name); +var isHiddenNotice = (block) => block?.type === "tool-removal" && (HIDDEN_REMOVED_TOOLS.has(block.toolName) || isConnectorTool(block.toolName) || isPluginTool(block.toolName) || isSilentRegistration(block.toolName)) || block?.type === "tool-addition" && (isConnectorTool(block.toolName) || isPluginTool(block.toolName) || isSilentRegistration(block.toolName)); +function isHiddenToolRemoval(node) { + const content = node?.data?.content; + if (!Array.isArray(content) || content.length === 0) return false; + return content.every(isHiddenNotice); } -function useRunningHost(active, where = RUNNING_HOST) { - const [host, setHost] = (0, import_react10.useState)(null); +function ContextCell({ node, useRoster }) { + useRoster?.((roster) => (roster.pluginTools ?? []).join("\n")); + if (isHiddenToolRemoval(node)) return null; + const content = Array.isArray(node?.data?.content) ? node.data.content : []; + const parts = []; + for (const block of content) { + if (isHiddenNotice(block)) continue; + if (block?.type === "tool-removal" && block.toolName) parts.push(`Tool removed: ${block.toolName}`); + else if (block?.type === "tool-addition" && block.toolName) parts.push(`Tool added: ${block.toolName}`); + } + if (parts.length === 0) return null; + return (0, import_react10.createElement)("div", { className: "bt-event" }, parts.join(" · ")); +} +var SEPARATOR_GAP_MS = 30 * 60 * 1e3; +function clockLabel(time, now = Date.now()) { + const date = new Date(time); + const midnight = (value) => new Date(value.getFullYear(), value.getMonth(), value.getDate()).getTime(); + const days = Math.round((midnight(new Date(now)) - midnight(date)) / 864e5); + const clock4 = date.toLocaleTimeString(dateLocale(), { hour: "numeric", minute: "2-digit" }); + if (days === 0) return t("Today {time}", { time: clock4 }); + if (days === 1) return t("Yesterday {time}", { time: clock4 }); + const sameYear = date.getFullYear() === new Date(now).getFullYear(); + return `${date.toLocaleDateString(dateLocale(), { weekday: "short", month: "short", day: "numeric", ...sameYear ? {} : { year: "numeric" } })} ${clock4}`; +} +function nextTurnStart(turns, turn) { + let next; + for (const key in turns) { + const candidate = Number(key); + if (candidate > turn && turns[key].first !== void 0 && (next === void 0 || candidate < next)) next = candidate; + } + return next === void 0 ? void 0 : turns[next].first; +} +function openingTurn(turns, firstTurns, hiddenTurns) { + if (!turns || !firstTurns) return null; + const start = nextTurnStart(turns, -Infinity); + if (start === void 0) return null; + const turn = Number(Object.keys(turns).filter((key) => turns[key].first === start)[0]); + const listed = firstTurns.split(",").map(Number); + const earlier = listed.filter((entry) => entry < turn); + return listed.includes(turn) && earlier.every((entry) => hiddenTurns?.includes(entry)) ? { turn, start } : null; +} +var unseenSince = (visit, time) => typeof visit?.seen === "number" && time > visit.seen && time <= visit.entered; +function movedOnSince(visit, turns, hiddenTurns) { + if (typeof visit?.entered !== "number") return false; + for (const key in turns ?? {}) { + if (hiddenTurns?.includes(Number(key))) continue; + if (turns[key].first !== void 0 && turns[key].first > visit.entered) return true; + } + return false; +} +var settledVisit = (visit) => typeof visit?.seen === "number" && visit.seen < visit.entered ? { ...visit, seen: visit.entered } : visit; +function TurnTailCell({ turn: location2, sessionId, useTurnClock, useUi, actions }) { + const turn = location2.turn; + const time = location2.end.time; (0, import_react10.useEffect)(() => { - if (!active) { - setHost(null); - return void 0; + actions.noteTurnTime(sessionId, turn, "end", time); + }, [sessionId, turn, time]); + const label = useTurnClock((state) => { + const start = nextTurnStart(state[sessionId] ?? {}, turn); + return start !== void 0 && start - time >= SEPARATOR_GAP_MS ? clockLabel(start) : ""; + }); + const visit = useUi((state) => state.visits?.[sessionId]); + const fresh = useTurnClock((state) => { + if (typeof visit?.seen !== "number" || time > visit.seen) return false; + const start = nextTurnStart(state[sessionId] ?? {}, turn); + return start !== void 0 && start > visit.seen && start <= visit.entered; + }); + if (!label && !fresh) return null; + return (0, import_react10.createElement)( + import_react10.Fragment, + null, + label ? (0, import_react10.createElement)("div", { className: "bt-time-sep", role: "separator" }, label) : null, + fresh ? (0, import_react10.createElement)(NewDivider) : null + ); +} +function Shimmer({ children }) { + return (0, import_react10.createElement)("span", { className: "bt-shimmer" }, children); +} +var PHRASES = { + thinking: ["Thinking about it", "Weighing the options", "Working out a plan", "Sorting out the details"], + working: ["On it", "Busy with it", "Moving ahead", "Taking care of it", "Making headway"] +}; +var TOOL_PHRASES = [ + [/web_?search|search_?web/i, "Searching online"], + [/fetch|browse|browser|web/i, "Reading a page"], + [/shell|bash|pwsh|powershell|exec|command|terminal/i, "Running shell commands"], + [/^(read|view|cat)\b|read_/i, "Opening a file"], + [/grep|glob|list_dir|^ls$|find|search/i, "Looking through files"], + [/write|edit|patch|apply|replace/i, "Writing changes"], + [/image|photo|draw/i, "Working on an image"] +]; +var GROUP_PHRASES = { create_group: "Creating a group", update_group: "Updating the group", delete_group: "Deleting the group", post_to_group: "Posting in the group" }; +function toolActivity(name, arg, findBot, self) { + switch (name) { + case "ask_user": + return null; + case "request_secret": + return null; + case "list_secrets": + return { label: t("Checking the keys"), state: "searching" }; + case "ask_user_question": + return { label: t("Waiting for your answer"), state: "alert" }; + case "group_relay": + return { relay: true, state: "orbit" }; + case "message_bot": { + const target = findBot(arg("to")); + return { label: target ? t("Asking {name}", { name: target.name }) : t("Asking another Bot"), state: "sending" }; } - let frame = 0; - const find = () => { - frame = 0; - const node = findHost(where); - setHost((prev) => prev === node ? prev : node); - }; - const observer = new MutationObserver(() => { - if (!frame) frame = requestAnimationFrame(find); - }); - observer.observe(document.querySelector("[data-conversation-scroll]") ?? document.body, { childList: true, subtree: true }); - find(); - return () => { - cancelAnimationFrame(frame); - observer.disconnect(); - }; - }, [active, where]); - return host; + case "stop_bot": { + const target = findBot(arg("bot")); + return { label: target ? t("Stopping {name}", { name: target.name }) : t("Stopping another Bot"), state: "sending" }; + } + case "create_bot": + return { label: t("Creating {name}", { name: arg("name") ?? t("a Bot") }), state: "sending" }; + case "update_bot": { + const target = findBot(arg("name")) ?? findBot(arg("bot")) ?? (arg("bot") ? void 0 : self); + return { label: t("Updating {name}", { name: target?.name ?? t("a Bot") }), state: "sending" }; + } + case "list_bots": + return { label: t("Checking the team"), state: "searching" }; + case "read_group_chat": + return { label: t("Reading {name}", { name: arg("group") ?? t("a group chat") }), state: "searching" }; + case "read_own_chat": + return { label: t("Looking back through the chat"), state: "searching" }; + case "remember": + return { label: t("Saving to memory"), state: "working" }; + case "forget": + return { label: t("Updating memory"), state: "working" }; + case "recall": + return { label: t("Checking memory"), state: "searching" }; + default: + if (GROUP_PHRASES[name]) return { label: t(GROUP_PHRASES[name]), state: "sending" }; + const phrase = TOOL_PHRASES.find(([pattern]) => pattern.test(name))?.[1]; + return { label: phrase ? t(phrase) : null, state: toolState(name) }; + } } -function useRowEntrances(replacing) { - const check = (0, import_react10.useRef)(replacing); - check.current = replacing; +function workPhrase(tool) { + if (!tool) return t("Thinking about it"); + return toolActivity(tool, () => void 0, () => void 0)?.label ?? t("Using {tool}", { tool }); +} +function workText(tool, step) { + return step ? t("{action}: {detail}", { action: workPhrase(tool), detail: step }) : `${workPhrase(tool)}…`; +} +function useLiveNote(actions, sessionId, id, entry) { + const key = entry ? JSON.stringify(entry) : ""; (0, import_react10.useEffect)(() => { - const seen = /* @__PURE__ */ new WeakSet(); - const started = Date.now(); - let column = null; - let frame = 0; - const settle = (event) => { - if (event.target !== event.currentTarget || !/^bt-row/.test(event.animationName)) return; - delete event.currentTarget.dataset.btEnter; - event.currentTarget.removeEventListener("animationend", settle); - }; - const scan = () => { - frame = 0; - const next = document.querySelector("[data-chat-flow]"); - if (next !== column) { - observer.disconnect(); - column = next; - if (column) observer.observe(column, { childList: true, subtree: true }); - } - if (!column) return; - const items = [...column.querySelectorAll(":scope > [data-chat-flow-key]")]; - let last = -1; - items.forEach((item, index) => { - if (seen.has(item)) last = index; - }); - const fresh = items.filter((item) => !seen.has(item) && item.offsetHeight > 0); - for (const item of fresh) seen.add(item); - if (last < 0 || Date.now() - started < 800 || fresh.length > 3) return; - for (const item of fresh) { - if (items.indexOf(item) < last) continue; - const reply = [void 0, "assistant-step", "tool-call"].includes(item.dataset.chatFlowKind); - item.dataset.btEnter = reply && check.current() ? "after-collapse" : "new"; - item.addEventListener("animationend", settle); + if (!key) return void 0; + actions.noteLive(sessionId, id, JSON.parse(key)); + return () => actions.noteLive(sessionId, id, null); + }, [sessionId, id, key]); +} +function GroupReplies({ sessionId, callId, done, content, roster, actions, useUi }) { + const [live, setLive] = (0, import_react10.useState)({ replies: [] }); + (0, import_react10.useEffect)(() => { + if (done) return void 0; + let stop = false; + let timer; + let seen = ""; + const tick = async () => { + const result = await actions.roomProgress(sessionId); + if (stop) return; + const entry = (result ?? []).find((item) => item.callId === callId) ?? (result ?? [])[0]; + const key = entry ? JSON.stringify(entry.replies) : ""; + if (entry && key !== seen) { + seen = key; + setLive({ replies: entry.replies ?? [] }); } + timer = setTimeout(tick, document.hidden ? 5e3 : 1e3); }; - const observer = new MutationObserver(() => { - if (!frame) frame = requestAnimationFrame(scan); - }); - scan(); + void tick(); return () => { - cancelAnimationFrame(frame); - observer.disconnect(); + stop = true; + clearTimeout(timer); }; - }, []); -} -function ActivityIndicator({ sessionId, useRoster, useSessionStatus, useLive, useSession, actions }) { - const roster = useRoster((value) => value); - const running = useSessionStatus((map) => map.get(sessionId)?.running === true); - const liveKey = useLive((state) => JSON.stringify(state[sessionId] ?? {})); - const pendingInput = useSession((snapshot) => Math.max(0, ...(snapshot?.pendingSubmissions ?? []).filter((sub) => sub.placement !== "queued").map((sub) => sub.time))); - const bot = roster.byId[sessionId]; - const room2 = roster.roomsById[sessionId]; - const entries2 = (0, import_react10.useMemo)(() => Object.values(JSON.parse(liveKey)), [liveKey]); - const streaming = entries2.some((entry) => entry.text); - const tools = entries2.filter((entry) => !entry.text).sort((a, b) => a.at - b.at); - const relaying = tools.some((entry) => entry.relay); - const awaiting = useAwaitingReply(roster, sessionId); - const sendRun = (0, import_react10.useRef)(false); - if (!running) sendRun.current = false; - else if (pendingInput > 0) sendRun.current = true; - const replied = useReplyAfterSend(bot?.remote !== void 0, running && sendRun.current); - const visible = bot?.remote !== void 0 ? !streaming && (running ? sendRun.current : awaiting && !replied) : running && !streaming && !relaying && (bot !== void 0 || room2 !== void 0); - const lastRun = (0, import_react10.useRef)({ running, send: false }); - const bridging = bot?.remote !== void 0 && !streaming && lastRun.current.running && lastRun.current.send && !running; - lastRun.current = { running, send: sendRun.current }; - const steady = bridging; - const tool = tools.at(-1); - const inputRef = (0, import_react10.useRef)(0); - const inputAt = Math.max(inputRef.current, pendingInput); - inputRef.current = inputAt; - const phrase = visible ? remoteApprovalPhrase(bot?.remote !== void 0, { tool, inputAt }) : null; - const want = !visible ? null : phrase ? { ...phrase, label: t(phrase.label) } : inputAt > (tool?.at ?? 0) ? { key: `input:${inputAt}`, pool: PHRASES.thinking, state: "thinking" } : tool ? { key: tool.label ? `tool:${tool.label}` : "working", label: tool.label, pool: PHRASES.working, state: tool.state ?? "working" } : { key: "thinking", pool: PHRASES.thinking, state: "thinking" }; - const [shown, setShown] = (0, import_react10.useState)(null); - const wantKey = want?.key ?? null; - const markState = (0, import_react10.useRef)("thinking"); - if (want) markState.current = want.state; - (0, import_react10.useEffect)(() => { - if (wantKey === null || shown?.key === wantKey) return void 0; - const make = () => setShown({ key: wantKey, text: want.label ?? drawPhrase(want.pool), at: Date.now() }); - const wait = shown ? shown.at + PHRASE_HOLD_MS - Date.now() : 0; - if (wait <= 0) { - make(); - return void 0; - } - const timer = setTimeout(make, wait); - return () => clearTimeout(timer); - }, [wantKey, shown]); - const [phase, setPhase] = (0, import_react10.useState)(null); - const endedAt2 = (0, import_react10.useRef)(0); - const shift = (0, import_react10.useRef)(null); - const leaving = (0, import_react10.useRef)(false); - leaving.current = phase !== null && !visible; - (0, import_react10.useEffect)(() => { - if (visible) { - setPhase("in"); - return void 0; + }, [done, sessionId, callId]); + let replies = live.replies; + if (done) { + try { + replies = JSON.parse(contentText(content)).replies ?? []; + } catch { + replies = []; } - let timer; - const leave = () => { - setPhase((current) => current === "in" ? "out" : current); - timer = setTimeout(() => { - shift.current = document.querySelector("[data-conversation-scroll]")?.scrollTop ?? null; - setPhase((current) => current === "out" ? null : current); - }, 140); - }; - if (steady) timer = setTimeout(leave, REMOTE_GRACE_MS); - else leave(); - return () => clearTimeout(timer); - }, [visible]); - (0, import_react10.useLayoutEffect)(() => { - if (phase !== null) return; - endedAt2.current = Date.now(); - setShown(null); - const before = shift.current; - shift.current = null; - const scroller = document.querySelector("[data-conversation-scroll]"); - if (before === null || !scroller || reducedMotion()) return; - const delta = before - scroller.scrollTop; - if (delta > 0) scroller.querySelector("[data-chat-flow]")?.animate([{ transform: `translateY(${-delta}px)` }, { transform: "none" }], { duration: 140, easing: "cubic-bezier(.23,1,.32,1)" }); - }, [phase]); - (0, import_react10.useEffect)(() => { - if (!running) actions.clearLive(sessionId); - }, [running, sessionId]); - useRowEntrances(() => leaving.current || Date.now() - endedAt2.current < 300); - const [now, setNow] = (0, import_react10.useState)(Date.now); - (0, import_react10.useEffect)(() => { - if (phase !== "in") return void 0; - const timer = setInterval(() => setNow(Date.now()), 1e4); - return () => clearInterval(timer); - }, [phase]); - const remote = bot?.remote !== void 0; - const host = useRunningHost(running || remote && phase !== null, running || !remote ? RUNNING_HOST : TAIL_HOST); - const drawn = (0, import_react10.useRef)(false); - const carry = drawn.current; - (0, import_react10.useEffect)(() => { - drawn.current = Boolean(host && phase !== null && shown); - }, [host, phase, shown]); - if (!host || phase === null || !shown) return null; - const elapsed = now - shown.at >= 6e4 ? elapsedLabel(now - shown.at) : null; - return (0, import_react_dom2.createPortal)((0, import_react10.createElement)( + } + if (replies.length === 0) return null; + const room2 = roster.roomsById[sessionId]; + return (0, import_react10.createElement)( "div", - { className: "bt-typing", "data-exiting": phase === "out" ? "" : void 0, "data-carry": carry && phase !== "out" ? "" : void 0, "data-remote": remote ? "" : void 0, "data-flybot": shown.key === "remote-approval" ? "remote-approval" : void 0, "aria-hidden": true }, - bot ? (0, import_react10.createElement)(BotMark, { bot, size: 28, state: markState.current, live: true, pokeable: true }) : (0, import_react10.createElement)(RoomAvatar, { room: room2, roster, size: 28 }), - (0, import_react10.createElement)( - "span", - { className: "bt-typing-text" }, - (0, import_react10.createElement)( - "span", - { key: `${shown.key}:${shown.at}`, className: "bt-typing-label" }, - (0, import_react10.createElement)(Shimmer, null, `${shown.text}…`), - elapsed ? (0, import_react10.createElement)("span", { className: "bt-typing-time" }, `· ${elapsed}`) : null - ) - ) - ), host); -} - -// src/client/dsh-offer.js -var APP_OFFER = /* @__PURE__ */ new Set(["available", "downloading", "verifying", "installing", "ready"]); -var bare = (value) => String(value ?? "").trim().replace(/^v/i, "").split("+")[0]; -var sameVersion = (a, b) => bare(a) !== "" && bare(a) === bare(b); -function showsDshRow(dsh, app) { - if (!dsh?.verified || !dsh.current || sameVersion(dsh.verified, dsh.current)) return false; - if (dsh.kind === "npm") return true; - return dsh.kind === "app" && APP_OFFER.has(app?.phase) && sameVersion(app.version, dsh.verified); -} - -// src/client/overlay-hooks.js -var import_react11 = require("react"); -var escapeLayers = []; -var onEscapeKey = (event) => { - if (event.key === "Escape") escapeLayers.at(-1)?.current(); -}; -function useEscape(onClose) { - const ref = (0, import_react11.useRef)(onClose); - ref.current = onClose; - (0, import_react11.useEffect)(() => { - if (escapeLayers.length === 0) window.addEventListener("keydown", onEscapeKey); - escapeLayers.push(ref); - return () => { - escapeLayers.splice(escapeLayers.indexOf(ref), 1); - if (escapeLayers.length === 0) window.removeEventListener("keydown", onEscapeKey); - }; - }, []); + { className: "bt-group-replies" }, + replies.map((reply, index) => { + const stored = roster.byId[reply.botId]; + const resolved = reply.remote ? stored : rosterBot(roster, reply.botId) ?? stored; + const bot = resolved ?? { id: reply.botId, name: reply.name, color: reply.color ?? "gray" }; + if (reply.kind === "user") { + const who = reply.name && reply.name !== "User" ? reply.name : reply.authorId; + if (reply.other) { + return (0, import_react10.createElement)(PersonMessage, { key: `user-${index}`, name: who, texts: [remoteMessageText(reply.text)], roster, selfId: sessionId, actions, sessionId, msgKey: callId ? `${sessionId}:${callId}:${index}` : void 0, useUi }); + } + return (0, import_react10.createElement)( + "div", + { key: `user-${index}`, className: "bt-urow", "data-author": reply.authorId, "data-author-name": who }, + who ? (0, import_react10.createElement)("span", { className: "bt-uauthor" }, who) : null, + (0, import_react10.createElement)( + "div", + { className: "bt-ububble" }, + (0, import_react10.createElement)(import_dsh_client_ui_primitives.MarkdownText, { text: remoteMessageText(reply.text), labels: mdLabels(), variant: "compact" }) + ), + room2?.origin === "face-to-face" ? (0, import_react10.createElement)("span", { className: "bt-lead", title: who || void 0 }, (0, import_react10.createElement)(PersonMark, { name: who || void 0 })) : null + ); + } + if (reply.kind === "failed") { + return (0, import_react10.createElement)( + "div", + { key: `${reply.botId}-${index}`, className: "bt-event bt-event-error", role: "status" }, + t("{name} could not answer: {reason}", { name: bot.name, reason: failureLabel(reply.code, reply.text) }) + ); + } + if (reply.kind === "lookup") { + return (0, import_react10.createElement)( + "div", + { key: `${reply.botId}-${index}`, className: "bt-event" }, + reply.text ? t("{name} looked up its own chat for “{text}”", { name: bot.name, text: reply.text }) : t("{name} looked up its own chat", { name: bot.name }) + ); + } + if (reply.kind === "missed") { + return (0, import_react10.createElement)("div", { key: `${reply.botId}-${index}`, className: "bt-event" }, t("{name} didn't answer in time", { name: bot.name })); + } + if (reply.kind === "resumed") { + return (0, import_react10.createElement)("div", { key: `${reply.botId}-${index}`, className: "bt-event" }, t("{name} was cut off by a restart and picked its work back up", { name: bot.name })); + } + if (reply.kind === "cut") { + return (0, import_react10.createElement)("div", { key: `${reply.botId}-${index}`, className: "bt-event" }, t("{name} was cut off by a restart again and stopped", { name: bot.name })); + } + if (reply.kind === "approval" || reply.kind === "working") { + return (0, import_react10.createElement)( + "div", + { key: `${reply.botId}-${index}`, className: "bt-event" }, + (0, import_react10.createElement)( + "button", + { type: "button", className: "bt-event-whole", onClick: () => actions.showDetails(sessionId) }, + (0, import_react10.createElement)(BotMark, { bot, size: 16 }), + (0, import_react10.createElement)("span", { className: "bt-event-name" }, t(reply.kind === "working" ? "{name} started on it" : "{name} paused for your approval", { name: bot.name })) + ) + ); + } + if (reply.kind === "stopped") { + return (0, import_react10.createElement)("div", { key: `${reply.botId}-${index}`, className: "bt-event" }, t("You stopped {name}'s work", { name: bot.name })); + } + if (reply.kind === "notice" && room2?.origin === "face-to-face" && String(reply.text ?? "").trim() === WAITING_OTHERS_NOTICE) return null; + if ((reply.kind === "notice" || reply.kind === "left") && leaveNoticeCoveredByClose(room2, reply.text)) return null; + if (reply.kind === "notice" || reply.kind === "joined" || reply.kind === "left" || reply.kind === "started") { + return (0, import_react10.createElement)("div", { key: `notice-${index}`, className: "bt-event", "data-flybot": "face-notice" }, remoteMessageText(reply.text)); + } + const remote = reply.remote || reply.handle && !roster.byId[reply.botId]; + if (remote) { + return (0, import_react10.createElement)(RemoteMessage, { + key: `${reply.botId || reply.handle}-${index}`, + source: { remote: { handle: reply.handle || reply.botId }, senderName: reply.name || reply.who, senderSessionId: reply.botId }, + text: remoteMessageText(reply.text), + roster, + actions, + sessionId, + msgKey: callId ? `${sessionId}:${callId}:${index}` : void 0, + useUi + }); + } + return (0, import_react10.createElement)(GroupMessage, { key: `${reply.botId}-${index}`, bot, text: reply.text, room: room2, roster, actions, msgKey: callId ? `${sessionId}:${callId}:${index}` : void 0, useUi, seq: reply.seq }); + }) + ); } -function useDialogFocus(ref) { - (0, import_react11.useEffect)(() => { - const before = document.activeElement; - ref.current?.focus?.({ preventScroll: true }); - return () => { - if (before && typeof before.focus === "function" && before.isConnected) before.focus({ preventScroll: true }); - }; - }, []); +var DRAWN = /* @__PURE__ */ new Set(["message_bot", "stop_bot", "create_bot", "update_bot", "group_relay", "create_group", "update_group", "delete_group", "post_to_group", "ask_user", "request_secret", "ask_user_question", "read_group_chat", "remember", "forget"]); +var CARDS = /* @__PURE__ */ new Set(["ask_user", "request_secret"]); +function callTree(root) { + const calls = []; + const seen = /* @__PURE__ */ new Set(); + const visit = (call4) => { + const id = call4.callId ?? call4; + if (seen.has(id)) return; + seen.add(id); + calls.push(call4); + for (const child of call4.subCalls ?? []) visit(child); + }; + visit(root); + return calls; } -function usePaneLeft() { - const [paneLeft, setPaneLeft] = (0, import_react11.useState)(280); - (0, import_react11.useLayoutEffect)(() => { - const column = document.querySelector('[class*="_footArea"]')?.parentElement; - if (column) setPaneLeft(Math.round(column.getBoundingClientRect().right)); - }, []); - return paneLeft; +function useShownCards(actions, sessionId, callIds) { + const key = JSON.stringify(callIds); + (0, import_react10.useLayoutEffect)(() => { + const ids = JSON.parse(key); + if (ids.length === 0 || !actions.noteCards) return void 0; + actions.noteCards(sessionId, ids, 1); + return () => actions.noteCards(sessionId, ids, -1); + }, [sessionId, key]); } - -// src/client/member-status.js -var import_react12 = require("react"); -var NO_WORK = { bots: {}, rooms: {}, approvals: [] }; -var ROWS_MAX = 4; -var stepIn = (work, botId, roomId, tool) => { - const own = work.bots?.[botId]; - return own?.roomId === roomId && own.tool === tool ? own.step : void 0; -}; -var workIn = (room2, work, id) => { - const local = (work.rooms?.[room2.id] ?? []).find((entry) => entry.botId === id); - if (local) return { ...local, roomId: room2.id, step: stepIn(work, id, room2.id, local.tool) }; - const own = work.bots?.[id]; - return own !== void 0 && own.roomId === void 0 && own.fromRoomId === room2.id ? { ...own, roomId: room2.id, handed: true } : void 0; -}; -var askedIn = (room2, work, id, doing) => (work.approvals ?? []).filter((entry) => entry.botId === id && (entry.roomId === room2.id || entry.roomId === void 0 && doing?.handed === true)); -function memberWork(room2, work = NO_WORK) { - return room2.members.map((id) => { - const doing = workIn(room2, work, id); - const approvals = askedIn(room2, work, id, doing); - return { id, approvals, doing, here: doing !== void 0, state: approvals.length > 0 ? "ask" : doing ? "work" : "idle" }; - }); -} -function busyMembers(room2, work, roster) { - const busy = memberWork(room2, work).filter((entry) => entry.state !== "idle" && roster.byId[entry.id]); - return [...busy.filter((entry) => entry.state === "ask"), ...busy.filter((entry) => entry.state === "work")].map((entry) => ({ ...entry, bot: roster.byId[entry.id] })); +function renderCall(call4, { sessionId, roster, actions, useUi, findBot }) { + const tool = toolName(call4); + const done = call4.kind === "tool-result"; + const { text: arg, value: argValue } = toolArgs(call4); + const self = roster.byId[sessionId]; + switch (tool) { + case "message_bot": { + const target = findBot(arg("to")) ?? { name: arg("to") ?? "…", color: "gray" }; + if (done && call4.isError) return (0, import_react10.createElement)("div", { className: "bt-event" }, t("Couldn't message {name}", { name: target.name })); + return (0, import_react10.createElement)(EventLine, { prefix: t("Messaged"), bots: [target], onOpen: (bot) => bot.id && actions.openExchange(self?.id ?? sessionId, bot.id), whole: true }); + } + case "stop_bot": { + const target = findBot(arg("bot")) ?? { name: arg("bot") ?? "…", color: "gray" }; + if (done && call4.isError) return (0, import_react10.createElement)("div", { className: "bt-event" }, t("Couldn't stop {name}", { name: target.name })); + return (0, import_react10.createElement)(EventLine, { prefix: t("Stopped"), bots: [target], onOpen: (bot) => bot.id && actions.openExchange(self?.id ?? sessionId, bot.id), whole: true }); + } + case "create_bot": { + const created = findBot(arg("name")); + if (!done || created === void 0) return null; + return (0, import_react10.createElement)(EventLine, { prefix: t("Created"), bots: [created], onOpen: (bot) => actions.openSession(bot.id) }); + } + case "update_bot": { + const target = findBot(arg("name")) ?? findBot(arg("bot")) ?? (arg("bot") ? void 0 : self); + return done && target ? (0, import_react10.createElement)(EventLine, { prefix: t("Updated"), bots: [target], onOpen: (bot) => actions.openSession(bot.id) }) : null; + } + case "group_relay": + return (0, import_react10.createElement)(GroupReplies, { sessionId, callId: call4.callId, done, content: call4.content, roster, actions, useUi }); + case "create_group": + case "update_group": + case "delete_group": + case "post_to_group": { + const verbs = { + create_group: ["Created group", "Couldn't create the group"], + update_group: ["Updated group", "Couldn't update the group"], + delete_group: ["Deleted group", "Couldn't delete the group"], + post_to_group: ["Posted in", "Couldn't post in the group"] + }[tool]; + if (!done) return null; + const result = contentText(call4.content); + const named = /^(Created group|Updated|Nothing changed in|Deleted|Posted in) "([^"]+)"/.exec(result); + if (call4.isError || !named) return (0, import_react10.createElement)("div", { className: "bt-event", title: result }, t(verbs[1])); + const name = named[2]; + const room2 = roster.rooms.find((entry) => entry.name === name); + return (0, import_react10.createElement)(GroupEvent, { prefix: t(verbs[0]), room: tool === "delete_group" ? void 0 : room2, name, roster, actions }); + } + case "ask_user": { + const question = roster.questions?.[sessionId]; + if (!question || question.callId !== call4.callId) return (0, import_react10.createElement)("span", { className: "bt-q-gone", hidden: true }); + return (0, import_react10.createElement)(PendingQuestion, { sessionId, question, actions, roster, inline: true }); + } + case "request_secret": + return (0, import_react10.createElement)(SecretToolCell, { sessionId, callId: call4.callId, name: arg("name"), done, content: call4.content, roster, actions }); + case "ask_user_question": { + if (!done) return null; + let parsed = null; + try { + parsed = JSON.parse(contentText(call4.content)); + } catch { + parsed = null; + } + const answer = parsed?.answers?.[0]; + const question = argValue("questions")?.[0]; + if (!answer) return null; + return (0, import_react10.createElement)("div", { className: "bt-q-inline" }, (0, import_react10.createElement)(SettledQuestion, { question, answer })); + } + case "read_group_chat": { + if (!done) return null; + const room2 = roster.rooms.find((entry) => entry.name === arg("group")); + return room2 ? (0, import_react10.createElement)(GroupEvent, { prefix: t("Read"), room: room2, roster, actions }) : null; + } + case "remember": + case "forget": + return done && !call4.isError ? (0, import_react10.createElement)(MemoryEvent, { sessionId, scope: arg("scope"), result: contentText(call4.content), roster, actions, findBot }) : null; + default: + return null; + } } -function splitBusy(busy) { - const here = busy.filter((entry) => entry.state === "work" && entry.here); - const rows = [...here.filter((entry) => entry.doing.speaking === true), ...here.filter((entry) => entry.doing.speaking !== true)].slice(0, ROWS_MAX); - return { rows, rest: busy.filter((entry) => !rows.includes(entry)) }; +function ToolCell({ node, sessionId, useRoster, useUi, actions }) { + const roster = useRoster((value) => value); + const root = node.data.root; + const turn = node.location?.turn?.turn ?? root.turn; + const hidden = isHiddenTurn(roster, sessionId, turn); + const findBot = (nameOrId) => roster.byId[nameOrId] ?? botByName(roster.bots, nameOrId); + const calls = hidden ? [] : callTree(root); + const drawn = calls.filter((call4) => DRAWN.has(toolName(call4))); + const running = root.kind === "tool-result" ? void 0 : calls.findLast((call4) => call4.kind !== "tool-result"); + const note = running ? toolActivity(toolName(running), toolArgs(running).text, findBot, roster.byId[sessionId]) : null; + useLiveNote(actions, sessionId, `tool:${root.callId ?? node.key}`, note); + useShownCards(actions, sessionId, drawn.filter((call4) => CARDS.has(toolName(call4)) && call4.callId).map((call4) => call4.callId)); + if (drawn.length === 0) return null; + const env = { sessionId, roster, actions, useUi, findBot }; + return (0, import_react10.createElement)(import_react10.Fragment, null, drawn.map((call4, index) => (0, import_react10.createElement)(import_react10.Fragment, { key: call4.callId ?? index }, renderCall(call4, env)))); } -function botGroupWork(botId, work, roster) { - return Object.entries(work.rooms ?? {}).flatMap(([roomId, entries2]) => { - const entry = entries2.find((item) => item.botId === botId); - const room2 = roster.roomsById[roomId]; - return entry && room2 ? [{ room: room2, tool: entry.tool, step: stepIn(work, botId, roomId, entry.tool), since: entry.since }] : []; - }); + +// src/client/awaiting.js +var AWAIT_REPLY_MS = 10 * 60 * 1e3; +function awaitingReply(roster, sessionId, now = Date.now()) { + const at = roster?.remoteAwaiting?.[sessionId]; + if (typeof at !== "number" || roster?.byId?.[sessionId]?.remote === void 0) return false; + return now - at < AWAIT_REPLY_MS; } -var moreRooms = (count) => count === 1 ? t("1 more room") : t("{count} more rooms", { count }); -function summaryText(busy) { - const asks = busy.filter((entry) => entry.state === "ask"); - const workers = busy.filter((entry) => entry.state === "work"); - const names = asks.slice(0, 2).map((entry) => entry.bot.name); - const waiting = asks.length === 0 ? "" : asks.length === 1 ? t("{name} is waiting for your approval", { name: names[0] }) : asks.length === 2 ? t("{first} and {second} are waiting for your approval", { first: names[0], second: names[1] }) : t("{first}, {second} and {count} more are waiting for your approval", { first: names[0], second: names[1], count: asks.length - 2, total: asks.length }); - const first = workers[0]; - const doing = first ? `${first.bot.name} · ${workText(first.doing.tool, first.doing.step)}${workers.length > 1 ? ` +${workers.length - 1}` : ""}` : ""; - return { waiting, doing }; + +// src/client/remote-approval-wait.js +var REMOTE_APPROVAL_LABEL = "Waiting for their approval"; +function remoteApprovalPhrase(remote, { tool, inputAt = 0 } = {}) { + if (!remote) return null; + if (tool && inputAt <= (tool.at ?? 0)) return null; + return { key: "remote-approval", label: REMOTE_APPROVAL_LABEL, state: "thinking" }; } -function Face({ entry, size }) { - return (0, import_react12.createElement)( - "span", - { className: "bt-mw-face", "data-ask": entry.state === "ask" || void 0 }, - (0, import_react12.createElement)(BotMark, { bot: entry.bot, size, state: entry.state === "ask" ? "alert" : toolState(entry.doing?.tool ?? "") }) - ); + +// src/client/activity-row-host.js +var RUNNING_HOST = "[data-chat-flow] [data-chat-running]"; +var TAIL_HOST = "tail"; +function findRunningHostIn(root) { + if (!root || typeof root.querySelector !== "function") return null; + if (typeof root.matches === "function" && root.matches("[data-chat-running]")) return root; + if (typeof Element !== "undefined" && root instanceof Element) return root.querySelector("[data-chat-running]"); + return root.querySelector(RUNNING_HOST); } -var elapsedSince = (now, at) => typeof at === "number" && now - at >= 6e4 ? elapsedLabel(now - at) : ""; -function WorkRow({ bot, tool, step, label, at, now, onOpen }) { - const text2 = `${label} · ${workText(tool, step)}`; - const elapsed = elapsedSince(now, at); - return (0, import_react12.createElement)( - "button", - { type: "button", className: "bt-mw-row", "aria-label": [text2, elapsed].filter(Boolean).join(" · "), onClick: onOpen }, - (0, import_react12.createElement)(BotMark, { bot, size: 22, state: toolState(tool ?? ""), live: true }), - (0, import_react12.createElement)("span", { className: "bt-mw-row-text" }, (0, import_react12.createElement)(Shimmer, null, text2)), - elapsed ? (0, import_react12.createElement)("span", { className: "bt-mw-row-time" }, `· ${elapsed}`) : null - ); + +// src/client/activity-row.js +var import_react11 = require("react"); +var import_react_dom2 = require("react-dom"); +var PHRASE_HOLD_MS = 800; +var drawPhrase = (pool) => t(pool[Math.min(pool.length - 1, Math.floor(Math.random() * pool.length))]); +function elapsedLabel(ms) { + const minutes = Math.floor(ms / 6e4); + if (minutes < 60) return `${Math.max(1, minutes)}m`; + const hours = Math.floor(minutes / 60); + return minutes % 60 === 0 ? `${hours}h` : `${hours}h ${minutes % 60}m`; } -function SummaryLine({ busy, now, onOpen }) { - const { waiting, doing } = summaryText(busy); - const elapsed = doing ? elapsedSince(now, busy.find((entry) => entry.state === "work")?.doing.since) : ""; - return (0, import_react12.createElement)( - "button", - { type: "button", className: "bt-mw-row bt-mw-line", "aria-label": [waiting, doing, elapsed].filter(Boolean).join(" · "), onClick: onOpen }, - (0, import_react12.createElement)("span", { className: "bt-mw-stack", "aria-hidden": true }, busy.slice(0, 4).map((entry) => (0, import_react12.createElement)(Face, { key: entry.id, entry, size: 16 }))), - (0, import_react12.createElement)( - "span", - { className: "bt-mw-row-text" }, - waiting ? (0, import_react12.createElement)("span", { className: "bt-mw-ask" }, waiting) : null, - waiting && doing ? " · " : null, - doing ? (0, import_react12.createElement)(Shimmer, null, doing) : null - ), - elapsed ? (0, import_react12.createElement)("span", { className: "bt-mw-row-time" }, `· ${elapsed}`) : null - ); +var REMOTE_GRACE_MS = 1500; +function useAwaitingReply(roster, sessionId) { + const at = roster?.remoteAwaiting?.[sessionId]; + const [, tick] = (0, import_react11.useState)(0); + const shown = awaitingReply(roster, sessionId); + (0, import_react11.useEffect)(() => { + if (!shown) return void 0; + const timer = setTimeout(() => tick((n) => n + 1), Math.max(0, at + AWAIT_REPLY_MS - Date.now()) + 50); + return () => clearTimeout(timer); + }, [shown, at]); + return shown; } -function MemberDock({ sessionId, useRoster, useWork, actions }) { - const roster = useRoster((value) => value); - const work = useWork((value) => value); - const room2 = roster.roomsById[sessionId]; - const bot = room2 ? void 0 : roster.byId[sessionId]; - const ownChat = bot !== void 0 && work.bots?.[bot.id] !== void 0 && work.bots[bot.id].roomId === void 0; - const busy = room2 ? busyMembers(room2, work, roster) : []; - const groups = bot !== void 0 && !ownChat ? botGroupWork(bot.id, work, roster) : []; - const shown = busy.length > 0 || groups.length > 0; - const now = useNow(shown); - const ref = (0, import_react12.useRef)(null); - useFlowColumn(ref, shown, sessionId); - if (!shown) return null; - if (room2) { - const { rows: rows2, rest } = splitBusy(busy); - const open = () => actions.showDetails(sessionId); - return (0, import_react12.createElement)( - "div", - { ref, className: "bt-mw-dock-stack" }, - rows2.map((entry) => (0, import_react12.createElement)(WorkRow, { key: entry.id, bot: entry.bot, tool: entry.doing.tool, step: entry.doing.step, label: entry.bot.name, at: entry.doing.since, now, onOpen: open })), - rest.length > 0 ? (0, import_react12.createElement)(SummaryLine, { busy: rest, now, onOpen: open }) : null - ); - } - const rows = groups.slice(0, ROWS_MAX); - const more = groups.length - rows.length; - return (0, import_react12.createElement)( - "div", - { ref, className: "bt-mw-dock-stack" }, - rows.map((item) => (0, import_react12.createElement)(WorkRow, { - key: item.room.id, - bot, - tool: item.tool, - step: item.step, - label: t("in {name}", { name: item.room.name }), - at: item.since, - now, - onOpen: () => actions.openSession(item.room.id) - })), - more > 0 ? (0, import_react12.createElement)( - "div", - { className: "bt-mw-row bt-mw-line bt-mw-static" }, - (0, import_react12.createElement)("span", { className: "bt-mw-row-text" }, moreRooms(more)) - ) : null - ); +function findHost(where) { + if (where !== TAIL_HOST) return findRunningHostIn(document); + const flow = document.querySelector("[data-conversation-scroll] [data-chat-flow]") ?? document.querySelector("[data-chat-flow]"); + if (!flow) return null; + const running = findRunningHostIn(flow); + if (running) return running; + const tails = flow.querySelectorAll(":scope > * [data-turn-tail], :scope > [data-turn-tail]"); + return tails.length ? tails[tails.length - 1] : null; } -function useFlowColumn(ref, active, key) { - (0, import_react12.useLayoutEffect)(() => { - const node = ref.current; - const flow = document.querySelector("[data-chat-flow]"); - if (!active || !node || !flow) return void 0; - const place = () => { - node.style.marginInlineStart = "0px"; - const column = flow.getBoundingClientRect(); - node.style.marginInlineStart = `${Math.round(column.left - node.getBoundingClientRect().left)}px`; - node.style.width = `${Math.round(column.width)}px`; +var lastTurnOnScreen = (kind) => { + let top = -1; + for (const node of document.querySelectorAll(`[data-chat-flow-kind${kind ? `="${kind}"` : ""}][data-chat-turn]`)) top = Math.max(top, Number(node.dataset.chatTurn)); + return top; +}; +function useReplyAfterSend(remote, sending) { + const sendTurn = (0, import_react11.useRef)(-1); + const wasSending = (0, import_react11.useRef)(false); + const [replied, setReplied] = (0, import_react11.useState)(false); + (0, import_react11.useEffect)(() => { + if (!remote) return void 0; + if (sending) { + wasSending.current = true; + sendTurn.current = -1; + setReplied(false); + return void 0; + } + if (wasSending.current) { + wasSending.current = false; + sendTurn.current = lastTurnOnScreen(); + } + if (sendTurn.current < 0) return void 0; + let frame = 0; + const check = () => { + frame = 0; + if (lastTurnOnScreen("assistant-step") > sendTurn.current) setReplied(true); }; - place(); - const observer = new ResizeObserver(place); - for (const target of [flow, flow.parentElement, node.parentElement]) if (target) observer.observe(target); - return () => observer.disconnect(); - }, [active, key]); -} -function useNow(active) { - const [now, setNow] = (0, import_react12.useState)(Date.now); - (0, import_react12.useEffect)(() => { - if (!active) return void 0; - const timer = setInterval(() => setNow(Date.now()), 3e4); - return () => clearInterval(timer); - }, [active]); - return now; -} -var since = (now, at) => typeof at === "number" && now - at >= 6e4 ? ` · ${elapsedLabel(now - at)}` : ""; -var alwaysChoices = (approval, bot) => bot?.remote ? [] : [ - ["always-bot", "Always allow for this Bot"], - ...approval.roomId ? [["always-room", "Always allow for this group"]] : [] -]; -function ApprovalCard({ approval, bot, actions }) { - const [busy, setBusy] = (0, import_react12.useState)(false); - const answer = (choice) => { - setBusy(true); - void Promise.resolve(actions.answerApproval(approval.id, choice)).catch((error) => actions.reportError(error, "approval")).finally(() => setBusy(false)); - }; - return (0, import_react12.createElement)( - "div", - { className: "bt-mw-card", role: "group", "aria-label": t("{name} is waiting for your approval", { name: bot.name }) }, - (0, import_react12.createElement)("div", { className: "bt-mw-tool" }, approval.tool), - approval.detail?.text ? (0, import_react12.createElement)("div", { className: "bt-mw-text" }, approval.detail.text) : null, - approval.reason ? (0, import_react12.createElement)("div", { className: "bt-mw-reason" }, approval.reason) : null, - approval.detail?.code ? (0, import_react12.createElement)("code", { className: "bt-mw-code", title: approval.detail.code }, approval.detail.code) : null, - approval.answerable ? (0, import_react12.createElement)( - "div", - { className: "bt-mw-actions" }, - (0, import_react12.createElement)("button", { type: "button", className: "bt-q-btn", disabled: busy, onClick: () => answer("reject") }, t("Reject")), - (0, import_react12.createElement)("button", { type: "button", className: "bt-q-submit", disabled: busy, onClick: () => answer("allow") }, t("Allow once")), - ...alwaysChoices(approval, bot).map(([choice, label]) => (0, import_react12.createElement)("button", { key: choice, type: "button", className: "bt-q-btn", "data-choice": choice, disabled: busy, onClick: () => answer(choice) }, t(label))) - ) : (0, import_react12.createElement)( - "div", - { className: "bt-mw-actions" }, - (0, import_react12.createElement)("button", { type: "button", className: "bt-q-btn", onClick: () => actions.openSession(bot.id) }, t("Open {name}'s chat", { name: bot.name })) - ) - ); -} -function MemberWork({ entry, bot, room: room2, actions, now }) { - const [stopping, setStopping] = (0, import_react12.useState)(false); - if (entry.state === "ask") { - return (0, import_react12.createElement)( - "div", - { className: "bt-mw-member" }, - (0, import_react12.createElement)("div", { className: "bt-mw-state bt-mw-ask" }, `${t("Waiting for your approval")}${since(now, entry.approvals[0].at)}`), - entry.approvals.map((approval) => (0, import_react12.createElement)(ApprovalCard, { key: approval.id, approval, bot, actions })) - ); - } - if (entry.state !== "work") return null; - const stop = () => { - setStopping(true); - void Promise.resolve(actions.stopMemberWork(room2.id, bot.id)).catch((error) => actions.reportError(error, "stop")).finally(() => setStopping(false)); - }; - return (0, import_react12.createElement)( - "div", - { className: "bt-mw-member" }, - (0, import_react12.createElement)( - "div", - { className: "bt-mw-state bt-mw-work" }, - (0, import_react12.createElement)("span", null, (0, import_react12.createElement)(Shimmer, null, workText(entry.doing.tool, entry.doing.step)), since(now, entry.doing.since)), - entry.here && entry.doing.handed !== true ? (0, import_react12.createElement)("button", { type: "button", className: "bt-mw-stop", disabled: stopping, onClick: stop }, t("Stop")) : null - ) - ); -} -function useMemberWork(room2, useWork) { - const work = useWork?.((value) => value) ?? NO_WORK; - const entries2 = room2 ? memberWork(room2, work) : []; - const now = useNow(entries2.some((entry) => entry.state !== "idle")); - return { entries: entries2, now }; -} -var MEMBER_CSS = ` -.bt-mw-dock-stack{box-sizing:border-box;display:flex;flex-direction:column;align-items:flex-start;gap:2px;width:var(--dsh-chat-content-width,min(960px,100%));margin:0 auto 6px;pointer-events:none} -.bt-mw-row{pointer-events:auto;display:flex;align-items:center;gap:10px;min-width:0;max-width:calc(100% + 6px);height:32px;margin-inline-start:-6px;padding:0 12px 0 6px;border:0;border-radius:16px;background:none;color:var(--bt-ink-2);font:inherit;font-size:14px;cursor:pointer;animation:bt-mw-in .32s cubic-bezier(.23,1,.32,1) backwards;transition:background-color .16s ease-out} -.bt-mw-row:hover{background:var(--bt-hover)} -.bt-mw-static{cursor:default;padding-inline-start:38px} -.bt-mw-static:hover{background:none} -.bt-mw-row>.bt-mark{flex:none} -.bt-mw-row-text{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} -.bt-mw-row-time{flex:none;font-size:12px;color:var(--bt-ink-3);white-space:nowrap} -.bt-mw-work{display:flex;align-items:center;justify-content:space-between;gap:8px} -.bt-mw-stop{flex:none;height:22px;padding:0 10px;border:1px solid var(--bt-line,rgba(0,0,0,.12));border-radius:11px;background:none;color:var(--bt-ink-2);font:inherit;font-size:12px;cursor:pointer} -.bt-mw-stop:hover{background:var(--bt-hover)} -.bt-mw-stop:disabled{opacity:.5;cursor:default} -.bt-mw-line{gap:8px} -.bt-mw-stack{display:inline-flex;align-items:center;flex:none;line-height:0} -.bt-mw-stack>*+*{margin-left:-4px} -.bt-mw-face{position:relative;display:inline-flex;flex:none;border-radius:50%} -.bt-mw-stack .bt-mw-face{box-shadow:0 0 0 1.5px var(--bt-main,#fff)} -.bt-mw-face[data-ask]::after{content:"";position:absolute;right:-2px;bottom:-2px;width:7px;height:7px;border-radius:50%;background:#e3a008;box-shadow:0 0 0 1.5px var(--bt-main,#fff)} -.bt-mw-ask{color:#b7791f;flex:none;white-space:nowrap} -body[data-ds-dark-theme] .bt-mw-ask{color:#f0b84a} -.bt-mw-member{padding:0 6px 8px 42px;display:flex;flex-direction:column;gap:6px} -.bt-mw-state{font-size:12px;line-height:16px;color:var(--bt-ink-2);margin-top:-4px} -.bt-mw-card{display:flex;flex-direction:column;gap:4px;padding:10px 12px;border-radius:12px;background:var(--bt-hover);font-size:12px;line-height:17px;color:var(--bt-ink-2)} -.bt-mw-tool{font-weight:600;color:var(--bt-ink)} -.bt-mw-text{color:var(--bt-ink);overflow-wrap:anywhere} -.bt-mw-reason{overflow-wrap:anywhere;display:-webkit-box;-webkit-line-clamp:3;-webkit-box-orient:vertical;overflow:hidden} -.bt-mw-code{display:block;font:11px/16px ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;color:var(--bt-ink);white-space:pre-wrap;overflow-wrap:anywhere;max-height:120px;overflow:auto} -.bt-mw-actions{display:flex;flex-wrap:wrap;justify-content:flex-end;gap:6px;margin-top:4px} -.bt-mw-actions>button{height:28px;padding:0 12px;font-size:12px} -.bt-mw-actions>button:disabled{opacity:.5;cursor:default} -@keyframes bt-mw-in{from{opacity:0;transform:translateY(4px)}} -@media (prefers-reduced-motion:reduce){.bt-mw-row{animation:none}} -`; - -// src/shared/task-board.js -var COLUMNS = ["not-started", "in-progress", "done"]; -var PRIORITIES = ["urgent", "high", "normal", "low"]; -var COLUMN_OF = { - open: "not-started", - claimed: "not-started", - working: "in-progress", - blocked: "in-progress", - "result-ready": "in-progress", - accepted: "done", - cancelled: "done", - failed: "done" -}; -function columnOf(task) { - return COLUMN_OF[task?.status] ?? "not-started"; -} -function occupiesSlot(task) { - return task?.status === "claimed" || task?.status === "working"; -} -function priorityOf(task) { - return PRIORITIES.includes(task?.priority) ? task.priority : "normal"; -} -var PRIORITY_RANK = { urgent: 0, high: 1, normal: 2, low: 3 }; -function workBucket(task) { - if (task?.status === "open" || task?.status === "claimed" || task?.status === "working") return 0; - if (task?.status === "blocked" || task?.status === "result-ready") return 1; - return 2; -} -function compareForWork(a, b) { - const byBucket = workBucket(a) - workBucket(b); - if (byBucket !== 0) return byBucket; - const byPriority = (PRIORITY_RANK[priorityOf(a)] ?? 2) - (PRIORITY_RANK[priorityOf(b)] ?? 2); - if (byPriority !== 0) return byPriority; - const byWorking = (a?.status === "working" ? 0 : 1) - (b?.status === "working" ? 0 : 1); - if (byWorking !== 0) return byWorking; - return (b?.updatedAt ?? 0) - (a?.updatedAt ?? 0); -} -function sameRole(a, b) { - const left = String(a ?? "").trim().toLowerCase(); - const right = String(b ?? "").trim().toLowerCase(); - return left !== "" && left === right; -} -function compareForQueue(a, b) { - const byPriority = (PRIORITY_RANK[priorityOf(a)] ?? 2) - (PRIORITY_RANK[priorityOf(b)] ?? 2); - if (byPriority !== 0) return byPriority; - return (a?.createdAt ?? 0) - (b?.createdAt ?? 0); -} -function progressOf(task) { - const value = task?.progress; - if (typeof value !== "number" || !Number.isFinite(value)) return columnOf(task) === "done" && task?.status !== "cancelled" && task?.status !== "failed" ? 100 : 0; - return Math.max(0, Math.min(100, Math.round(value))); -} - -// src/client/inbox.js -var import_react13 = require("react"); -var KEEP_MS = 7 * 864e5; -var POLL_MS2 = 3e4; -var DONE_MAX = 20; -var FEEDBACK_MS = 5e3; -var line = (d) => (0, import_react13.createElement)("path", { d }); -var icon = (...paths) => function StatusIcon() { - return (0, import_react13.createElement)("svg", { width: 14, height: 14, viewBox: "0 0 20 20", fill: "none", stroke: "currentColor", strokeWidth: 1.8, strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": true }, paths.map(line)); -}; -var RING = "M10 3.25a6.75 6.75 0 1 1 0 13.5 6.75 6.75 0 0 1 0-13.5z"; -var InboxIcon = icon("M3.5 11.5l1.9-6.1A1.5 1.5 0 0 1 6.83 4.35h6.34a1.5 1.5 0 0 1 1.43 1.05l1.9 6.1", "M3.5 11.5v3.25a1.5 1.5 0 0 0 1.5 1.5h10a1.5 1.5 0 0 0 1.5-1.5V11.5", "M3.5 11.5h3.75l1 1.75h3.5l1-1.75h3.75"); -var AskIcon = icon(RING, "M10 6.75v3.75", "M10 13.25h.01"); -var HeldIcon = icon(RING, "M10 6.5V10l2.25 1.5"); -var QuestionIcon = icon(RING, "M8.1 8a2 2 0 1 1 2.65 1.9c-.45.18-.75.6-.75 1.1v.25", "M10 13.5h.01"); -var ResultIcon = icon(RING, "M7 10.25l2 2 4-4.25"); -var FailedIcon = icon(RING, "M7.75 7.75l4.5 4.5M12.25 7.75l-4.5 4.5"); -var RunIcon = icon(RING, "M8.5 7.25v5.5l4.25-2.75z"); -var DoneIcon = icon("M4.5 10.5l3.5 3.5 7.5-8"); -var CancelledIcon = icon(RING, "M5.25 14.75l9.5-9.5"); -var STATUSES = { - approval: { label: "Needs approval", tone: "ask", Icon: AskIcon }, - held: { label: "Waiting to send", tone: "ask", Icon: HeldIcon }, - input: { label: "Needs your answer", tone: "ask", Icon: QuestionIcon }, - result: { label: "Result ready", tone: "ask", Icon: ResultIcon }, - failed: { label: "Failed", tone: "danger", Icon: FailedIcon }, - running: { label: "Working", tone: "run", Icon: RunIcon }, - task: { label: "In progress", tone: "run", Icon: RunIcon }, - done: { label: "Done", tone: "done", Icon: DoneIcon }, - cancelled: { label: "Cancelled", tone: "muted", Icon: CancelledIcon } -}; -var GROUPS = [ - { id: "needs", title: "Needs you", empty: "Nothing needs you right now." }, - { id: "progress", title: "In progress", empty: "Nothing is running." }, - { id: "done", title: "Done", empty: "Nothing finished in the last 7 days." } -]; -var sharedTasks = []; -var taskListeners = /* @__PURE__ */ new Set(); -function publishTasks(list) { - sharedTasks = Array.isArray(list) ? list : []; - for (const listener of taskListeners) listener(sharedTasks); -} -function useTasks(actions) { - const [tasks, setTasks] = (0, import_react13.useState)(sharedTasks); - (0, import_react13.useEffect)(() => { - taskListeners.add(setTasks); - let stopped = false; - let timer; - const load = () => Promise.resolve(actions?.tasks?.({ archive: true })).then((list) => { - if (!stopped && list !== void 0) publishTasks(list); - }).catch(() => { - }).finally(() => { - if (!stopped) timer = setTimeout(load, POLL_MS2); + const observer = new MutationObserver(() => { + if (!frame) frame = requestAnimationFrame(check); }); - load(); + observer.observe(document.querySelector("[data-conversation-scroll]") ?? document.body, { childList: true, subtree: true }); + check(); return () => { - stopped = true; - clearTimeout(timer); - taskListeners.delete(setTasks); + cancelAnimationFrame(frame); + observer.disconnect(); }; - }, []); - return tasks; -} -var workflowsOf = (settled) => settled?.phase === "ready" && Array.isArray(settled.pack?.workflows) ? settled.pack.workflows : []; -var sharedWorkflows = []; -var workflowListeners = /* @__PURE__ */ new Set(); -var workflowPoll = null; -function publishWorkflows(settled) { - sharedWorkflows = workflowsOf(settled); - for (const listener of workflowListeners) listener(sharedWorkflows); + }, [remote, sending]); + return replied; } -function useWorkflows(actions) { - const [workflows, setWorkflows] = (0, import_react13.useState)(sharedWorkflows); - (0, import_react13.useEffect)(() => { - workflowListeners.add(setWorkflows); - if (workflowPoll === null && typeof actions?.workflowState === "function") { - const load = () => Promise.resolve(actions.workflowState()).then(publishWorkflows, () => { - }); - load(); - workflowPoll = setInterval(load, POLL_MS2); +function useRunningHost(active, where = RUNNING_HOST) { + const [host, setHost] = (0, import_react11.useState)(null); + (0, import_react11.useEffect)(() => { + if (!active) { + setHost(null); + return void 0; } + let frame = 0; + const find = () => { + frame = 0; + const node = findHost(where); + setHost((prev) => prev === node ? prev : node); + }; + const observer = new MutationObserver(() => { + if (!frame) frame = requestAnimationFrame(find); + }); + observer.observe(document.querySelector("[data-conversation-scroll]") ?? document.body, { childList: true, subtree: true }); + find(); return () => { - workflowListeners.delete(setWorkflows); - if (workflowListeners.size === 0 && workflowPoll !== null) { - clearInterval(workflowPoll); - workflowPoll = null; - } + cancelAnimationFrame(frame); + observer.disconnect(); }; - }, []); - return workflows; + }, [active, where]); + return host; } -var ownBot = (roster, id) => { - const bot = roster.byId?.[id]; - if (bot === void 0 || bot.remote) return void 0; - const me = roster.self?.memberId; - return me && bot.ownerId && bot.ownerId !== me ? void 0 : bot; -}; -var people = (roster, botId) => ({ assigner: roster.self?.memberId ?? "me", owner: roster.self?.memberId ?? "me", executingBot: botId ?? null }); -var roomName = (roster, id) => roster.roomsById?.[id]?.name; -var placeOf = (roster, roomId) => roomId && roomName(roster, roomId) ? t("in {name}", { name: roomName(roster, roomId) }) : ""; -var lastWhy = (task) => [...task.actors ?? []].reverse().find((entry) => typeof entry.why === "string" && entry.why !== "")?.why ?? ""; -var chatOfTask = (task) => task.origin?.roomId ?? task.origin?.sessionId ?? task.ownerBotId; -var ownAskChat = (roster, bot, text2) => { - if (bot === void 0 || typeof text2 !== "string" || text2 === "") return void 0; - for (const [key, question] of Object.entries(roster.questions ?? {})) { - if (question == null || typeof question !== "object" || roster.roomsById?.[key]) continue; - if (question.question !== text2) continue; - if (key === bot.id || key === currentPart(bot)) return bot.id; - } - return void 0; -}; -var placeKey = (botId, roomId) => `${botId}|${roomId ?? ""}`; -var partOf = (roster, chat) => roster.roomsById?.[chat] || !roster.byId?.[chat] ? chat : currentPart(roster.byId[chat]); -var runningIn = (work, botId, roomId) => roomId === void 0 ? work.bots?.[botId]?.roomId === void 0 ? work.bots?.[botId] : void 0 : (work.rooms?.[roomId] ?? []).find((entry) => entry.botId === botId) ?? (work.bots?.[botId]?.roomId === roomId ? work.bots[botId] : void 0); -function latestActivity(card, { roster, work, sessions }) { - const options = []; - if (card.kind === "task") options.push(lastWhy(card.task)); - if (card.kind === "run" || card.kind === "task") { - const entry = card.executingBot ? runningIn(work, card.executingBot, roster.roomsById?.[card.chat] ? card.chat : void 0) : void 0; - if (entry) options.push(`${workPhrase(entry.tool)}…`); - } - if (sessions?.byId && card.chat) options.push(previewOf(sessions, partOf(roster, card.chat), [], [], botNames(roster))); - return options.find((text2) => typeof text2 === "string" && text2 !== "" && text2 !== card.title && text2 !== card.reason) ?? ""; -} -function toolCalls(card, { roster, work }) { - const botId = card.bot?.id; - if (botId === void 0) return 0; - const roomId = roster.roomsById?.[card.chat] ? card.chat : void 0; - const running = runningIn(work, botId, roomId); - if (running !== void 0) return running.calls ?? 0; - let newest; - for (const entry of work.ended ?? []) { - if (entry.botId !== botId || (entry.roomId ?? "") !== (roomId ?? "")) continue; - if ((entry.at ?? 0) >= (newest?.at ?? -Infinity)) newest = entry; - } - return newest?.calls ?? 0; -} -var endedAt = (run) => [run?.finishedAt, run?.updatedAt, run?.at].find((at) => typeof at === "number"); -function inboxModel({ roster, work = {}, tasks = [], sessions, workflows = [], now = Date.now() }) { - const needs = []; - const progress = []; - const done = []; - const fresh = (at) => typeof at === "number" && now - at <= KEEP_MS; - const waitingAt = /* @__PURE__ */ new Set(); - for (const approval of work.approvals ?? []) { - const bot = ownBot(roster, approval.botId); - if (bot === void 0) continue; - waitingAt.add(`${approval.botId}|${approval.roomId ?? ""}`); - needs.push({ - id: `approval:${approval.id}`, - kind: "approval", - status: "approval", - bot, - at: approval.at, - title: [approval.tool, approval.detail?.text].filter(Boolean).join(" · ") || t("A tool call"), - place: placeOf(roster, approval.roomId), - reason: approval.reason ?? "", - chat: approval.roomId ?? bot.id, - approval, - ...people(roster, bot.id) - }); - } - for (const [key, question] of Object.entries(roster.questions ?? {})) { - if (question == null || typeof question !== "object") continue; - const room2 = roster.roomsById?.[key]; - const bot = room2 ? ownBot(roster, question.botId) : (roster.bots ?? []).find((candidate) => ownBot(roster, candidate.id) && (candidate.id === key || currentPart(candidate) === key)); - if (bot === void 0) continue; - waitingAt.add(room2 ? `${bot.id}|${room2.id}` : `${bot.id}|`); - needs.push({ - id: `question:${question.id ?? key}`, - kind: "question", - status: "input", - bot, - ...room2 ? { room: room2 } : {}, - at: question.askedAt, - title: question.question ?? "", - reason: question.detail ?? "", - place: room2 ? placeOf(roster, key) : "", - chat: room2 ? key : bot.id, - sessionId: key, - question, - ...people(roster, bot.id) - }); - } - const held = (chat, count, extra) => needs.push({ - id: `held:${chat}`, - kind: "held", - status: "held", - chat, - at: void 0, - title: count === 1 ? t("1 message waits for your approval") : t("{count} messages wait for your approval", { count }), - hint: t("Approve it in the chat"), - ...extra - }); - for (const bot of roster.bots ?? []) { - const count = bot.pendingCount ?? 0; - if (count <= 0) continue; - if (bot.remote) held(bot.id, count, { bot, place: "", ...people(roster, null) }); - else if (ownBot(roster, bot.id)) held(bot.id, count, { bot, place: "", ...people(roster, bot.id) }); - } - for (const room2 of roster.rooms ?? []) { - const count = room2.pendingCount ?? 0; - if (count > 0) held(room2.id, count, { room: room2, place: placeOf(roster, room2.id), ...people(roster, null) }); - } - const seen = /* @__PURE__ */ new Set(); - const running = (botId, entry, roomId) => { - const bot = ownBot(roster, botId); - const key = `${botId}|${roomId ?? ""}`; - if (bot === void 0 || seen.has(key) || waitingAt.has(key)) return; - seen.add(key); - progress.push({ - id: `run:${key}`, - kind: "run", - status: "running", - bot, - at: entry.since, - since: entry.since, - title: `${workPhrase(entry.tool)}…`, - place: placeOf(roster, roomId), - chat: roomId ?? bot.id, - ...people(roster, bot.id) - }); - }; - for (const [botId, entry] of Object.entries(work.bots ?? {})) running(botId, entry, entry.roomId); - for (const [roomId, entries2] of Object.entries(work.rooms ?? {})) for (const entry of entries2) running(entry.botId, entry, roomId); - const lastEnded = /* @__PURE__ */ new Map(); - for (const entry of work.ended ?? []) { - const key = placeKey(entry.botId, entry.roomId); - if ((entry.at ?? 0) >= (lastEnded.get(key)?.at ?? -Infinity)) lastEnded.set(key, entry); - } - 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({ - id: `turn:${entry.id}`, - kind: "turn", - status: "failed", - bot, - at: entry.at, - title: asked || t("The last turn did not finish"), - reason: entry.failed, - place: placeOf(roster, entry.roomId), - chat, - ...people(roster, bot.id) - }); - } - for (const task of tasks) { - const bot = ownBot(roster, task.ownerBotId); - if (bot === void 0) continue; - const askedIn2 = task.waitingFor === "input" ? ownAskChat(roster, bot, task.inputRequest?.question) : void 0; - const card = { - id: `task:${task.id}`, - kind: "task", - bot, - at: task.updatedAt, - title: task.title || t("Untitled task"), - place: placeOf(roster, task.origin?.roomId), - chat: askedIn2 ?? chatOfTask(task), - task, - ...people(roster, task.workBotId ?? bot.id) +function useRowEntrances(replacing) { + const check = (0, import_react11.useRef)(replacing); + check.current = replacing; + (0, import_react11.useEffect)(() => { + const seen = /* @__PURE__ */ new WeakSet(); + const started = Date.now(); + let column = null; + let frame = 0; + const settle = (event) => { + if (event.target !== event.currentTarget || !/^bt-row/.test(event.animationName)) return; + delete event.currentTarget.dataset.btEnter; + event.currentTarget.removeEventListener("animationend", settle); }; - if (task.status === "failed") { - if (fresh(task.updatedAt)) needs.push({ ...card, status: "failed", reason: lastWhy(task) }); - } else if (task.status === "result-ready") needs.push({ ...card, status: "result", reason: task.result?.summary ?? "" }); - else if (task.status === "blocked") needs.push({ ...card, status: task.waitingFor === "input" ? "input" : "approval", reason: task.inputRequest?.question ?? "" }); - else { - const column = columnOf(task); - if (column === "in-progress") progress.push({ ...card, status: "task", since: startedAtOf(task) }); - else if (column === "done" && fresh(task.updatedAt)) done.push({ ...card, status: task.status === "cancelled" ? "cancelled" : "done" }); - } - } - for (const graph of Array.isArray(workflows) ? workflows : []) { - if (graph === null || typeof graph !== "object") continue; - const seenRuns = /* @__PURE__ */ new Set(); - let latest; - for (const run of [...graph.runs ?? [], ...graph.history ?? []]) { - if (run === null || typeof run !== "object" || run.status !== "failed" || seenRuns.has(run.id)) continue; - seenRuns.add(run.id); - const at2 = endedAt(run); - if (!fresh(at2)) continue; - if (latest === void 0 || at2 > endedAt(latest)) latest = run; - } - if (latest === void 0) continue; - const at = endedAt(latest); - const name = typeof graph.name === "string" && graph.name.trim() !== "" ? graph.name.trim() : String(graph.id ?? t("A workflow")); - const nodeName = typeof latest.failure?.nodeName === "string" ? latest.failure.nodeName.trim() : ""; - const reason = typeof latest.failure?.text === "string" && latest.failure.text !== "" ? latest.failure.text : typeof latest.failure?.code === "string" ? latest.failure.code : ""; - const caller = typeof latest.task?.callerBotId === "string" ? ownBot(roster, latest.task.callerBotId) : void 0; - needs.push({ - id: `workflow:${graph.id ?? name}:${latest.id}`, - kind: "workflow", - status: "failed", - at, - workflow: { id: graph.id ?? "", name }, - run: { id: latest.id }, - title: nodeName === "" ? name : `${name} · ${nodeName}`, - reason, - ...caller === void 0 ? {} : { bot: caller } + const scan = () => { + frame = 0; + const next = document.querySelector("[data-chat-flow]"); + if (next !== column) { + observer.disconnect(); + column = next; + if (column) observer.observe(column, { childList: true, subtree: true }); + } + if (!column) return; + const items = [...column.querySelectorAll(":scope > [data-chat-flow-key]")]; + let last = -1; + items.forEach((item, index) => { + if (seen.has(item)) last = index; + }); + const fresh = items.filter((item) => !seen.has(item) && item.offsetHeight > 0); + for (const item of fresh) seen.add(item); + if (last < 0 || Date.now() - started < 800 || fresh.length > 3) return; + for (const item of fresh) { + if (items.indexOf(item) < last) continue; + const reply = [void 0, "assistant-step", "tool-call"].includes(item.dataset.chatFlowKind); + item.dataset.btEnter = reply && check.current() ? "after-collapse" : "new"; + item.addEventListener("animationend", settle); + } + }; + const observer = new MutationObserver(() => { + if (!frame) frame = requestAnimationFrame(scan); }); - } - if (sessions?.byId) { - for (const bot of roster.bots ?? []) { - if (ownBot(roster, bot.id) === void 0 || work.bots?.[bot.id] !== void 0 || waitingAt.has(`${bot.id}|`)) continue; - if (lastEnded.get(placeKey(bot.id))?.failed !== void 0) continue; - const part = currentPart(bot); - const at = sessions.byId[part]?.updatedAt; - if (!fresh(at)) continue; - const text2 = previewOf(sessions, part, [], [], botNames(roster)); - if (text2 === "") continue; - done.push({ id: `chat:${bot.id}`, kind: "chat", status: "done", bot, at, title: text2, place: "", chat: bot.id, ...people(roster, bot.id) }); - } - } - const newest = (a, b) => (b.at ?? Infinity) - (a.at ?? Infinity); - for (const card of [...needs, ...progress, ...done]) { - card.activity = latestActivity(card, { roster, work, sessions }); - card.calls = toolCalls(card, { roster, work }); - } - const settled = done.sort(newest); - return { needs: needs.sort(newest), progress: progress.sort(newest), done: settled.slice(0, DONE_MAX), doneAll: settled, doneTotal: settled.length }; -} -var needsCount = (roster, work, tasks, now, workflows = []) => inboxModel({ roster, work, tasks, now, workflows }).needs.length; -function startedAtOf(task) { - const started = (task.actors ?? []).filter((actor) => actor.status === "working" && typeof actor.at === "number"); - return started.length ? started.at(-1).at : task.updatedAt; -} -function visibleNeeds(model, answered) { - return { ...model, needs: model.needs.filter((card) => !answered?.[card.id]) }; -} -function inboxAnswer(actions, card, choice) { - if (card?.kind === "question") { - const reply = choice !== null && typeof choice === "object" ? choice : {}; - const selected = Array.isArray(reply.selected) ? reply.selected.filter((item) => typeof item === "string" && item !== "") : []; - const custom = typeof reply.custom === "string" ? reply.custom.trim() : ""; - if (selected.length === 0 && custom === "") return void 0; - return Promise.resolve(actions.answerQuestion(card.sessionId, card.question.id, { selected, custom })); - } - if (card?.kind === "task" && card.status === "input") { - const text2 = typeof choice === "string" ? choice.trim() : ""; - const questionId = card.task?.inputRequest?.id; - if (text2 === "" || typeof questionId !== "string") return void 0; - return Promise.resolve(actions.answerTaskInput(card.task.id, questionId, text2)); - } - if (card?.kind === "task" && card.status === "result") { - if (choice === "accept") return Promise.resolve(actions.acceptTaskResult(card.task.id)); - const note = choice !== null && typeof choice === "object" ? String(choice.note ?? "").trim() : ""; - if (note === "") return void 0; - return Promise.resolve(actions.sendBackTask(card.task.id, note)); - } - if (card?.kind === "approval" && card.approval?.answerable === true) { - return Promise.resolve(actions.answerApproval(card.approval.id, choice)); - } - return void 0; -} -var answerSlots = /* @__PURE__ */ new Map(); -var answerSlot = (id) => { - let slot = answerSlots.get(id); - if (slot === void 0) { - slot = { text: "", picked: [] }; - answerSlots.set(id, slot); - } - return slot; -}; -var resultSlots = /* @__PURE__ */ new Map(); -var resultSlot = (id) => { - let slot = resultSlots.get(id); - if (slot === void 0) { - slot = { open: false, text: "" }; - resultSlots.set(id, slot); - } - return slot; -}; -function ResultActions({ card, busy, onAnswer }) { - const [, redraw] = (0, import_react13.useState)(0); - if (!(card.kind === "task" && card.status === "result")) return null; - const slot = resultSlot(card.id); - const send = () => { - const note = slot.text.trim(); - if (busy || note === "") return; - onAnswer(card, { note }); - }; - return (0, import_react13.createElement)( - "span", - { className: "bt-inbox-result" }, - slot.open ? (0, import_react13.createElement)("input", { - type: "text", - className: "bt-inbox-answer-text", - value: slot.text, - disabled: busy, - "aria-label": t("What should change"), - placeholder: t("What should change"), - onChange: (event) => { - slot.text = String(event.target?.value ?? ""); - redraw((count) => count + 1); - }, - onKeyDown: (event) => { - if (event.key !== "Enter" || event.shiftKey || event.nativeEvent.isComposing) return; - event.preventDefault(); - send(); - } - }) : null, - slot.open ? (0, import_react13.createElement)("button", { type: "button", className: "bt-send bt-soft-sm", disabled: busy, onClick: send }, t("Send it back")) : (0, import_react13.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", disabled: busy, onClick: () => { - slot.open = true; - redraw((count) => count + 1); - } }, t("Send back")), - (0, import_react13.createElement)("button", { type: "button", className: "bt-send bt-soft-sm", disabled: busy, onClick: () => onAnswer(card, "accept") }, t("Accept")) - ); -} -function AnswerFields({ card, busy, onAnswer }) { - const [, redraw] = (0, import_react13.useState)(0); - const question = card.kind === "question"; - const taskInput = card.kind === "task" && card.status === "input" && typeof card.task?.inputRequest?.id === "string"; - if (!question && !taskInput) return null; - const slot = answerSlot(card.id); - const multi = question && card.question?.multiSelect === true; - const options = question && Array.isArray(card.question?.options) ? card.question.options : []; - const send = () => { - if (busy) return; - const custom = slot.text.trim(); - if (question) { - const selected = multi ? slot.picked.filter((label) => label !== "") : []; - if (selected.length === 0 && custom === "") return; - onAnswer(card, { selected, custom }); - return; - } - if (custom === "") return; - onAnswer(card, custom); - }; - const choose = (label) => { - if (busy) return; - if (!multi) { - onAnswer(card, { selected: [label], custom: "" }); - return; - } - slot.picked = slot.picked.includes(label) ? slot.picked.filter((item) => item !== label) : [...slot.picked, label]; - redraw((count) => count + 1); - }; - return (0, import_react13.createElement)( - "span", - { className: "bt-inbox-answer" }, - ...options.map((option, index) => (0, import_react13.createElement)("button", { - key: `${option.label}-${index}`, - type: "button", - className: "bt-soft bt-soft-sm", - disabled: busy, - "aria-pressed": multi ? slot.picked.includes(option.label) : void 0, - onClick: () => choose(option.label) - }, option.label)), - (0, import_react13.createElement)("input", { - type: "text", - className: "bt-inbox-answer-text", - value: slot.text, - disabled: busy, - "aria-label": t("Your own answer"), - placeholder: t("Type your own answer"), - onChange: (event) => { - slot.text = String(event.target?.value ?? ""); - redraw((count) => count + 1); - }, - onKeyDown: (event) => { - if (event.key !== "Enter" || event.shiftKey || event.nativeEvent.isComposing) return; - event.preventDefault(); - send(); - } - }), - (0, import_react13.createElement)("button", { type: "button", className: "bt-send bt-soft-sm", disabled: busy, onClick: send }, t("Submit")) - ); -} -function InboxRow({ wide, useRoster, useWork, useUi, actions }) { - const roster = useRoster((value) => value); - const work = useWork ? useWork((value) => value) : {}; - const open = useUi ? useUi((value) => Boolean(value.inbox)) : false; - const tasks = useTasks(actions); - const workflows = useWorkflows(actions); - const count = needsCount(roster, work, tasks, void 0, workflows); - const label = count > 0 ? t("Inbox, {count} need you", { count }) : t("Inbox"); - return (0, import_react13.createElement)( - "button", - { - type: "button", - className: "bt-row bt-inbox-row", - "aria-label": label, - title: wide ? void 0 : label, - "aria-haspopup": "dialog", - "aria-expanded": open, - onClick: () => actions.openInbox() - }, - (0, import_react13.createElement)("span", { className: "bt-inbox-mark" }, (0, import_react13.createElement)(InboxIcon)), - wide ? (0, import_react13.createElement)("span", { className: "bt-inbox-label" }, t("Inbox")) : null, - count > 0 ? (0, import_react13.createElement)("span", { className: "bt-inbox-count", "aria-hidden": true }, count > 99 ? "99+" : String(count)) : null - ); -} -function Status({ status }) { - const { label, tone, Icon } = STATUSES[status]; - return (0, import_react13.createElement)("span", { className: "bt-inbox-status", "data-tone": tone }, (0, import_react13.createElement)(Icon), (0, import_react13.createElement)("span", null, t(label))); -} -function whenText(card, now) { - if (card.status === "running" || card.status === "task") { - return typeof card.since === "number" && now - card.since >= 6e4 ? t("for {time}", { time: elapsedLabel(now - card.since) }) : t("just started"); - } - return typeof card.at === "number" ? relativeShort(card.at) : ""; -} -function InboxCard({ card, roster, now, busy, error, onAnswer, onOpen }) { - const who = card.bot ? card.bot.name : card.room?.name ?? ""; - const produced = card.kind === "task" && card.status === "result" ? [card.bot?.model] : []; - const meta = [who, ...produced, card.place, whenText(card, now)].filter(Boolean).join(" · "); - const answerable = card.kind === "approval" && card.approval?.answerable === true; - const evidence = card.kind === "task" && card.status === "result" ? card.task?.result?.links ?? [] : []; - const sentBack = card.kind === "task" && card.status === "task" ? card.task?.followUp?.note : void 0; - const target = card.kind === "task" ? card.task?.target?.href : void 0; - const writeBack = card.kind === "task" && card.task?.writeBack?.state === "failed" ? card.task.writeBack : void 0; - const unconfirmed = card.kind === "task" && card.task?.writeBack?.state === "unconfirmed" ? card.task.writeBack : void 0; - return (0, import_react13.createElement)( - "div", - { className: "bt-inbox-card", role: "group", "aria-label": `${t(STATUSES[card.status].label)}: ${card.title}`, "data-status": card.status, "data-run": card.kind === "workflow" ? card.run?.id : void 0 }, - (0, import_react13.createElement)("span", { className: "bt-inbox-ava" }, card.bot ? (0, import_react13.createElement)(BotAvatar, { bot: card.bot, size: 28, badge: false, live: false }) : card.room ? (0, import_react13.createElement)(RoomAvatar, { room: card.room, roster, size: 28 }) : null), - (0, import_react13.createElement)( - "span", - { className: "bt-inbox-copy" }, - (0, import_react13.createElement)( - "span", - { className: "bt-inbox-top" }, - (0, import_react13.createElement)(Status, { status: card.status }), - (0, import_react13.createElement)("span", { className: "bt-inbox-meta", title: meta }, meta) - ), - (0, import_react13.createElement)("span", { className: "bt-inbox-title", title: card.title }, card.title), - card.calls > 0 ? (0, import_react13.createElement)("span", { className: "bt-inbox-calls" }, t("{count} tool calls", { count: card.calls })) : null, - card.reason ? (0, import_react13.createElement)("span", { className: "bt-inbox-reason", title: card.reason }, failureSentence(card.reason, t)) : null, - card.activity ? (0, import_react13.createElement)("span", { className: "bt-inbox-activity", title: card.activity }, t("Latest: {text}", { text: card.activity })) : null, - card.hint ? (0, import_react13.createElement)("span", { className: "bt-inbox-hint" }, card.hint) : null, - evidence.length > 0 ? (0, import_react13.createElement)("span", { className: "bt-inbox-evidence" }, ...evidence.map((href, index) => (0, import_react13.createElement)("a", { - key: `${href}-${index}`, - href, - target: "_blank", - rel: "noreferrer noopener", - className: "bt-inbox-evidence-link", - title: href - }, href))) : null, - sentBack ? (0, import_react13.createElement)("span", { className: "bt-inbox-reason", title: sentBack }, t("Sent back: {note}", { note: sentBack })) : null, - target ? (0, import_react13.createElement)("span", { className: "bt-inbox-evidence" }, (0, import_react13.createElement)("a", { - href: target, - target: "_blank", - rel: "noreferrer noopener", - className: "bt-inbox-evidence-link bt-inbox-target", - title: target - }, t("Comment goes to {target}", { target: target.replace(/^https:\/\/github\.com\//, "") }))) : null, - writeBack ? (0, import_react13.createElement)("span", { className: "bt-inbox-writeback", role: "alert", title: writeBack.error }, t("Could not comment on {target}: {error}", { target: writeBack.target, error: writeBack.error })) : null, - unconfirmed ? (0, import_react13.createElement)("span", { className: "bt-inbox-writeback", title: unconfirmed.target }, t("Could not confirm the comment on {target} went out", { target: unconfirmed.target })) : null, - (0, import_react13.createElement)(AnswerFields, { card, busy, onAnswer }), - (0, import_react13.createElement)(ResultActions, { card, busy, onAnswer }), - error ? (0, import_react13.createElement)("span", { className: "bt-inbox-error", role: "alert" }, error) : null - ), - (0, import_react13.createElement)( - "span", - { className: "bt-inbox-actions" }, - answerable ? (0, import_react13.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", disabled: busy, onClick: () => onAnswer(card, "reject") }, t("Reject")) : null, - answerable ? (0, import_react13.createElement)("button", { type: "button", className: "bt-send bt-soft-sm", disabled: busy, onClick: () => onAnswer(card, "allow") }, t("Allow once")) : null, - ...(answerable ? alwaysChoices(card.approval, card.bot) : []).map(([choice, label]) => (0, import_react13.createElement)("button", { key: choice, type: "button", className: "bt-soft bt-soft-sm", "data-choice": choice, disabled: busy, onClick: () => onAnswer(card, choice) }, t(label))), - (0, import_react13.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", "aria-label": who ? t("Open {name}", { name: card.room?.name ?? who }) : t("Open"), onClick: () => onOpen(card) }, t("Open")) - ) - ); -} -function useNow2(ms) { - const [now, setNow] = (0, import_react13.useState)(Date.now); - (0, import_react13.useEffect)(() => { - const timer = setInterval(() => setNow(Date.now()), ms); - return () => clearInterval(timer); - }, [ms]); - return now; -} -function openCard(actions, card) { - actions.closeInbox(); - if (card?.kind === "workflow") { - actions.openSettings("workflow"); - return; - } - actions.openSession(card.chat); -} -function InboxDialog({ roster, useWork, useSessions, actions, leaving = false }) { - const work = useWork((value) => value); - const sessions = useSessions((value) => value); - const tasks = useTasks(actions); - const workflows = useWorkflows(actions); - const now = useNow2(3e4); - const [answered, setAnswered] = (0, import_react13.useState)({}); - const [busy, setBusy] = (0, import_react13.useState)(""); - const [errors, setErrors] = (0, import_react13.useState)({}); - const [feedback, setFeedback] = (0, import_react13.useState)(""); - const [allDone, setAllDone] = (0, import_react13.useState)(false); - const close = (0, import_react13.useCallback)(() => actions.closeInbox(), []); - useEscape(close); - (0, import_react13.useEffect)(() => { - if (!feedback) return void 0; - const timer = setTimeout(() => setFeedback(""), FEEDBACK_MS); - return () => clearTimeout(timer); - }, [feedback]); - const model = inboxModel({ roster, work, tasks, sessions, workflows, now }); - const groups = visibleNeeds(model, answered); - const answer = (card, choice) => { - setBusy(card.id); - setErrors((value) => ({ ...value, [card.id]: "" })); - const pending = inboxAnswer(actions, card, choice); - if (pending === void 0) { - setBusy(""); - return; - } - void Promise.resolve(pending).then(() => { - setAnswered((value) => ({ ...value, [card.id]: true })); - const name = card.bot?.name ?? ""; - setFeedback(card.kind === "approval" ? choice === "allow" ? t("Allowed once for {name}", { name }) : choice === "always-bot" ? t("Always allowed for {name}", { name }) : choice === "always-room" ? t("Always allowed in this group") : t("Rejected for {name}", { name }) : t("Answered")); - }, (failure) => setErrors((value) => ({ ...value, [card.id]: hostText(failure?.message ?? String(failure)) }))).finally(() => setBusy("")); - }; - const open = (card) => openCard(actions, card); - return (0, import_react13.createElement)( - "div", - { className: "bt-mem-layer bt-inbox-layer", "data-leaving": leaving || void 0 }, - (0, import_react13.createElement)("div", { className: "bt-scrim bt-mem-scrim", onMouseDown: close }), - (0, import_react13.createElement)( - "div", - { className: "bt-mem bt-inbox", role: "dialog", "aria-modal": true, "aria-labelledby": "bt-inbox-heading" }, - (0, import_react13.createElement)( - "div", - { className: "bt-mem-head" }, - (0, import_react13.createElement)("h2", { id: "bt-inbox-heading" }, t("Inbox")), - (0, import_react13.createElement)("span", { className: "bt-mem-status" }, t("Your Bots")), - (0, import_react13.createElement)("span", { className: "bt-mem-head-gap" }), - (0, import_react13.createElement)("button", { type: "button", className: "bt-icon-btn", "aria-label": t("Close inbox"), autoFocus: true, onClick: close }, (0, import_react13.createElement)(CloseIcon)) - ), - (0, import_react13.createElement)( - "div", - { className: "bt-mem-body bt-inbox-body" }, - (0, import_react13.createElement)("div", { className: "bt-inbox-feedback", role: "status", "aria-live": "polite" }, feedback), - GROUPS.map((group) => { - const settled = group.id === "done" ? model.doneAll ?? groups.done : null; - const canOpen = group.id === "done" && settled.length > DONE_MAX; - const rows = canOpen && allDone ? settled : groups[group.id]; - const hidden = group.id === "done" ? Math.max(0, (model.doneTotal ?? rows.length) - rows.length) : 0; - return (0, import_react13.createElement)( - "section", - { key: group.id, className: "bt-inbox-group", "aria-labelledby": `bt-inbox-${group.id}` }, - (0, import_react13.createElement)( - "h3", - { id: `bt-inbox-${group.id}`, className: "bt-inbox-head" }, - (0, import_react13.createElement)("span", null, t(group.title)), - rows.length > 0 ? (0, import_react13.createElement)("span", { className: "bt-inbox-head-count" }, String(rows.length)) : null - ), - rows.length === 0 ? (0, import_react13.createElement)("p", { className: "bt-inbox-empty" }, t(group.empty)) : (0, import_react13.createElement)("div", { className: "bt-inbox-list" }, rows.map((card) => (0, import_react13.createElement)(InboxCard, { - key: card.id, - card, - roster, - now, - busy: busy === card.id, - error: errors[card.id], - onAnswer: answer, - onOpen: open - }))), - hidden > 0 ? (0, import_react13.createElement)("div", { key: "more", className: "bt-inbox-more" }, t("{count} more not shown", { count: hidden })) : null, - canOpen ? (0, import_react13.createElement)("button", { - key: "all", - type: "button", - className: "bt-inbox-show-all", - "aria-expanded": allDone, - onClick: () => setAllDone((value) => !value) - }, t(allDone ? "Show fewer" : "Show all")) : null - ); - }) - ) - ) - ); -} -var INBOX_CSS = ` -.bt-inbox-row{min-height:40px;padding:6px 8px;gap:10px} -.bt-inbox-mark{flex:none;width:36px;height:28px;display:inline-flex;align-items:center;justify-content:center;color:var(--bt-ink-2)} -.bt-inbox-mark svg{width:18px;height:18px} -.bt-inbox-label{flex:1 1 auto;min-width:0;font-size:14px;line-height:20px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis} -.bt-inbox-count{flex:none;min-width:20px;height:20px;padding:0 6px;border-radius:999px;background:var(--bt-accent);color:var(--bt-accent-ink,#fff);font-size:11.5px;line-height:20px;font-weight:600;text-align:center;font-variant-numeric:tabular-nums;box-sizing:border-box} -.bt-rail .bt-inbox-row{position:relative} -.bt-rail .bt-inbox-count{position:absolute;top:2px;right:calc(50% - 22px);min-width:16px;height:16px;padding:0 4px;font-size:10px;line-height:16px;box-shadow:0 0 0 1.5px var(--bt-sidebar)} -.bt-inbox-body{gap:18px} -.bt-inbox-feedback{min-height:0;font-size:13px;line-height:18px;color:var(--bt-ink-2)} -.bt-inbox-feedback:empty{display:none} -.bt-inbox-group{display:flex;flex-direction:column;gap:8px} -.bt-inbox-head{display:flex;align-items:center;gap:6px;margin:0;font-size:13px;line-height:18px;font-weight:600;color:var(--bt-ink-2)} -.bt-inbox-head-count{font-weight:500;color:var(--bt-ink-3);font-variant-numeric:tabular-nums} -.bt-inbox-empty{margin:0;font-size:13px;line-height:18px;color:var(--bt-ink-3)} -.bt-inbox-more{margin:0;font-size:12.5px;line-height:18px;color:var(--bt-ink-3);font-variant-numeric:tabular-nums} -.bt-inbox-show-all{align-self:flex-start;padding:0;border:0;background:none;font:inherit;font-size:12.5px;line-height:18px;color:var(--bt-accent);cursor:pointer} -.bt-inbox-show-all:hover{text-decoration:underline} -.bt-inbox-show-all:focus-visible{outline:2px solid color-mix(in srgb,var(--bt-accent) 45%,transparent);outline-offset:2px;border-radius:4px} -.bt-inbox-list{display:flex;flex-direction:column;gap:6px} -.bt-inbox-card{display:flex;align-items:flex-start;gap:10px;padding:10px 12px;border:.5px solid var(--bt-line-2);border-radius:12px;background:var(--bt-card,var(--bt-main))} -.bt-inbox-card:focus-within{box-shadow:0 0 0 2px color-mix(in srgb,var(--bt-accent) 35%,transparent)} -.bt-inbox-ava{flex:none;line-height:0;padding-top:2px} -.bt-inbox-copy{flex:1 1 auto;min-width:0;display:flex;flex-direction:column;gap:2px} -.bt-inbox-top{display:flex;align-items:center;gap:8px;min-width:0} -.bt-inbox-status{flex:none;display:inline-flex;align-items:center;gap:4px;height:20px;padding:0 7px 0 5px;border-radius:999px;font-size:11.5px;line-height:20px;font-weight:600;white-space:nowrap} -.bt-inbox-status svg{flex:none} -.bt-inbox-status[data-tone=ask]{background:color-mix(in srgb,var(--bt-star) 18%,transparent);color:color-mix(in srgb,var(--bt-star) 70%,var(--bt-ink))} -.bt-inbox-status[data-tone=danger]{background:rgba(224,33,53,.12);color:#c21d2e} -.bt-inbox-status[data-tone=run]{background:color-mix(in srgb,var(--bt-accent) 14%,transparent);color:color-mix(in srgb,var(--bt-accent) 80%,var(--bt-ink))} -.bt-inbox-status[data-tone=done]{background:color-mix(in srgb,var(--bt-green) 16%,transparent);color:color-mix(in srgb,var(--bt-green) 70%,var(--bt-ink))} -.bt-inbox-status[data-tone=muted]{background:var(--bt-hover);color:var(--bt-ink-2)} -.bt-inbox-meta{min-width:0;font-size:12px;line-height:16px;color:var(--bt-ink-3);white-space:nowrap;overflow:hidden;text-overflow:ellipsis} -.bt-inbox-title{font-size:13.5px;line-height:19px;color:var(--bt-ink);white-space:nowrap;overflow:hidden;text-overflow:ellipsis} -.bt-inbox-reason,.bt-inbox-hint,.bt-inbox-activity{font-size:12px;line-height:16px;color:var(--bt-ink-2);white-space:nowrap;overflow:hidden;text-overflow:ellipsis} -/* Needs you is answered on the card, so its ask is the whole text (#1177). The phone sheet clamps these two classes to two lines at a lower specificity; this unsets that clamp. In progress and Done stay one line. */ -.bt-inbox-card:is([data-status=approval],[data-status=held],[data-status=input],[data-status=result],[data-status=failed]) :is(.bt-inbox-title,.bt-inbox-reason){white-space:pre-wrap;overflow-wrap:anywhere;overflow:visible;text-overflow:clip;display:block;min-width:0;-webkit-line-clamp:unset;-webkit-box-orient:unset} -.bt-inbox-calls{font-size:12px;line-height:16px;color:var(--bt-ink-3);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;font-variant-numeric:tabular-nums} -.bt-inbox-activity{color:var(--bt-ink-3)} -.bt-inbox-error{font-size:12px;line-height:16px;color:#c21d2e;overflow-wrap:anywhere} -.bt-inbox-writeback{font-size:12px;line-height:16px;color:#c21d2e;overflow-wrap:anywhere} -.bt-inbox-evidence{display:flex;flex-wrap:wrap;gap:6px;font-size:12px;line-height:16px} -.bt-inbox-evidence-link{color:var(--bt-blue,#3a7afe);text-decoration:none;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:100%} -.bt-inbox-evidence-link:hover{text-decoration:underline} -.bt-inbox-result{display:flex;flex-wrap:wrap;align-items:center;gap:6px;margin-top:6px} -.bt-inbox-result .bt-send,.bt-inbox-result .bt-soft{height:28px;font-size:12px} -.bt-inbox-answer{display:flex;flex-wrap:wrap;align-items:center;gap:6px;margin-top:6px} -.bt-inbox-answer-text{flex:1 1 140px;min-width:0;height:28px;padding:0 8px;border:.5px solid var(--bt-line-2);border-radius:8px;background:transparent;color:var(--bt-ink);font:inherit;font-size:12px} -.bt-inbox-answer .bt-send,.bt-inbox-answer .bt-soft{height:28px;font-size:12px} -.bt-inbox-answer button[aria-pressed=true]{background:var(--bt-active);color:var(--bt-ink)} -.bt-inbox-actions{flex:none;display:flex;align-items:center;gap:6px;align-self:center} -.bt-inbox-actions .bt-send{height:28px;font-size:12px} -`; - -// src/shared/board-card.js -function taskCardKey(id) { - const text2 = String(id ?? "").trim(); - return text2 === "" ? null : `task:${text2}`; -} -function taskCard(task) { - const key = taskCardKey(task?.id); - if (key === null) return null; - return { - source: "task", - key, - id: String(task.id), - title: String(task.title ?? "") - }; -} - -// src/client/board-card.js -var import_react14 = require("react"); -var TASK_SKIN = { - card: "bt-board-card", - top: "bt-board-card-top", - id: "bt-board-id", - badge: "bt-board-badge", - title: "bt-board-title", - labels: "bt-board-labels", - label: "bt-board-label", - owner: "bt-board-who", - note: "bt-board-note" -}; -function BoardCard({ - card, - skin = TASK_SKIN, - attrs = {}, - idText = "", - topExtra = null, - badge = "", - meta = null, - note = "", - footer = null, - actions = null, - emptyTitle = "", - draggable = false, - dragValue = "", - onDragStart = null, - onDragEnd = null -}) { - const labels = Array.isArray(card?.labels) ? card.labels : []; - return (0, import_react14.createElement)( - "article", - { - ...attrs, - className: skin.card, - "data-source": card?.source, - "data-key": card?.key ?? void 0, - draggable: draggable || void 0, - onDragStart: draggable ? (event) => { - event.dataTransfer?.setData("text/plain", dragValue); - if (event.dataTransfer) event.dataTransfer.effectAllowed = "move"; - onDragStart?.(); - } : void 0, - onDragEnd: draggable ? () => onDragEnd?.() : void 0 - }, - (0, import_react14.createElement)( - "span", - { className: skin.top }, - (0, import_react14.createElement)("span", { className: skin.id }, idText), - topExtra, - badge ? (0, import_react14.createElement)("span", { className: skin.badge, "data-badge": card?.badge ?? void 0 }, badge) : null - ), - (0, import_react14.createElement)("span", { className: skin.title }, card?.title || emptyTitle), - labels.length ? (0, import_react14.createElement)("span", { className: skin.labels }, labels.map((name) => (0, import_react14.createElement)("span", { key: name, className: skin.label, "data-label": name }, name))) : null, - card?.owner ? (0, import_react14.createElement)("span", { className: skin.owner, "data-kind": card.ownerKind ?? "human" }, card.owner) : null, - note ? (0, import_react14.createElement)("span", { className: skin.note }, note) : null, - meta, - footer, - actions - ); -} - -// src/client/kanban.js -var import_react15 = require("react"); -var POLL_MS3 = 15e3; -function BoardIcon() { - return (0, import_react15.createElement)( - "svg", - { width: 18, height: 18, viewBox: "0 0 20 20", fill: "none", stroke: "currentColor", strokeWidth: 1.6, strokeLinejoin: "round", "aria-hidden": true }, - (0, import_react15.createElement)("rect", { x: 2.5, y: 3, width: 4, height: 14, rx: 1 }), - (0, import_react15.createElement)("rect", { x: 8, y: 3, width: 4, height: 9, rx: 1 }), - (0, import_react15.createElement)("rect", { x: 13.5, y: 3, width: 4, height: 12, rx: 1 }) - ); -} -function shortId(id) { - const hex = String(id ?? "").replace(/^task-/, ""); - return `#${hex.slice(-6)}`; -} -var ARCHIVE_SHOWN = 20; -function boardTasksFor(tasks, archive, limit = ARCHIVE_SHOWN) { - const newest = [...archive ?? []].sort((a, b) => (b.updatedAt ?? 0) - (a.updatedAt ?? 0) || (b.createdAt ?? 0) - (a.createdAt ?? 0)); - return [...tasks ?? [], ...newest.slice(0, limit)]; -} -function boardColumns(tasks) { - const columns = { "not-started": [], "in-progress": [], done: [] }; - for (const task of tasks ?? []) { - const column = columnOf(task); - (columns[column] ?? columns["not-started"]).push(task); - } - for (const list of Object.values(columns)) list.sort((a, b) => (b.updatedAt ?? 0) - (a.updatedAt ?? 0)); - return columns; -} -function columnTree(tasks) { - const ids = new Set((tasks ?? []).map((task) => task.id)); - const childrenOf = /* @__PURE__ */ new Map(); - const nested = /* @__PURE__ */ new Set(); - for (const task of tasks ?? []) { - if (!task.parentId || !ids.has(task.parentId)) continue; - const parent = tasks.find((item) => item.id === task.parentId); - if (parent?.kind !== "group") continue; - const list = childrenOf.get(parent.id) ?? []; - list.push(task); - childrenOf.set(parent.id, list); - nested.add(task.id); - } - const nests = []; - const loose = []; - for (const task of tasks ?? []) { - if (nested.has(task.id)) continue; - if (task.kind === "group") nests.push({ task, children: childrenOf.get(task.id) ?? [] }); - else loose.push(task); - } - return { nests, loose }; -} -function adminOf(roster, room2) { - const me = roster?.self?.memberId; - if (!me) return room2?.origin !== "face-to-face"; - const human = (room2?.participants ?? []).find((person) => person.kind === "human" && person.id === me && person.leftSeq === void 0); - if (human?.role === "admin") return true; - const admin = roster?.byId?.[room2?.admin]; - return Boolean(admin && !admin.remote && admin.ownerId === me); -} -function boardRail(roster) { - const me = roster?.self?.memberId; - const bots = (roster?.bots ?? []).filter((bot) => bot && !bot.remote && (!me || !bot.ownerId || bot.ownerId === me)); - const ids = new Set(bots.map((bot) => bot.id)); - const children = /* @__PURE__ */ new Map(); - for (const bot of bots) { - const parent = bot.createdBy && ids.has(bot.createdBy) ? bot.createdBy : ""; - const list = children.get(parent) ?? []; - list.push(bot); - children.set(parent, list); - } - for (const list of children.values()) list.sort((a, b) => (a.createdAt ?? 0) - (b.createdAt ?? 0) || String(a.name).localeCompare(String(b.name))); - const rows = []; - const walk = (parent, depth) => { - for (const bot of children.get(parent) ?? []) { - rows.push({ kind: "bot", id: bot.id, name: bot.name, depth, bot }); - walk(bot.id, depth + 1); - } - }; - walk("", 0); - const groups = (roster?.rooms ?? []).filter((room2) => room2 && room2.hidden !== true && room2.origin !== "face-to-face" && adminOf(roster, room2)).map((room2) => ({ kind: "group", id: room2.id, name: room2.displayName || room2.name || t("Group"), room: room2 })); - return { bots: rows, groups }; -} -function canMoveTask(roster, task) { - const me = roster?.self?.memberId; - if (!me) return true; - const bot = roster?.byId?.[task?.ownerBotId]; - return Boolean(bot && !bot.remote && (!bot.ownerId || bot.ownerId === me)); -} -var TERMINAL_STATUSES = /* @__PURE__ */ new Set(["accepted", "cancelled", "failed"]); -function finishedAt(task) { - const terminal = (task?.actors ?? []).find((entry) => TERMINAL_STATUSES.has(entry?.status)); - if (Number.isFinite(terminal?.at)) return terminal.at; - return Number.isFinite(task?.updatedAt) ? task.updatedAt : void 0; -} -function priorityLabel(priority) { - if (priority === "urgent") return t("Highest"); - if (priority === "high") return t("High"); - if (priority === "low") return t("Low"); - return t("Normal"); -} -function statusLabel(status) { - switch (status) { - case "claimed": - return t("Claimed"); - case "working": - return t("In progress"); - case "blocked": - return t("Blocked"); - case "result-ready": - return t("Result ready"); - case "accepted": - return t("Done"); - case "cancelled": - return t("Cancelled"); - case "failed": - return t("Failed"); - default: - return t("Not started"); - } -} -function dropLabel(column) { - if (column === "in-progress") return t("Start task"); - if (column === "done") return t("Complete task"); - return t("Not started"); -} -function columnTitle(column) { - if (column === "in-progress") return t("In progress"); - if (column === "done") return t("Done"); - return t("Not started"); -} -function botName(roster, task) { - return roster?.byId?.[task.ownerBotId]?.name || task.ownerBotName || ""; + scan(); + return () => { + cancelAnimationFrame(frame); + observer.disconnect(); + }; + }, []); } -function lanesOf(tasks, roster) { - const order = []; - const map = /* @__PURE__ */ new Map(); - for (const task of tasks) { - const id = task.ownerBotId ?? ""; - if (!map.has(id)) { - map.set(id, []); - order.push(id); +function ActivityIndicator({ sessionId, useRoster, useSessionStatus, useLive, useSession, actions }) { + const roster = useRoster((value) => value); + const running = useSessionStatus((map) => map.get(sessionId)?.running === true); + const liveKey = useLive((state) => JSON.stringify(state[sessionId] ?? {})); + const pendingInput = useSession((snapshot) => Math.max(0, ...(snapshot?.pendingSubmissions ?? []).filter((sub) => sub.placement !== "queued").map((sub) => sub.time))); + const bot = roster.byId[sessionId]; + const room2 = roster.roomsById[sessionId]; + const entries2 = (0, import_react11.useMemo)(() => Object.values(JSON.parse(liveKey)), [liveKey]); + const streaming = entries2.some((entry) => entry.text); + const tools = entries2.filter((entry) => !entry.text).sort((a, b) => a.at - b.at); + const relaying = tools.some((entry) => entry.relay); + const awaiting = useAwaitingReply(roster, sessionId); + const sendRun = (0, import_react11.useRef)(false); + if (!running) sendRun.current = false; + else if (pendingInput > 0) sendRun.current = true; + const replied = useReplyAfterSend(bot?.remote !== void 0, running && sendRun.current); + const visible = bot?.remote !== void 0 ? !streaming && (running ? sendRun.current : awaiting && !replied) : running && !streaming && !relaying && (bot !== void 0 || room2 !== void 0); + const lastRun = (0, import_react11.useRef)({ running, send: false }); + const bridging = bot?.remote !== void 0 && !streaming && lastRun.current.running && lastRun.current.send && !running; + lastRun.current = { running, send: sendRun.current }; + const steady = bridging; + const tool = tools.at(-1); + const inputRef = (0, import_react11.useRef)(0); + const inputAt = Math.max(inputRef.current, pendingInput); + inputRef.current = inputAt; + const phrase = visible ? remoteApprovalPhrase(bot?.remote !== void 0, { tool, inputAt }) : null; + const want = !visible ? null : phrase ? { ...phrase, label: t(phrase.label) } : inputAt > (tool?.at ?? 0) ? { key: `input:${inputAt}`, pool: PHRASES.thinking, state: "thinking" } : tool ? { key: tool.label ? `tool:${tool.label}` : "working", label: tool.label, pool: PHRASES.working, state: tool.state ?? "working" } : { key: "thinking", pool: PHRASES.thinking, state: "thinking" }; + const [shown, setShown] = (0, import_react11.useState)(null); + const wantKey = want?.key ?? null; + const markState = (0, import_react11.useRef)("thinking"); + if (want) markState.current = want.state; + (0, import_react11.useEffect)(() => { + if (wantKey === null || shown?.key === wantKey) return void 0; + const make = () => setShown({ key: wantKey, text: want.label ?? drawPhrase(want.pool), at: Date.now() }); + const wait = shown ? shown.at + PHRASE_HOLD_MS - Date.now() : 0; + if (wait <= 0) { + make(); + return void 0; } - map.get(id).push(task); - } - return order.map((id) => ({ id, name: botName(roster, map.get(id)[0]) || id, tasks: map.get(id) })); + const timer = setTimeout(make, wait); + return () => clearTimeout(timer); + }, [wantKey, shown]); + const [phase, setPhase] = (0, import_react11.useState)(null); + const endedAt2 = (0, import_react11.useRef)(0); + const shift = (0, import_react11.useRef)(null); + const leaving = (0, import_react11.useRef)(false); + leaving.current = phase !== null && !visible; + (0, import_react11.useEffect)(() => { + if (visible) { + setPhase("in"); + return void 0; + } + let timer; + const leave = () => { + setPhase((current) => current === "in" ? "out" : current); + timer = setTimeout(() => { + shift.current = document.querySelector("[data-conversation-scroll]")?.scrollTop ?? null; + setPhase((current) => current === "out" ? null : current); + }, 140); + }; + if (steady) timer = setTimeout(leave, REMOTE_GRACE_MS); + else leave(); + return () => clearTimeout(timer); + }, [visible]); + (0, import_react11.useLayoutEffect)(() => { + if (phase !== null) return; + endedAt2.current = Date.now(); + setShown(null); + const before = shift.current; + shift.current = null; + const scroller = document.querySelector("[data-conversation-scroll]"); + if (before === null || !scroller || reducedMotion()) return; + const delta = before - scroller.scrollTop; + if (delta > 0) scroller.querySelector("[data-chat-flow]")?.animate([{ transform: `translateY(${-delta}px)` }, { transform: "none" }], { duration: 140, easing: "cubic-bezier(.23,1,.32,1)" }); + }, [phase]); + (0, import_react11.useEffect)(() => { + if (!running) actions.clearLive(sessionId); + }, [running, sessionId]); + useRowEntrances(() => leaving.current || Date.now() - endedAt2.current < 300); + const [now, setNow] = (0, import_react11.useState)(Date.now); + (0, import_react11.useEffect)(() => { + if (phase !== "in") return void 0; + const timer = setInterval(() => setNow(Date.now()), 1e4); + return () => clearInterval(timer); + }, [phase]); + const remote = bot?.remote !== void 0; + const host = useRunningHost(running || remote && phase !== null, running || !remote ? RUNNING_HOST : TAIL_HOST); + const drawn = (0, import_react11.useRef)(false); + const carry = drawn.current; + (0, import_react11.useEffect)(() => { + drawn.current = Boolean(host && phase !== null && shown); + }, [host, phase, shown]); + if (!host || phase === null || !shown) return null; + const elapsed = now - shown.at >= 6e4 ? elapsedLabel(now - shown.at) : null; + return (0, import_react_dom2.createPortal)((0, import_react11.createElement)( + "div", + { className: "bt-typing", "data-exiting": phase === "out" ? "" : void 0, "data-carry": carry && phase !== "out" ? "" : void 0, "data-remote": remote ? "" : void 0, "data-flybot": shown.key === "remote-approval" ? "remote-approval" : void 0, "aria-hidden": true }, + bot ? (0, import_react11.createElement)(BotMark, { bot, size: 28, state: markState.current, live: true, pokeable: true }) : (0, import_react11.createElement)(RoomAvatar, { room: room2, roster, size: 28 }), + (0, import_react11.createElement)( + "span", + { className: "bt-typing-text" }, + (0, import_react11.createElement)( + "span", + { key: `${shown.key}:${shown.at}`, className: "bt-typing-label" }, + (0, import_react11.createElement)(Shimmer, null, `${shown.text}…`), + elapsed ? (0, import_react11.createElement)("span", { className: "bt-typing-time" }, `· ${elapsed}`) : null + ) + ) + ), host); } -var upsert = (list, task) => list.some((item) => item.id === task.id) ? list.map((item) => item.id === task.id ? task : item) : [...list, task]; -var without = (list, id) => list.filter((item) => item.id !== id); -function useBoardTasks(actions, scope) { - const [own, setOwn] = (0, import_react15.useState)([]); - const [groupTasks, setGroupTasks] = (0, import_react15.useState)([]); - const [archive, setArchive] = (0, import_react15.useState)([]); - const key = scope?.kind === "group" ? `group:${scope.roomId}` : `bot:${scope.botId ?? ""}`; - (0, import_react15.useEffect)(() => { - let stopped = false; - const load = () => { - const query = scope?.kind === "group" ? { roomId: scope.roomId } : {}; - const archiveQuery = scope?.kind === "bot" ? { botId: scope.botId } : query; - const job = scope?.kind === "group" ? Promise.resolve(actions?.tasks?.(query)) : Promise.resolve(actions?.tasks?.()); - void job.then((list) => { - if (stopped || !Array.isArray(list)) return; - if (scope?.kind === "group") setGroupTasks(list); - else setOwn(list); - }).catch(() => void 0); - void Promise.resolve(actions?.tasks?.({ ...archiveQuery, archive: true, limit: ARCHIVE_SHOWN })).then((list) => { - if (stopped || !Array.isArray(list)) return; - setArchive(list.filter((task) => columnOf(task) === "done")); - }).catch(() => void 0); + +// src/client/dsh-offer.js +var APP_OFFER = /* @__PURE__ */ new Set(["available", "downloading", "verifying", "installing", "ready"]); +var bare = (value) => String(value ?? "").trim().replace(/^v/i, "").split("+")[0]; +var sameVersion = (a, b) => bare(a) !== "" && bare(a) === bare(b); +function showsDshRow(dsh, app) { + if (!dsh?.verified || !dsh.current || sameVersion(dsh.verified, dsh.current)) return false; + if (dsh.kind === "npm") return true; + return dsh.kind === "app" && APP_OFFER.has(app?.phase) && sameVersion(app.version, dsh.verified); +} + +// src/client/overlay-hooks.js +var import_react12 = require("react"); +var escapeLayers = []; +var onEscapeKey = (event) => { + if (event.key === "Escape") escapeLayers.at(-1)?.current(); +}; +function useEscape(onClose) { + const ref = (0, import_react12.useRef)(onClose); + ref.current = onClose; + (0, import_react12.useEffect)(() => { + if (escapeLayers.length === 0) window.addEventListener("keydown", onEscapeKey); + escapeLayers.push(ref); + return () => { + escapeLayers.splice(escapeLayers.indexOf(ref), 1); + if (escapeLayers.length === 0) window.removeEventListener("keydown", onEscapeKey); }; - load(); - const timer = setInterval(load, POLL_MS3); + }, []); +} +function useDialogFocus(ref) { + (0, import_react12.useEffect)(() => { + const before = document.activeElement; + ref.current?.focus?.({ preventScroll: true }); return () => { - stopped = true; - clearInterval(timer); + if (before && typeof before.focus === "function" && before.isConnected) before.focus({ preventScroll: true }); }; - }, [key]); - const tasks = scope?.kind === "group" ? groupTasks : own.filter((task) => !scope?.botId || task.ownerBotId === scope.botId); - const replace = (task) => { - const done2 = columnOf(task) === "done"; - if (scope?.kind === "group") setGroupTasks((list) => done2 ? without(list, task.id) : upsert(list, task)); - else setOwn((list) => done2 ? without(list, task.id) : upsert(list, task)); - setArchive((list) => done2 ? [task, ...without(list, task.id)] : without(list, task.id)); - }; - const done = scope?.kind === "group" ? archive : archive.filter((task) => !scope?.botId || task.ownerBotId === scope.botId); - return { own, groupTasks, tasks, archive: done, replace }; + }, []); } -function RailButton({ row, selected, meta, onSelect }) { - return (0, import_react15.createElement)( - "button", - { - type: "button", - className: "bt-board-rail-item", - "data-kind": row.kind, - "data-id": row.id, - "data-selected": selected || void 0, - "aria-current": selected ? "true" : void 0, - style: row.depth ? { paddingLeft: `${10 + row.depth * 14}px` } : void 0, - onClick: () => onSelect(row.kind === "group" ? { kind: "group", roomId: row.id } : { kind: "bot", botId: row.id }) - }, - (0, import_react15.createElement)("span", { className: "bt-board-rail-name" }, row.name), - meta ? (0, import_react15.createElement)("span", { className: "bt-board-rail-meta" }, meta) : null - ); +function usePaneLeft() { + const [paneLeft, setPaneLeft] = (0, import_react12.useState)(280); + (0, import_react12.useLayoutEffect)(() => { + const column = document.querySelector('[class*="_footArea"]')?.parentElement; + if (column) setPaneLeft(Math.round(column.getBoundingClientRect().right)); + }, []); + return paneLeft; } -function BoardCard2({ task, roster, movable, onMove, onDrag, partOf: partOf2 }) { - const column = columnOf(task); - const priority = priorityOf(task); - const progress = progressOf(task); - const who = botName(roster, task); - const question = task.waitingFor === "input" ? task.inputRequest?.question : ""; - const overall = task.kind === "group"; - const finished = column === "done" ? finishedAt(task) : void 0; - return (0, import_react15.createElement)(BoardCard, { - card: taskCard(task), - skin: TASK_SKIN, - attrs: { - "data-task": task.id, - "data-column": column, - "data-priority": priority, - "data-kind": overall ? "group" : void 0 - }, - idText: shortId(task.id), - emptyTitle: t("Untitled task"), - topExtra: [ - (0, import_react15.createElement)("span", { key: "priority", className: "bt-board-priority", "data-priority": priority }, priorityLabel(priority)), - (0, import_react15.createElement)("span", { key: "status", className: "bt-board-status" }, statusLabel(task.status)) - ], - note: task.note ?? "", - meta: [ - overall ? (0, import_react15.createElement)("span", { key: "kind", className: "bt-board-kind" }, t("Group task")) : null, - partOf2 ? (0, import_react15.createElement)("span", { key: "part", className: "bt-board-part" }, t("Part of {title}", { title: partOf2 })) : null, - question ? (0, import_react15.createElement)("span", { key: "wait", className: "bt-board-wait" }, question) : null - ], - footer: (0, import_react15.createElement)( - "span", - { className: "bt-board-foot" }, - who ? (0, import_react15.createElement)("span", { className: "bt-board-who" }, who) : null, - Number.isFinite(finished) && finished > 0 ? (0, import_react15.createElement)("span", { key: "finished", className: "bt-board-when" }, relativeShort(finished)) : null, - (0, import_react15.createElement)( - "span", - { className: "bt-board-progress" }, - (0, import_react15.createElement)( - "span", - { className: "bt-board-progress-bar", role: "progressbar", "aria-valuemin": 0, "aria-valuemax": 100, "aria-valuenow": progress, "aria-label": t("Progress") }, - (0, import_react15.createElement)("span", { style: { width: `${progress}%` } }) - ), - (0, import_react15.createElement)("span", { className: "bt-board-percent" }, t("{percent}%", { percent: progress })) - ) - ), - actions: [ - movable && column === "not-started" ? (0, import_react15.createElement)("button", { key: "start", type: "button", className: "bt-soft bt-soft-sm", onClick: () => onMove?.(task.id, "in-progress") }, t("Start")) : null, - movable && column === "in-progress" && task.status !== "blocked" ? (0, import_react15.createElement)("button", { key: "complete", type: "button", className: "bt-soft bt-soft-sm", onClick: () => onMove?.(task.id, "done") }, t("Complete")) : null - ], - draggable: movable, - dragValue: task.id, - onDragStart: () => onDrag?.(task.id), - onDragEnd: () => onDrag?.(null) + +// src/client/member-status.js +var import_react13 = require("react"); +var NO_WORK = { bots: {}, rooms: {}, approvals: [] }; +var ROWS_MAX = 4; +var stepIn = (work, botId, roomId, tool) => { + const own = work.bots?.[botId]; + return own?.roomId === roomId && own.tool === tool ? own.step : void 0; +}; +var workIn = (room2, work, id) => { + const local = (work.rooms?.[room2.id] ?? []).find((entry) => entry.botId === id); + if (local) return { ...local, roomId: room2.id, step: stepIn(work, id, room2.id, local.tool) }; + const own = work.bots?.[id]; + return own !== void 0 && own.roomId === void 0 && own.fromRoomId === room2.id ? { ...own, roomId: room2.id, handed: true } : void 0; +}; +var askedIn = (room2, work, id, doing) => (work.approvals ?? []).filter((entry) => entry.botId === id && (entry.roomId === room2.id || entry.roomId === void 0 && doing?.handed === true)); +function memberWork(room2, work = NO_WORK) { + return room2.members.map((id) => { + const doing = workIn(room2, work, id); + const approvals = askedIn(room2, work, id, doing); + return { id, approvals, doing, here: doing !== void 0, state: approvals.length > 0 ? "ask" : doing ? "work" : "idle" }; + }); +} +function busyMembers(room2, work, roster) { + const busy = memberWork(room2, work).filter((entry) => entry.state !== "idle" && roster.byId[entry.id]); + return [...busy.filter((entry) => entry.state === "ask"), ...busy.filter((entry) => entry.state === "work")].map((entry) => ({ ...entry, bot: roster.byId[entry.id] })); +} +function splitBusy(busy) { + const here = busy.filter((entry) => entry.state === "work" && entry.here); + const rows = [...here.filter((entry) => entry.doing.speaking === true), ...here.filter((entry) => entry.doing.speaking !== true)].slice(0, ROWS_MAX); + return { rows, rest: busy.filter((entry) => !rows.includes(entry)) }; +} +function botGroupWork(botId, work, roster) { + return Object.entries(work.rooms ?? {}).flatMap(([roomId, entries2]) => { + const entry = entries2.find((item) => item.botId === botId); + const room2 = roster.roomsById[roomId]; + return entry && room2 ? [{ room: room2, tool: entry.tool, step: stepIn(work, botId, roomId, entry.tool), since: entry.since }] : []; }); } -function cardProps(task, roster, onMove, onDrag, partOf2, locked) { - return { task, roster, movable: !locked && canMoveTask(roster, task), onMove, onDrag, partOf: partOf2 }; +var moreRooms = (count) => count === 1 ? t("1 more room") : t("{count} more rooms", { count }); +function summaryText(busy) { + const asks = busy.filter((entry) => entry.state === "ask"); + const workers = busy.filter((entry) => entry.state === "work"); + const names = asks.slice(0, 2).map((entry) => entry.bot.name); + const waiting = asks.length === 0 ? "" : asks.length === 1 ? t("{name} is waiting for your approval", { name: names[0] }) : asks.length === 2 ? t("{first} and {second} are waiting for your approval", { first: names[0], second: names[1] }) : t("{first}, {second} and {count} more are waiting for your approval", { first: names[0], second: names[1], count: asks.length - 2, total: asks.length }); + const first = workers[0]; + const doing = first ? `${first.bot.name} · ${workText(first.doing.tool, first.doing.step)}${workers.length > 1 ? ` +${workers.length - 1}` : ""}` : ""; + return { waiting, doing }; } -function BoardColumn({ column, tasks, roster, lanes, dragging, onMove, onDrag, archiveFull, locked }) { - const tree = lanes ? columnTree(tasks) : null; - const linked = tree ? tree.loose.filter((task) => task.parentId || task.parentTitle) : []; - const plain = tree ? tree.loose.filter((task) => !task.parentId && !task.parentTitle) : tasks; - return (0, import_react15.createElement)( - "section", - { - className: "bt-board-column", - "data-column": column, - "aria-label": columnTitle(column), - onDragOver: (event) => { - if (locked || !dragging) return; - event.preventDefault(); - }, - onDrop: (event) => { - if (locked) return; - event.preventDefault(); - const id = event.dataTransfer?.getData("text/plain"); - if (id) onMove?.(id, column); - onDrag?.(null); - } - }, - (0, import_react15.createElement)( - "h3", - { className: "bt-board-column-head" }, - (0, import_react15.createElement)("span", null, columnTitle(column)), - // #2045: the Done column is one archive page. `tasks.length` is what was drawn, not what the - // owner has, so a full page says `20 newest` instead of claiming the page is the total. - (0, import_react15.createElement)("span", { className: "bt-board-count" }, column === "done" && archiveFull ? t("{count} newest", { count: tasks.length }) : String(tasks.length)) +function Face({ entry, size }) { + return (0, import_react13.createElement)( + "span", + { className: "bt-mw-face", "data-ask": entry.state === "ask" || void 0 }, + (0, import_react13.createElement)(BotMark, { bot: entry.bot, size, state: entry.state === "ask" ? "alert" : toolState(entry.doing?.tool ?? "") }) + ); +} +var elapsedSince = (now, at) => typeof at === "number" && now - at >= 6e4 ? elapsedLabel(now - at) : ""; +function WorkRow({ bot, tool, step, label, at, now, onOpen }) { + const text2 = `${label} · ${workText(tool, step)}`; + const elapsed = elapsedSince(now, at); + return (0, import_react13.createElement)( + "button", + { type: "button", className: "bt-mw-row", "aria-label": [text2, elapsed].filter(Boolean).join(" · "), onClick: onOpen }, + (0, import_react13.createElement)(BotMark, { bot, size: 22, state: toolState(tool ?? ""), live: true }), + (0, import_react13.createElement)("span", { className: "bt-mw-row-text" }, (0, import_react13.createElement)(Shimmer, null, text2)), + elapsed ? (0, import_react13.createElement)("span", { className: "bt-mw-row-time" }, `· ${elapsed}`) : null + ); +} +function SummaryLine({ busy, now, onOpen }) { + const { waiting, doing } = summaryText(busy); + const elapsed = doing ? elapsedSince(now, busy.find((entry) => entry.state === "work")?.doing.since) : ""; + return (0, import_react13.createElement)( + "button", + { type: "button", className: "bt-mw-row bt-mw-line", "aria-label": [waiting, doing, elapsed].filter(Boolean).join(" · "), onClick: onOpen }, + (0, import_react13.createElement)("span", { className: "bt-mw-stack", "aria-hidden": true }, busy.slice(0, 4).map((entry) => (0, import_react13.createElement)(Face, { key: entry.id, entry, size: 16 }))), + (0, import_react13.createElement)( + "span", + { className: "bt-mw-row-text" }, + waiting ? (0, import_react13.createElement)("span", { className: "bt-mw-ask" }, waiting) : null, + waiting && doing ? " · " : null, + doing ? (0, import_react13.createElement)(Shimmer, null, doing) : null ), - dragging ? (0, import_react15.createElement)("div", { className: "bt-board-drop" }, dropLabel(column)) : null, - tasks.length === 0 && !dragging ? (0, import_react15.createElement)("p", { className: "bt-board-empty" }, t("Nothing in this column.")) : null, - tree ? tree.nests.map((nest) => (0, import_react15.createElement)( - "div", - { key: nest.task.id, className: "bt-board-nest" }, - (0, import_react15.createElement)(BoardCard2, cardProps(nest.task, roster, onMove, onDrag, void 0, locked)), - nest.children.map((child) => (0, import_react15.createElement)( - "div", - { key: child.id, className: "bt-board-subtask", "data-parent": nest.task.id }, - (0, import_react15.createElement)(BoardCard2, cardProps(child, roster, onMove, onDrag, void 0, locked)) - )) - )) : null, - linked.map((task) => (0, import_react15.createElement)(BoardCard2, { key: task.id, ...cardProps(task, roster, onMove, onDrag, task.parentTitle || "", locked) })), - lanes ? lanesOf(plain, roster).map((lane) => (0, import_react15.createElement)( - "div", - { key: lane.id, className: "bt-board-lane" }, - (0, import_react15.createElement)("span", { className: "bt-board-lane-name" }, lane.name), - lane.tasks.map((task) => (0, import_react15.createElement)(BoardCard2, { key: task.id, ...cardProps(task, roster, onMove, onDrag, void 0, locked) })) - )) : plain.map((task) => (0, import_react15.createElement)(BoardCard2, { key: task.id, ...cardProps(task, roster, onMove, onDrag, void 0, locked) })) + elapsed ? (0, import_react13.createElement)("span", { className: "bt-mw-row-time" }, `· ${elapsed}`) : null ); } -function BoardView({ roster, tasks, rail, scope, counts: counts2, dragging, error, draft, onDraft, onSelect, onMove, onDrag, onCreate, archiveFull, locked }) { - const columns = boardColumns(tasks); - const lanes = scope?.kind === "group"; - const selectedBot = scope?.kind === "bot" ? scope.botId : ""; - const selectedRoom = scope?.kind === "group" ? scope.roomId : ""; - return (0, import_react15.createElement)( +function MemberDock({ sessionId, useRoster, useWork, actions }) { + const roster = useRoster((value) => value); + const work = useWork((value) => value); + const room2 = roster.roomsById[sessionId]; + const bot = room2 ? void 0 : roster.byId[sessionId]; + const ownChat = bot !== void 0 && work.bots?.[bot.id] !== void 0 && work.bots[bot.id].roomId === void 0; + const busy = room2 ? busyMembers(room2, work, roster) : []; + const groups = bot !== void 0 && !ownChat ? botGroupWork(bot.id, work, roster) : []; + const shown = busy.length > 0 || groups.length > 0; + const now = useNow(shown); + const ref = (0, import_react13.useRef)(null); + useFlowColumn(ref, shown, sessionId); + if (!shown) return null; + if (room2) { + const { rows: rows2, rest } = splitBusy(busy); + const open = () => actions.showDetails(sessionId); + return (0, import_react13.createElement)( + "div", + { ref, className: "bt-mw-dock-stack" }, + rows2.map((entry) => (0, import_react13.createElement)(WorkRow, { key: entry.id, bot: entry.bot, tool: entry.doing.tool, step: entry.doing.step, label: entry.bot.name, at: entry.doing.since, now, onOpen: open })), + rest.length > 0 ? (0, import_react13.createElement)(SummaryLine, { busy: rest, now, onOpen: open }) : null + ); + } + const rows = groups.slice(0, ROWS_MAX); + const more = groups.length - rows.length; + return (0, import_react13.createElement)( "div", - { className: "bt-board-body" }, - (0, import_react15.createElement)( - "aside", - { className: "bt-board-rail", "aria-label": t("Task board") }, - (0, import_react15.createElement)("h3", { className: "bt-board-rail-head" }, t("Your Bots")), - rail.bots.length === 0 ? (0, import_react15.createElement)("p", { className: "bt-board-empty" }, t("No tasks yet.")) : rail.bots.map((row) => { - const count = counts2?.[row.id] ?? { done: 0, total: 0 }; - const capped = archiveFull && (scope?.kind === "group" ? count.done > 0 : count.done >= ARCHIVE_SHOWN); - const meta = count.total === 0 ? "" : capped ? t("{count}+ done", { count: count.done }) : t("{done}/{total}", { done: count.done, total: count.total }); - return (0, import_react15.createElement)(RailButton, { - key: row.id, - row, - selected: row.id === selectedBot, - onSelect, - meta - }); - }), - rail.groups.length > 0 ? (0, import_react15.createElement)("h3", { className: "bt-board-rail-head" }, t("Groups you admin")) : null, - rail.groups.map((row) => (0, import_react15.createElement)(RailButton, { key: row.id, row, selected: row.id === selectedRoom, onSelect })) - ), - (0, import_react15.createElement)( + { ref, className: "bt-mw-dock-stack" }, + rows.map((item) => (0, import_react13.createElement)(WorkRow, { + key: item.room.id, + bot, + tool: item.tool, + step: item.step, + label: t("in {name}", { name: item.room.name }), + at: item.since, + now, + onOpen: () => actions.openSession(item.room.id) + })), + more > 0 ? (0, import_react13.createElement)( "div", - { className: "bt-board-main" }, - error ? (0, import_react15.createElement)("p", { className: "bt-board-error", role: "alert" }, error) : null, - lanes ? (0, import_react15.createElement)("p", { className: "bt-board-hint" }, t("Assign, claim, and progress live on this board: one overall task, a subtask per part, an assignee, a column, and progress. @ only wakes someone.")) : null, - scope?.kind === "bot" && onCreate && !locked ? (0, import_react15.createElement)( - "form", - { className: "bt-board-add", onSubmit: (event) => { - event.preventDefault(); - onCreate(draft); - } }, - (0, import_react15.createElement)("input", { "aria-label": t("Task title"), placeholder: t("New task"), value: draft ?? "", onChange: (event) => onDraft?.(event.target.value) }), - (0, import_react15.createElement)("button", { type: "submit", className: "bt-soft bt-soft-sm" }, t("Add task")) - ) : null, - (0, import_react15.createElement)( - "div", - { className: "bt-board-columns" }, - ["not-started", "in-progress", "done"].map((column) => (0, import_react15.createElement)(BoardColumn, { - key: column, - column, - tasks: columns[column], - roster, - lanes, - dragging, - onMove, - onDrag, - archiveFull, - locked - })) - ) - ) + { className: "bt-mw-row bt-mw-line bt-mw-static" }, + (0, import_react13.createElement)("span", { className: "bt-mw-row-text" }, moreRooms(more)) + ) : null ); } -function KanbanRow({ wide, useUi, actions }) { - const open = useUi ? useUi((value) => Boolean(value.board)) : false; - const label = t("Tasks"); - return (0, import_react15.createElement)( - "button", - { - type: "button", - className: "bt-row bt-board-row", - "aria-label": label, - title: wide ? void 0 : label, - "aria-haspopup": "dialog", - "aria-expanded": open, - onClick: () => actions.openBoard() - }, - (0, import_react15.createElement)("span", { className: "bt-board-mark" }, (0, import_react15.createElement)(BoardIcon)), - wide ? (0, import_react15.createElement)("span", { className: "bt-board-label" }, label) : null - ); +function useFlowColumn(ref, active, key) { + (0, import_react13.useLayoutEffect)(() => { + const node = ref.current; + const flow = document.querySelector("[data-chat-flow]"); + if (!active || !node || !flow) return void 0; + const place = () => { + node.style.marginInlineStart = "0px"; + const column = flow.getBoundingClientRect(); + node.style.marginInlineStart = `${Math.round(column.left - node.getBoundingClientRect().left)}px`; + node.style.width = `${Math.round(column.width)}px`; + }; + place(); + const observer = new ResizeObserver(place); + for (const target of [flow, flow.parentElement, node.parentElement]) if (target) observer.observe(target); + return () => observer.disconnect(); + }, [active, key]); } -function scopeName(roster, scope) { - if (scope?.kind === "group") return roster.roomsById?.[scope.roomId]?.name || t("Group"); - if (scope?.kind === "bot") return roster.byId?.[scope.botId]?.name || ""; - return ""; +function useNow(active) { + const [now, setNow] = (0, import_react13.useState)(Date.now); + (0, import_react13.useEffect)(() => { + if (!active) return void 0; + const timer = setInterval(() => setNow(Date.now()), 3e4); + return () => clearInterval(timer); + }, [active]); + return now; } -function KanbanDialog({ roster, actions, leaving = false }) { - const locked = typeof roster?.readOnly === "string" && roster.readOnly.trim() !== ""; - const rail = boardRail(roster); - const [scope, setScope] = (0, import_react15.useState)(() => rail.bots[0] ? { kind: "bot", botId: rail.bots[0].id } : rail.groups[0] ? { kind: "group", roomId: rail.groups[0].id } : null); - const [dragging, setDragging] = (0, import_react15.useState)(null); - const [error, setError] = (0, import_react15.useState)(""); - const [draft, setDraft] = (0, import_react15.useState)(""); - const { own, groupTasks, tasks, archive, replace } = useBoardTasks(actions, scope); - const boardTasks = boardTasksFor(tasks, archive); - const close = (0, import_react15.useCallback)(() => actions.closeBoard(), []); - useEscape(close); - (0, import_react15.useEffect)(() => { - if (typeof window === "undefined") return; - if (!window.location.hash.startsWith("#task=")) return; - const [kind, id] = window.location.hash.slice("#task=".length).split(":"); - window.location.hash = ""; - if (id === void 0 || id === "") return; - if (kind === "bot" && roster.byId?.[id]) setScope({ kind: "bot", botId: id }); - if (kind === "group" && roster.roomsById?.[id]) setScope({ kind: "group", roomId: id }); - }, []); - const archiveFull = Array.isArray(archive) && archive.length >= ARCHIVE_SHOWN; - const counts2 = {}; - for (const task of [...scope?.kind === "group" ? groupTasks : own, ...(archive ?? []).slice(0, ARCHIVE_SHOWN)]) { - const id = task.ownerBotId; - const bucket = counts2[id] ?? (counts2[id] = { done: 0, total: 0 }); - if (task.status === "cancelled" || task.status === "failed") continue; - bucket.total += 1; - if (columnOf(task) === "done") bucket.done += 1; - } - const move = async (id, column) => { - if (locked) return; - const task = tasks.find((item) => item.id === id) ?? own.find((item) => item.id === id); - if (!task || !canMoveTask(roster, task) || columnOf(task) === column) return; - setError(""); - try { - replace(await actions.taskUpdate({ id, column })); - } catch (failure) { - setError(failure?.message ?? String(failure)); - } +var since = (now, at) => typeof at === "number" && now - at >= 6e4 ? ` · ${elapsedLabel(now - at)}` : ""; +var alwaysChoices = (approval, bot) => bot?.remote ? [] : [ + ["always-bot", "Always allow for this Bot"], + ...approval.roomId ? [["always-room", "Always allow for this group"]] : [] +]; +function ApprovalCard({ approval, bot, actions }) { + const [busy, setBusy] = (0, import_react13.useState)(false); + const answer = (choice) => { + setBusy(true); + void Promise.resolve(actions.answerApproval(approval.id, choice)).catch((error) => actions.reportError(error, "approval")).finally(() => setBusy(false)); }; - const create = async (title) => { - if (locked) return; - const text2 = String(title ?? "").trim(); - if (!text2 || scope?.kind !== "bot") return; - setError(""); - try { - replace(await actions.taskCreate({ botId: scope.botId, title: text2, column: "not-started" })); - setDraft(""); - } catch (failure) { - setError(failure?.message ?? String(failure)); - } + return (0, import_react13.createElement)( + "div", + { className: "bt-mw-card", role: "group", "aria-label": t("{name} is waiting for your approval", { name: bot.name }) }, + (0, import_react13.createElement)("div", { className: "bt-mw-tool" }, approval.tool), + approval.detail?.text ? (0, import_react13.createElement)("div", { className: "bt-mw-text" }, approval.detail.text) : null, + approval.reason ? (0, import_react13.createElement)("div", { className: "bt-mw-reason" }, approval.reason) : null, + approval.detail?.code ? (0, import_react13.createElement)("code", { className: "bt-mw-code", title: approval.detail.code }, approval.detail.code) : null, + approval.answerable ? (0, import_react13.createElement)( + "div", + { className: "bt-mw-actions" }, + (0, import_react13.createElement)("button", { type: "button", className: "bt-q-btn", disabled: busy, onClick: () => answer("reject") }, t("Reject")), + (0, import_react13.createElement)("button", { type: "button", className: "bt-q-submit", disabled: busy, onClick: () => answer("allow") }, t("Allow once")), + ...alwaysChoices(approval, bot).map(([choice, label]) => (0, import_react13.createElement)("button", { key: choice, type: "button", className: "bt-q-btn", "data-choice": choice, disabled: busy, onClick: () => answer(choice) }, t(label))) + ) : (0, import_react13.createElement)( + "div", + { className: "bt-mw-actions" }, + (0, import_react13.createElement)("button", { type: "button", className: "bt-q-btn", onClick: () => actions.openSession(bot.id) }, t("Open {name}'s chat", { name: bot.name })) + ) + ); +} +function MemberWork({ entry, bot, room: room2, actions, now }) { + const [stopping, setStopping] = (0, import_react13.useState)(false); + if (entry.state === "ask") { + return (0, import_react13.createElement)( + "div", + { className: "bt-mw-member" }, + (0, import_react13.createElement)("div", { className: "bt-mw-state bt-mw-ask" }, `${t("Waiting for your approval")}${since(now, entry.approvals[0].at)}`), + entry.approvals.map((approval) => (0, import_react13.createElement)(ApprovalCard, { key: approval.id, approval, bot, actions })) + ); + } + if (entry.state !== "work") return null; + const stop = () => { + setStopping(true); + void Promise.resolve(actions.stopMemberWork(room2.id, bot.id)).catch((error) => actions.reportError(error, "stop")).finally(() => setStopping(false)); }; - const name = scopeName(roster, scope); - return (0, import_react15.createElement)( + return (0, import_react13.createElement)( "div", - { className: "bt-mem-layer bt-board-layer", "data-leaving": leaving || void 0 }, - (0, import_react15.createElement)("div", { className: "bt-scrim", onClick: close }), - (0, import_react15.createElement)( + { className: "bt-mw-member" }, + (0, import_react13.createElement)( "div", - { className: "bt-mem bt-board", role: "dialog", "aria-modal": true, "aria-labelledby": "bt-board-heading" }, - (0, import_react15.createElement)( - "div", - { className: "bt-mem-head" }, - (0, import_react15.createElement)("h2", { id: "bt-board-heading" }, t("Board")), - (0, import_react15.createElement)("span", { className: "bt-mem-status" }, name), - // #1854: the left sidebar's Projects row is the one entry to a Project board; this header - // used to draw a second door (#1701), so the sidebar row was the only way in for the rest. - (0, import_react15.createElement)("span", { className: "bt-mem-head-gap" }), - (0, import_react15.createElement)("button", { type: "button", className: "bt-icon-btn", "aria-label": t("Close board"), autoFocus: true, onClick: close }, (0, import_react15.createElement)(CloseIcon)) - ), - (0, import_react15.createElement)(BoardView, { - roster, - tasks: boardTasks, - rail, - scope, - counts: counts2, - dragging, - error, - draft, - archiveFull, - locked, - onDraft: setDraft, - onSelect: setScope, - onMove: move, - onDrag: setDragging, - onCreate: create - }) + { className: "bt-mw-state bt-mw-work" }, + (0, import_react13.createElement)("span", null, (0, import_react13.createElement)(Shimmer, null, workText(entry.doing.tool, entry.doing.step)), since(now, entry.doing.since)), + entry.here && entry.doing.handed !== true ? (0, import_react13.createElement)("button", { type: "button", className: "bt-mw-stop", disabled: stopping, onClick: stop }, t("Stop")) : null ) ); } -var KANBAN_CSS = ` -.bt-board-row{min-height:40px;padding:6px 8px;gap:10px} -.bt-board-mark{flex:none;width:36px;height:28px;display:inline-flex;align-items:center;justify-content:center;color:var(--bt-ink-2)} -.bt-board-mark svg{width:18px;height:18px} -.bt-board-label{flex:1 1 auto;min-width:0;font-size:14px;line-height:20px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis} -.bt-mem.bt-board{width:min(1080px,calc(100vw - 32px));height:min(760px,calc(100vh - 48px))} -.bt-board-body{flex:1 1 auto;min-height:0;display:flex;gap:0} -.bt-board-rail{flex:none;width:232px;overflow:auto;border-right:.5px solid var(--bt-line);padding:8px 8px 16px;display:flex;flex-direction:column;gap:2px} -.bt-board-rail-head{margin:10px 8px 4px;font-size:12px;line-height:16px;font-weight:600;color:var(--bt-ink-3)} -.bt-board-rail-item{display:flex;align-items:center;gap:8px;width:100%;min-height:36px;padding:6px 10px;border:0;border-radius:10px;background:transparent;color:var(--bt-ink);text-align:left;cursor:pointer} -.bt-board-rail-item:hover{background:var(--bt-hover)} -.bt-board-rail-item[data-selected]{background:var(--bt-active,var(--bt-hover))} -.bt-board-rail-name{flex:1 1 auto;min-width:0;font-size:13.5px;line-height:18px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis} -.bt-board-rail-meta{flex:none;font-size:12px;line-height:16px;color:var(--bt-ink-3);font-variant-numeric:tabular-nums} -.bt-board-main{flex:1 1 auto;min-width:0;display:flex;flex-direction:column;gap:10px;padding:12px 14px 16px} -.bt-board-add{display:flex;gap:8px} -.bt-board-add input{flex:1 1 auto;min-width:0;height:32px;padding:0 10px;border:.5px solid var(--bt-line-2);border-radius:10px;background:var(--bt-card,var(--bt-main));color:var(--bt-ink);font:inherit} -.bt-board-columns{flex:1 1 auto;min-height:0;display:flex;gap:10px} -.bt-board-column{flex:1 1 0;min-width:0;display:flex;flex-direction:column;gap:8px;padding:8px;border-radius:14px;background:var(--bt-hover);overflow:auto} -.bt-board-column-head{display:flex;align-items:center;gap:6px;margin:0;padding:2px 4px;font-size:13px;line-height:18px;font-weight:600;color:var(--bt-ink-2)} -.bt-board-count{font-weight:500;color:var(--bt-ink-3);font-variant-numeric:tabular-nums} -.bt-board-drop{margin:0;padding:18px 8px;border:1px dashed var(--bt-line-2);border-radius:12px;text-align:center;font-size:13px;line-height:18px;color:var(--bt-ink-3)} -.bt-board-empty{margin:0;padding:8px 4px;font-size:13px;line-height:18px;color:var(--bt-ink-3)} -.bt-board-hint{margin:0;font-size:13px;line-height:18px;color:var(--bt-ink-2)} -.bt-board-error{margin:0;font-size:13px;line-height:18px;color:#c21d2e} -.bt-board-lane{display:flex;flex-direction:column;gap:6px} -.bt-board-lane-name{font-size:12px;line-height:16px;font-weight:600;color:var(--bt-ink-3);padding:0 2px} -.bt-board-nest{display:flex;flex-direction:column;gap:6px} -.bt-board-subtask{margin-left:12px;padding-left:8px;border-left:2px solid var(--bt-line)} -.bt-board-kind{font-size:11px;line-height:16px;font-weight:600;color:var(--bt-ink-2)} -.bt-board-part{font-size:12px;line-height:16px;color:var(--bt-ink-3)} -.bt-board-wait{font-size:12px;line-height:16px;color:var(--bt-ink-2)} -.bt-board-card{display:flex;flex-direction:column;gap:6px;padding:10px;border:.5px solid var(--bt-line-2);border-radius:12px;background:var(--bt-card,var(--bt-main));cursor:grab} -.bt-board-card-top{display:flex;align-items:center;gap:6px;min-width:0} -.bt-board-id{flex:none;font-size:12px;line-height:16px;color:var(--bt-ink-3);font-variant-numeric:tabular-nums} -.bt-board-priority{flex:none;height:18px;padding:0 6px;border-radius:999px;font-size:11px;line-height:18px;font-weight:600} -.bt-board-priority[data-priority=urgent]{background:rgba(224,33,53,.12);color:#c21d2e} -.bt-board-priority[data-priority=high]{background:rgba(217,119,6,.14);color:#b45309} -.bt-board-priority[data-priority=normal]{background:color-mix(in srgb,var(--bt-accent) 14%,transparent);color:color-mix(in srgb,var(--bt-accent) 80%,var(--bt-ink))} -.bt-board-priority[data-priority=low]{background:color-mix(in srgb,var(--bt-green) 16%,transparent);color:color-mix(in srgb,var(--bt-green) 70%,var(--bt-ink))} -.bt-board-status{margin-left:auto;font-size:11.5px;line-height:16px;color:var(--bt-ink-3);white-space:nowrap} -.bt-board-title{font-size:13.5px;line-height:19px;color:var(--bt-ink)} -.bt-board-note{font-size:12px;line-height:16px;color:var(--bt-ink-2)} -.bt-board-foot{display:flex;align-items:center;gap:8px} -.bt-board-who{flex:none;max-width:40%;font-size:12px;line-height:16px;color:var(--bt-ink-2);white-space:nowrap;overflow:hidden;text-overflow:ellipsis} -.bt-board-when{flex:none;font-size:12px;line-height:16px;color:var(--bt-ink-3);white-space:nowrap} -.bt-board-progress{flex:1 1 auto;min-width:0;display:flex;align-items:center;gap:6px} -.bt-board-progress-bar{flex:1 1 auto;height:4px;border-radius:999px;background:var(--bt-line);overflow:hidden} -.bt-board-progress-bar>span{display:block;height:100%;background:var(--bt-accent)} -.bt-board-percent{flex:none;font-size:11.5px;line-height:16px;color:var(--bt-ink-3);font-variant-numeric:tabular-nums} -.bt-board-card .bt-soft{align-self:flex-start} -@media (max-width:800px){ - .bt-mem.bt-board{width:min(100vw,calc(100vw - 16px));height:100%} - .bt-board-body{flex-direction:column} - .bt-board-rail{width:auto;max-height:180px;border-right:0;border-bottom:.5px solid var(--bt-line)} - .bt-board-columns{overflow:auto} - .bt-board-column{min-width:220px} -} -@media (max-width:640px){ - .bt-mem.bt-board{width:100vw;border-radius:0;border:0} - .bt-board-columns{flex-direction:column} - .bt-board-column{min-width:0} +function useMemberWork(room2, useWork) { + const work = useWork?.((value) => value) ?? NO_WORK; + const entries2 = room2 ? memberWork(room2, work) : []; + const now = useNow(entries2.some((entry) => entry.state !== "idle")); + return { entries: entries2, now }; } +var MEMBER_CSS = ` +.bt-mw-dock-stack{box-sizing:border-box;display:flex;flex-direction:column;align-items:flex-start;gap:2px;width:var(--dsh-chat-content-width,min(960px,100%));margin:0 auto 6px;pointer-events:none} +.bt-mw-row{pointer-events:auto;display:flex;align-items:center;gap:10px;min-width:0;max-width:calc(100% + 6px);height:32px;margin-inline-start:-6px;padding:0 12px 0 6px;border:0;border-radius:16px;background:none;color:var(--bt-ink-2);font:inherit;font-size:14px;cursor:pointer;animation:bt-mw-in .32s cubic-bezier(.23,1,.32,1) backwards;transition:background-color .16s ease-out} +.bt-mw-row:hover{background:var(--bt-hover)} +.bt-mw-static{cursor:default;padding-inline-start:38px} +.bt-mw-static:hover{background:none} +.bt-mw-row>.bt-mark{flex:none} +.bt-mw-row-text{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} +.bt-mw-row-time{flex:none;font-size:12px;color:var(--bt-ink-3);white-space:nowrap} +.bt-mw-work{display:flex;align-items:center;justify-content:space-between;gap:8px} +.bt-mw-stop{flex:none;height:22px;padding:0 10px;border:1px solid var(--bt-line,rgba(0,0,0,.12));border-radius:11px;background:none;color:var(--bt-ink-2);font:inherit;font-size:12px;cursor:pointer} +.bt-mw-stop:hover{background:var(--bt-hover)} +.bt-mw-stop:disabled{opacity:.5;cursor:default} +.bt-mw-line{gap:8px} +.bt-mw-stack{display:inline-flex;align-items:center;flex:none;line-height:0} +.bt-mw-stack>*+*{margin-left:-4px} +.bt-mw-face{position:relative;display:inline-flex;flex:none;border-radius:50%} +.bt-mw-stack .bt-mw-face{box-shadow:0 0 0 1.5px var(--bt-main,#fff)} +.bt-mw-face[data-ask]::after{content:"";position:absolute;right:-2px;bottom:-2px;width:7px;height:7px;border-radius:50%;background:#e3a008;box-shadow:0 0 0 1.5px var(--bt-main,#fff)} +.bt-mw-ask{color:#b7791f;flex:none;white-space:nowrap} +body[data-ds-dark-theme] .bt-mw-ask{color:#f0b84a} +.bt-mw-member{padding:0 6px 8px 42px;display:flex;flex-direction:column;gap:6px} +.bt-mw-state{font-size:12px;line-height:16px;color:var(--bt-ink-2);margin-top:-4px} +.bt-mw-card{display:flex;flex-direction:column;gap:4px;padding:10px 12px;border-radius:12px;background:var(--bt-hover);font-size:12px;line-height:17px;color:var(--bt-ink-2)} +.bt-mw-tool{font-weight:600;color:var(--bt-ink)} +.bt-mw-text{color:var(--bt-ink);overflow-wrap:anywhere} +.bt-mw-reason{overflow-wrap:anywhere;display:-webkit-box;-webkit-line-clamp:3;-webkit-box-orient:vertical;overflow:hidden} +.bt-mw-code{display:block;font:11px/16px ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;color:var(--bt-ink);white-space:pre-wrap;overflow-wrap:anywhere;max-height:120px;overflow:auto} +.bt-mw-actions{display:flex;flex-wrap:wrap;justify-content:flex-end;gap:6px;margin-top:4px} +.bt-mw-actions>button{height:28px;padding:0 12px;font-size:12px} +.bt-mw-actions>button:disabled{opacity:.5;cursor:default} +@keyframes bt-mw-in{from{opacity:0;transform:translateY(4px)}} +@media (prefers-reduced-motion:reduce){.bt-mw-row{animation:none}} `; -// src/client/role-labels.js -var ROLE_LABELS = { owner: "Owner", maintainer: "Maintainer", member: "Member" }; -function roleText(role) { - if (role === null || role === void 0 || role === "") return ""; - return t(ROLE_LABELS[role] ?? String(role)); +// src/shared/task-board.js +var COLUMNS = ["not-started", "in-progress", "done"]; +var PRIORITIES = ["urgent", "high", "normal", "low"]; +var COLUMN_OF = { + open: "not-started", + claimed: "not-started", + working: "in-progress", + blocked: "in-progress", + "result-ready": "in-progress", + accepted: "done", + cancelled: "done", + failed: "done" +}; +function columnOf(task) { + return COLUMN_OF[task?.status] ?? "not-started"; } - -// src/client/projects-nav.js -var import_react16 = require("react"); -var import_react_dom3 = require("react-dom"); -var PROJECTS_NAV_CSS = ` -.bt-projects-row { display: flex; align-items: center; gap: 8px; } -.bt-projects-chevron { margin-left: auto; display: inline-flex; transition: transform 120ms ease; } -.bt-projects-row[aria-expanded="true"] .bt-projects-chevron { transform: rotate(90deg); } -.bt-projects-list { display: flex; flex-direction: column; margin: 0 0 4px; padding-left: 30px; } -.bt-project-entry { display: flex; align-items: baseline; gap: 8px; width: 100%; text-align: left; border: 0; background: none; color: inherit; font: inherit; padding: 4px 8px; border-radius: 6px; cursor: pointer; } -.bt-project-entry:hover, .bt-project-entry:focus-visible { background: var(--bt-hover, rgba(127,127,127,.14)); outline: none; } -.bt-project-entry[aria-current="true"] { background: var(--bt-hover, rgba(127,127,127,.14)); } -.bt-project-entry-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } -.bt-project-entry-role { margin-left: auto; opacity: .62; font-size: .85em; white-space: nowrap; } -.bt-projects-empty, .bt-projects-note { padding: 2px 8px 6px; opacity: .7; font-size: .85em; } -.bt-project-menu { position: fixed; z-index: 60; min-width: 168px; padding: 4px; border-radius: 8px; background: var(--bt-menu-bg, #1f2126); border: 1px solid var(--bt-menu-border, rgba(255,255,255,.12)); box-shadow: 0 8px 24px rgba(0,0,0,.32); } -.bt-project-menu-item { display: flex; align-items: center; gap: 8px; width: 100%; border: 0; background: none; color: inherit; font: inherit; text-align: left; padding: 6px 8px; border-radius: 6px; cursor: pointer; } -.bt-project-menu-item:hover, .bt-project-menu-item:focus-visible { background: var(--bt-hover, rgba(127,127,127,.14)); outline: none; } -.bt-project-menu-item[aria-checked="true"]::before { content: "✓"; margin-right: 2px; } -.bt-project-menu-item:not([aria-checked="true"])::before { content: " "; margin-right: 2px; } -.bt-project-menu-item[disabled] { opacity: .6; cursor: default; } -`; -function projectHash(id) { - return `#project=${encodeURIComponent(String(id ?? ""))}`; +function occupiesSlot(task) { + return task?.status === "claimed" || task?.status === "working"; } -function projectName(project) { - const name = typeof project?.name === "string" ? project.name.trim() : ""; - return name === "" ? `#${project?.id ?? ""}` : name; +function priorityOf(task) { + return PRIORITIES.includes(task?.priority) ? task.priority : "normal"; +} +var PRIORITY_RANK = { urgent: 0, high: 1, normal: 2, low: 3 }; +function workBucket(task) { + if (task?.status === "open" || task?.status === "claimed" || task?.status === "working") return 0; + if (task?.status === "blocked" || task?.status === "result-ready") return 1; + return 2; +} +function compareForWork(a, b) { + const byBucket = workBucket(a) - workBucket(b); + if (byBucket !== 0) return byBucket; + const byPriority = (PRIORITY_RANK[priorityOf(a)] ?? 2) - (PRIORITY_RANK[priorityOf(b)] ?? 2); + if (byPriority !== 0) return byPriority; + const byWorking = (a?.status === "working" ? 0 : 1) - (b?.status === "working" ? 0 : 1); + if (byWorking !== 0) return byWorking; + return (b?.updatedAt ?? 0) - (a?.updatedAt ?? 0); +} +function sameRole(a, b) { + const left = String(a ?? "").trim().toLowerCase(); + const right = String(b ?? "").trim().toLowerCase(); + return left !== "" && left === right; +} +function compareForQueue(a, b) { + const byPriority = (PRIORITY_RANK[priorityOf(a)] ?? 2) - (PRIORITY_RANK[priorityOf(b)] ?? 2); + if (byPriority !== 0) return byPriority; + return (a?.createdAt ?? 0) - (b?.createdAt ?? 0); +} +function progressOf(task) { + const value = task?.progress; + if (typeof value !== "number" || !Number.isFinite(value)) return columnOf(task) === "done" && task?.status !== "cancelled" && task?.status !== "failed" ? 100 : 0; + return Math.max(0, Math.min(100, Math.round(value))); +} + +// src/client/inbox.js +var import_react14 = require("react"); +var KEEP_MS = 7 * 864e5; +var POLL_MS3 = 3e4; +var DONE_MAX = 20; +var FEEDBACK_MS = 5e3; +var line = (d) => (0, import_react14.createElement)("path", { d }); +var icon = (...paths) => function StatusIcon() { + return (0, import_react14.createElement)("svg", { width: 14, height: 14, viewBox: "0 0 20 20", fill: "none", stroke: "currentColor", strokeWidth: 1.8, strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": true }, paths.map(line)); +}; +var RING = "M10 3.25a6.75 6.75 0 1 1 0 13.5 6.75 6.75 0 0 1 0-13.5z"; +var InboxIcon = icon("M3.5 11.5l1.9-6.1A1.5 1.5 0 0 1 6.83 4.35h6.34a1.5 1.5 0 0 1 1.43 1.05l1.9 6.1", "M3.5 11.5v3.25a1.5 1.5 0 0 0 1.5 1.5h10a1.5 1.5 0 0 0 1.5-1.5V11.5", "M3.5 11.5h3.75l1 1.75h3.5l1-1.75h3.75"); +var AskIcon = icon(RING, "M10 6.75v3.75", "M10 13.25h.01"); +var HeldIcon = icon(RING, "M10 6.5V10l2.25 1.5"); +var QuestionIcon = icon(RING, "M8.1 8a2 2 0 1 1 2.65 1.9c-.45.18-.75.6-.75 1.1v.25", "M10 13.5h.01"); +var ResultIcon = icon(RING, "M7 10.25l2 2 4-4.25"); +var FailedIcon = icon(RING, "M7.75 7.75l4.5 4.5M12.25 7.75l-4.5 4.5"); +var RunIcon = icon(RING, "M8.5 7.25v5.5l4.25-2.75z"); +var DoneIcon = icon("M4.5 10.5l3.5 3.5 7.5-8"); +var CancelledIcon = icon(RING, "M5.25 14.75l9.5-9.5"); +var STATUSES = { + approval: { label: "Needs approval", tone: "ask", Icon: AskIcon }, + held: { label: "Waiting to send", tone: "ask", Icon: HeldIcon }, + input: { label: "Needs your answer", tone: "ask", Icon: QuestionIcon }, + result: { label: "Result ready", tone: "ask", Icon: ResultIcon }, + failed: { label: "Failed", tone: "danger", Icon: FailedIcon }, + running: { label: "Working", tone: "run", Icon: RunIcon }, + task: { label: "In progress", tone: "run", Icon: RunIcon }, + done: { label: "Done", tone: "done", Icon: DoneIcon }, + cancelled: { label: "Cancelled", tone: "muted", Icon: CancelledIcon } +}; +var GROUPS = [ + { id: "needs", title: "Needs you", empty: "Nothing needs you right now." }, + { id: "progress", title: "In progress", empty: "Nothing is running." }, + { id: "done", title: "Done", empty: "Nothing finished in the last 7 days." } +]; +var sharedTasks = []; +var taskListeners = /* @__PURE__ */ new Set(); +function publishTasks(list) { + sharedTasks = Array.isArray(list) ? list : []; + for (const listener of taskListeners) listener(sharedTasks); } -function projectMenuItems(project) { - const current = project?.role ?? null; - const choices = Array.isArray(project?.choices) ? project.choices : []; - const items = current === null ? [] : [{ role: current, label: roleText(current), current: true, pick: false }]; - for (const role of choices) { - if (role === current) continue; - items.push({ role, label: roleText(role), current: false, pick: true }); - } - return items; +function useTasks(actions) { + const [tasks, setTasks] = (0, import_react14.useState)(sharedTasks); + (0, import_react14.useEffect)(() => { + taskListeners.add(setTasks); + let stopped = false; + let timer; + const load = () => Promise.resolve(actions?.tasks?.({ archive: true })).then((list) => { + if (!stopped && list !== void 0) publishTasks(list); + }).catch(() => { + }).finally(() => { + if (!stopped) timer = setTimeout(load, POLL_MS3); + }); + load(); + return () => { + stopped = true; + clearTimeout(timer); + taskListeners.delete(setTasks); + }; + }, []); + return tasks; } -function roleNote(answer) { - if (answer?.changed === true) return t("Role changed."); - const reason = typeof answer?.reason === "string" && answer.reason !== "" ? answer.reason : t("refused"); - return t("Role unchanged: {reason}", { reason }); +var workflowsOf = (settled) => settled?.phase === "ready" && Array.isArray(settled.pack?.workflows) ? settled.pack.workflows : []; +var sharedWorkflows = []; +var workflowListeners = /* @__PURE__ */ new Set(); +var workflowPoll = null; +function publishWorkflows(settled) { + sharedWorkflows = workflowsOf(settled); + for (const listener of workflowListeners) listener(sharedWorkflows); } -function ProjectList({ projects, error, note, onOpen, onRole }) { - const rows = projects === null ? (0, import_react16.createElement)("div", { className: "bt-projects-note" }, t("Reading Projects…")) : error !== "" ? (0, import_react16.createElement)("div", { className: "bt-projects-note" }, error) : projects.length === 0 ? (0, import_react16.createElement)("div", { className: "bt-projects-empty" }, t("No Projects yet")) : projects.map((project) => (0, import_react16.createElement)( - "button", - { - key: String(project.id), - type: "button", - className: "bt-project-entry", - "data-project-id": String(project.id), - "aria-current": project.current === true ? "true" : void 0, - title: roleText(project.role) === "" ? projectName(project) : `${projectName(project)} · ${roleText(project.role)}`, - onClick: () => onOpen?.(project.id), - onContextMenu: (event) => { - event.preventDefault(); - onRole?.(project, event); +function useWorkflows(actions) { + const [workflows, setWorkflows] = (0, import_react14.useState)(sharedWorkflows); + (0, import_react14.useEffect)(() => { + workflowListeners.add(setWorkflows); + if (workflowPoll === null && typeof actions?.workflowState === "function") { + const load = () => Promise.resolve(actions.workflowState()).then(publishWorkflows, () => { + }); + load(); + workflowPoll = setInterval(load, POLL_MS3); + } + return () => { + workflowListeners.delete(setWorkflows); + if (workflowListeners.size === 0 && workflowPoll !== null) { + clearInterval(workflowPoll); + workflowPoll = null; } - }, - (0, import_react16.createElement)("span", { className: "bt-project-entry-name" }, projectName(project)), - roleText(project.role) === "" ? null : (0, import_react16.createElement)("span", { className: "bt-project-entry-role" }, roleText(project.role)) - )); - return (0, import_react16.createElement)( - "div", - { className: "bt-projects-list", role: "group", "aria-label": t("Projects") }, - rows, - note === "" || note === void 0 ? null : (0, import_react16.createElement)("div", { className: "bt-projects-note", role: "status" }, note) - ); + }; + }, []); + return workflows; } -function Chevron() { - return (0, import_react16.createElement)( - "svg", - { width: 12, height: 12, viewBox: "0 0 20 20", fill: "none", stroke: "currentColor", strokeWidth: 2, strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": true }, - (0, import_react16.createElement)("path", { d: "M7.5 4.5L13 10l-5.5 5.5" }) - ); +var ownBot = (roster, id) => { + const bot = roster.byId?.[id]; + if (bot === void 0 || bot.remote) return void 0; + const me = roster.self?.memberId; + return me && bot.ownerId && bot.ownerId !== me ? void 0 : bot; +}; +var people = (roster, botId) => ({ assigner: roster.self?.memberId ?? "me", owner: roster.self?.memberId ?? "me", executingBot: botId ?? null }); +var roomName = (roster, id) => roster.roomsById?.[id]?.name; +var placeOf = (roster, roomId) => roomId && roomName(roster, roomId) ? t("in {name}", { name: roomName(roster, roomId) }) : ""; +var lastWhy = (task) => [...task.actors ?? []].reverse().find((entry) => typeof entry.why === "string" && entry.why !== "")?.why ?? ""; +var chatOfTask = (task) => task.origin?.roomId ?? task.origin?.sessionId ?? task.ownerBotId; +var ownAskChat = (roster, bot, text2) => { + if (bot === void 0 || typeof text2 !== "string" || text2 === "") return void 0; + for (const [key, question] of Object.entries(roster.questions ?? {})) { + if (question == null || typeof question !== "object" || roster.roomsById?.[key]) continue; + if (question.question !== text2) continue; + if (key === bot.id || key === currentPart(bot)) return bot.id; + } + return void 0; +}; +var placeKey = (botId, roomId) => `${botId}|${roomId ?? ""}`; +var partOf = (roster, chat) => roster.roomsById?.[chat] || !roster.byId?.[chat] ? chat : currentPart(roster.byId[chat]); +var runningIn = (work, botId, roomId) => roomId === void 0 ? work.bots?.[botId]?.roomId === void 0 ? work.bots?.[botId] : void 0 : (work.rooms?.[roomId] ?? []).find((entry) => entry.botId === botId) ?? (work.bots?.[botId]?.roomId === roomId ? work.bots[botId] : void 0); +function latestActivity(card, { roster, work, sessions }) { + const options = []; + if (card.kind === "task") options.push(lastWhy(card.task)); + if (card.kind === "run" || card.kind === "task") { + const entry = card.executingBot ? runningIn(work, card.executingBot, roster.roomsById?.[card.chat] ? card.chat : void 0) : void 0; + if (entry) options.push(`${workPhrase(entry.tool)}…`); + } + if (sessions?.byId && card.chat) options.push(previewOf(sessions, partOf(roster, card.chat), [], [], botNames(roster))); + return options.find((text2) => typeof text2 === "string" && text2 !== "" && text2 !== card.title && text2 !== card.reason) ?? ""; } -function ProjectsMark() { - return (0, import_react16.createElement)( - "svg", - { width: 18, height: 18, viewBox: "0 0 20 20", fill: "none", stroke: "currentColor", strokeWidth: 1.6, strokeLinejoin: "round", "aria-hidden": true }, - (0, import_react16.createElement)("path", { d: "M2.5 5.5h6l1.5 2h7.5v8a1 1 0 0 1-1 1h-13a1 1 0 0 1-1-1z" }), - (0, import_react16.createElement)("path", { d: "M2.5 5.5v-1a1 1 0 0 1 1-1h4l1.5 2" }) - ); +function toolCalls(card, { roster, work }) { + const botId = card.bot?.id; + if (botId === void 0) return 0; + const roomId = roster.roomsById?.[card.chat] ? card.chat : void 0; + const running = runningIn(work, botId, roomId); + if (running !== void 0) return running.calls ?? 0; + let newest; + for (const entry of work.ended ?? []) { + if (entry.botId !== botId || (entry.roomId ?? "") !== (roomId ?? "")) continue; + if ((entry.at ?? 0) >= (newest?.at ?? -Infinity)) newest = entry; + } + return newest?.calls ?? 0; } -function ProjectRoleMenu({ project, x, y, onPick, onClose }) { - const ref = (0, import_react16.useRef)(null); - (0, import_react16.useEffect)(() => { - const onDown = (event) => { - if (!ref.current?.contains(event.target)) onClose(); - }; - const onKey = (event) => { - if (event.key === "Escape") onClose(); - }; - window.addEventListener("mousedown", onDown, true); - window.addEventListener("keydown", onKey, true); - return () => { - window.removeEventListener("mousedown", onDown, true); - window.removeEventListener("keydown", onKey, true); +var endedAt = (run) => [run?.finishedAt, run?.updatedAt, run?.at].find((at) => typeof at === "number"); +function inboxModel({ roster, work = {}, tasks = [], sessions, workflows = [], now = Date.now() }) { + const needs = []; + const progress = []; + const done = []; + const fresh = (at) => typeof at === "number" && now - at <= KEEP_MS; + const waitingAt = /* @__PURE__ */ new Set(); + for (const approval of work.approvals ?? []) { + const bot = ownBot(roster, approval.botId); + if (bot === void 0) continue; + waitingAt.add(`${approval.botId}|${approval.roomId ?? ""}`); + needs.push({ + id: `approval:${approval.id}`, + kind: "approval", + status: "approval", + bot, + at: approval.at, + title: [approval.tool, approval.detail?.text].filter(Boolean).join(" · ") || t("A tool call"), + place: placeOf(roster, approval.roomId), + reason: approval.reason ?? "", + chat: approval.roomId ?? bot.id, + approval, + ...people(roster, bot.id) + }); + } + for (const [key, question] of Object.entries(roster.questions ?? {})) { + if (question == null || typeof question !== "object") continue; + const room2 = roster.roomsById?.[key]; + const bot = room2 ? ownBot(roster, question.botId) : (roster.bots ?? []).find((candidate) => ownBot(roster, candidate.id) && (candidate.id === key || currentPart(candidate) === key)); + if (bot === void 0) continue; + waitingAt.add(room2 ? `${bot.id}|${room2.id}` : `${bot.id}|`); + needs.push({ + id: `question:${question.id ?? key}`, + kind: "question", + status: "input", + bot, + ...room2 ? { room: room2 } : {}, + at: question.askedAt, + title: question.question ?? "", + reason: question.detail ?? "", + place: room2 ? placeOf(roster, key) : "", + chat: room2 ? key : bot.id, + sessionId: key, + question, + ...people(roster, bot.id) + }); + } + const held = (chat, count, extra) => needs.push({ + id: `held:${chat}`, + kind: "held", + status: "held", + chat, + at: void 0, + title: count === 1 ? t("1 message waits for your approval") : t("{count} messages wait for your approval", { count }), + hint: t("Approve it in the chat"), + ...extra + }); + for (const bot of roster.bots ?? []) { + const count = bot.pendingCount ?? 0; + if (count <= 0) continue; + if (bot.remote) held(bot.id, count, { bot, place: "", ...people(roster, null) }); + else if (ownBot(roster, bot.id)) held(bot.id, count, { bot, place: "", ...people(roster, bot.id) }); + } + for (const room2 of roster.rooms ?? []) { + const count = room2.pendingCount ?? 0; + if (count > 0) held(room2.id, count, { room: room2, place: placeOf(roster, room2.id), ...people(roster, null) }); + } + const seen = /* @__PURE__ */ new Set(); + const running = (botId, entry, roomId) => { + const bot = ownBot(roster, botId); + const key = `${botId}|${roomId ?? ""}`; + if (bot === void 0 || seen.has(key) || waitingAt.has(key)) return; + seen.add(key); + progress.push({ + id: `run:${key}`, + kind: "run", + status: "running", + bot, + at: entry.since, + since: entry.since, + title: `${workPhrase(entry.tool)}…`, + place: placeOf(roster, roomId), + chat: roomId ?? bot.id, + ...people(roster, bot.id) + }); + }; + for (const [botId, entry] of Object.entries(work.bots ?? {})) running(botId, entry, entry.roomId); + for (const [roomId, entries2] of Object.entries(work.rooms ?? {})) for (const entry of entries2) running(entry.botId, entry, roomId); + const lastEnded = /* @__PURE__ */ new Map(); + for (const entry of work.ended ?? []) { + const key = placeKey(entry.botId, entry.roomId); + if ((entry.at ?? 0) >= (lastEnded.get(key)?.at ?? -Infinity)) lastEnded.set(key, entry); + } + 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({ + id: `turn:${entry.id}`, + kind: "turn", + status: "failed", + bot, + at: entry.at, + title: asked || t("The last turn did not finish"), + reason: entry.failed, + place: placeOf(roster, entry.roomId), + chat, + ...people(roster, bot.id) + }); + } + for (const task of tasks) { + const bot = ownBot(roster, task.ownerBotId); + if (bot === void 0) continue; + const askedIn2 = task.waitingFor === "input" ? ownAskChat(roster, bot, task.inputRequest?.question) : void 0; + const card = { + id: `task:${task.id}`, + kind: "task", + bot, + at: task.updatedAt, + title: task.title || t("Untitled task"), + place: placeOf(roster, task.origin?.roomId), + chat: askedIn2 ?? chatOfTask(task), + task, + ...people(roster, task.workBotId ?? bot.id) }; - }, [onClose]); - const items = projectMenuItems(project); - return (0, import_react_dom3.createPortal)((0, import_react16.createElement)( - "div", - { - ref, - className: "bt-project-menu", - role: "menu", - "aria-label": t("My role"), - style: { left: `${x}px`, top: `${y}px` }, - "data-project-id": String(project?.id ?? "") - }, - (0, import_react16.createElement)("div", { className: "bt-projects-note" }, projectName(project)), - items.length === 0 ? (0, import_react16.createElement)("div", { className: "bt-projects-note" }, t("No role to switch here.")) : items.map((item) => (0, import_react16.createElement)("button", { - key: item.role, - type: "button", - role: "menuitemradio", - className: "bt-project-menu-item", - "aria-checked": item.current ? "true" : "false", - disabled: !item.pick, - onClick: () => { - if (item.pick) onPick(item.role); - else onClose(); - } - }, item.label)) - ), document.body); -} -function ProjectsNav({ wide = true, request, onOpen } = {}) { - const read = request ?? call; - const open = (0, import_react16.useCallback)((id) => { - if (onOpen) { - onOpen(id); - return; - } - if (typeof window !== "undefined") window.location.hash = projectHash(id); - }, [onOpen]); - const [expanded, setExpanded] = (0, import_react16.useState)(false); - const [projects, setProjects] = (0, import_react16.useState)(null); - const [error, setError] = (0, import_react16.useState)(""); - const [note, setNote] = (0, import_react16.useState)(""); - const [menu, setMenu] = (0, import_react16.useState)(null); - const load = (0, import_react16.useCallback)(async () => { - try { - const listing = await read("projects"); - const list = Array.isArray(listing?.projects) ? listing.projects : []; - setProjects(list); - setError(""); - } catch (failure) { - setProjects([]); - setError(failure?.message ?? String(failure)); + if (task.status === "failed") { + if (fresh(task.updatedAt)) needs.push({ ...card, status: "failed", reason: lastWhy(task) }); + } else if (task.status === "result-ready") needs.push({ ...card, status: "result", reason: task.result?.summary ?? "" }); + else if (task.status === "blocked") needs.push({ ...card, status: task.waitingFor === "input" ? "input" : "approval", reason: task.inputRequest?.question ?? "" }); + else { + const column = columnOf(task); + if (column === "in-progress") progress.push({ ...card, status: "task", since: startedAtOf(task) }); + else if (column === "done" && fresh(task.updatedAt)) done.push({ ...card, status: task.status === "cancelled" ? "cancelled" : "done" }); } - }, [read]); - (0, import_react16.useEffect)(() => { - if (!expanded) return; - void load(); - }, [expanded, load]); - const toggle = () => { - setNote(""); - if (!wide) { - void open(""); - return; + } + for (const graph of Array.isArray(workflows) ? workflows : []) { + if (graph === null || typeof graph !== "object") continue; + const seenRuns = /* @__PURE__ */ new Set(); + let latest; + for (const run of [...graph.runs ?? [], ...graph.history ?? []]) { + if (run === null || typeof run !== "object" || run.status !== "failed" || seenRuns.has(run.id)) continue; + seenRuns.add(run.id); + const at2 = endedAt(run); + if (!fresh(at2)) continue; + if (latest === void 0 || at2 > endedAt(latest)) latest = run; } - setExpanded((value) => !value); - }; - const listed = expanded && wide; - return (0, import_react16.createElement)( - "div", - { className: "bt-projects" }, - (0, import_react16.createElement)( - "button", - { - type: "button", - className: "bt-row bt-projects-row", - "aria-expanded": listed ? "true" : "false", - "aria-label": t("Projects"), - title: wide ? void 0 : t("Projects"), - onClick: toggle - }, - (0, import_react16.createElement)("span", { className: "bt-projects-mark" }, (0, import_react16.createElement)(ProjectsMark)), - wide ? (0, import_react16.createElement)("span", { className: "bt-projects-label" }, t("Projects")) : null, - wide ? (0, import_react16.createElement)("span", { className: "bt-projects-chevron", "aria-hidden": true }, (0, import_react16.createElement)(Chevron)) : null - ), - listed ? (0, import_react16.createElement)(ProjectList, { projects, error, note, onOpen: open, onRole: (project, event) => setMenu({ project, x: event.clientX, y: event.clientY }) }) : null, - menu === null ? null : (0, import_react16.createElement)(ProjectRoleMenu, { - project: menu.project, - x: menu.x, - y: menu.y, - onClose: () => setMenu(null), - onPick: async (role) => { - const project = menu.project; - setMenu(null); - try { - const answer = await read("project-role", { id: project.id, role }); - setNote(roleNote(answer)); - if (answer?.changed === true) void load(); - } catch (failure) { - setNote(failure?.message ?? String(failure)); - } - } - }) - ); -} - -// src/client/sidebar.js -var import_react17 = require("react"); -var import_react_dom4 = require("react-dom"); -var import_dsh_client_ui_primitives2 = require("@deepseek-ai/dsh-client-ui-primitives"); -function useMarkedUnread(id, active, marked, actions) { - (0, import_react17.useEffect)(() => { - if (active && marked) actions.clearUnread(id); - }, [active, marked]); -} -var PinMark = () => (0, import_react17.createElement)("span", { className: "bt-pin", "aria-hidden": true }, (0, import_react17.createElement)(PinMarkIcon)); -var useApproval = (useWork, match) => useWork ? useWork((work) => (work.approvals ?? []).some(match)) : false; -var useGroupWork = (useWork, pick2) => useWork ? useWork((work) => pick2(work.rooms ?? {}).join(",")) : ""; -var workKeys = (text2) => text2 === "" ? [] : text2.split(",").map((key) => { - const [id, tool] = key.split("|"); - return { id, tool: tool || void 0 }; -}); -var workLine = (lead, tool, more) => (0, import_react17.createElement)(Shimmer, null, `${lead} · ${workPhrase(tool)}…${more > 0 ? ` +${more}` : ""}`); -function BotRow({ bot, roster, actions, useSessions, useSessionStatus, useSessionRetainInfo, useActivity, useWork, wide, marked, asRow = false }) { - const id = bot.id; - const part = currentPart(bot); - const preview = useSessions((list) => nameMentions(previewOf(list, part, roster.exchangeTurns[part], unsentReplyTurns(bot, part), botNames(roster)), roster)); - const running = useSessionStatus((map) => map.get(part)?.running === true); - const active = useSessionRetainInfo(part, (info) => (info?.retainedBy?.mainView ?? 0) > 0); - const unread = useSessionStatus((map) => map.get(part)?.completionUnread === true || map.get(part)?.pendingInteraction !== void 0) || marked && !active; - useMarkedUnread(id, active, marked, actions); - const main = isMainOf(roster, id); - const tile = roster.mainBotId === id && !asRow; - const approval = useApproval(useWork, (entry) => entry.botId === id); - const waiting = approval || (bot.pendingCount ?? 0) > 0; - const alert = roster.questions?.[part] !== void 0 || waiting; - const doing = useActivity((map) => map[id]); - const groups = workKeys(useGroupWork(useWork, (rooms) => Object.entries(rooms).flatMap(([roomId, entries2]) => entries2.filter((entry) => entry.botId === id).map((entry) => `${roomId}|${entry.tool ?? ""}`)))); - const away = !running && groups.length > 0; - const state = botState(running || away, doing, alert); - const shownAway = away ? awayGroups(groups, roster.roomsById) : []; - const awayLine = shownAway.length > 0 ? workLine(roster.roomsById[shownAway[0].id]?.name ?? t("a group chat"), shownAway[0].tool, shownAway.length - 1) : null; - if (!isShownRemoteChat(bot) && !active && !main) return null; - const onContextMenu = (event) => { - event.preventDefault(); - actions.openMenu({ kind: "bot", id, x: event.clientX, y: event.clientY }); - }; - const pinned = bot.pinned === true && !tile; - const remote = Boolean(bot.remote); - const shown = remote ? friendBotTitle(bot) : bot.name; - const secondLine = awayLine || preview || (remote ? bot.remote?.gone ? t("no longer shared") : t("Friend's Bot") : ""); - const label = [shown, main ? t("Main Bot") : "", pinned ? t("Pinned") : "", running || away ? t("Working") : "", waiting ? t("Waiting for your approval") : alert ? t("Waiting for your answer") : "", unread ? t("Unread activity") : ""].filter(Boolean).join(", "); - if (tile && wide) { - return (0, import_react17.createElement)("div", { className: "bt-tile-wrap" }, (0, import_react17.createElement)( - "button", - { - type: "button", - className: "bt-tile", - "aria-label": label, - "aria-current": active ? "page" : void 0, - "data-bot-id": id, - title: preview || bot.name, - onClick: () => actions.openSession(id), - onContextMenu - }, - (0, import_react17.createElement)(BotAvatar, { bot, size: 60, state, main: true }), - (0, import_react17.createElement)("span", { className: "bt-tile-name" }, shown), - // The Main Bot's tile gets the same unread dot as a row (#420); a wait shows on its avatar. - unread && !waiting && !active ? (0, import_react17.createElement)("span", { className: "bt-unread bt-tile-unread", "aria-hidden": true }) : null, - bot.role ? (0, import_react17.createElement)("span", { className: "bt-chip" }, bot.role) : null - )); + if (latest === void 0) continue; + const at = endedAt(latest); + const name = typeof graph.name === "string" && graph.name.trim() !== "" ? graph.name.trim() : String(graph.id ?? t("A workflow")); + const nodeName = typeof latest.failure?.nodeName === "string" ? latest.failure.nodeName.trim() : ""; + const reason = typeof latest.failure?.text === "string" && latest.failure.text !== "" ? latest.failure.text : typeof latest.failure?.code === "string" ? latest.failure.code : ""; + const caller = typeof latest.task?.callerBotId === "string" ? ownBot(roster, latest.task.callerBotId) : void 0; + needs.push({ + id: `workflow:${graph.id ?? name}:${latest.id}`, + kind: "workflow", + status: "failed", + at, + workflow: { id: graph.id ?? "", name }, + run: { id: latest.id }, + title: nodeName === "" ? name : `${name} · ${nodeName}`, + reason, + ...caller === void 0 ? {} : { bot: caller } + }); } - const unreadMark = wide && waiting && !active ? (0, import_react17.createElement)("span", { className: "bt-unread bt-unread-wait", "aria-hidden": true }) : wide && unread && !active ? (0, import_react17.createElement)("span", { className: "bt-unread", "aria-hidden": true }) : null; - const row = (0, import_react17.createElement)( - "button", - { - type: "button", - className: "bt-row", - "aria-label": label, - "aria-current": active ? "page" : void 0, - // An own Bot's hover text names its model (#366); a friend's Bot runs on its owner's model. - "data-bot-id": id, - title: remote ? shown : botRowTitle(shown, modelOf(bot, roster)?.name, wide), - onClick: () => actions.openSession(id), - onContextMenu - }, - (0, import_react17.createElement)(BotAvatar, { bot, size: 36, state, main }), - pinned && !wide ? (0, import_react17.createElement)(PinMark) : null, - wide ? (0, import_react17.createElement)( - "span", - { className: "bt-row-body" }, - (0, import_react17.createElement)( - "span", - { className: "bt-row-name" }, - (0, import_react17.createElement)("span", { className: "bt-row-title" }, shown), - bot.remote ? (0, import_react17.createElement)(RemoteMark, { remote: bot.remote }) : null, - pinned ? (0, import_react17.createElement)(PinMark) : null - ), - secondLine ? (0, import_react17.createElement)("span", { className: "bt-row-preview" }, secondLine) : null - ) : null, - unreadMark - ); - const settingsLabel = bot.remote ? t("Bot settings") : t("Edit Bot"); - const settings = wide ? (0, import_react17.createElement)("button", { - type: "button", - className: "bt-row-settings", - "aria-label": settingsLabel, - title: settingsLabel, - onFocus: (event) => keepSidebarStill(event.currentTarget), - onClick: (event) => { - event.stopPropagation(); - keepSidebarStill(event.currentTarget); - actions.openSettings("bots", id); + if (sessions?.byId) { + for (const bot of roster.bots ?? []) { + if (ownBot(roster, bot.id) === void 0 || work.bots?.[bot.id] !== void 0 || waitingAt.has(`${bot.id}|`)) continue; + if (lastEnded.get(placeKey(bot.id))?.failed !== void 0) continue; + const part = currentPart(bot); + const at = sessions.byId[part]?.updatedAt; + if (!fresh(at)) continue; + const text2 = previewOf(sessions, part, [], [], botNames(roster)); + if (text2 === "") continue; + done.push({ id: `chat:${bot.id}`, kind: "chat", status: "done", bot, at, title: text2, place: "", chat: bot.id, ...people(roster, bot.id) }); } - }, (0, import_react17.createElement)(GearIcon)) : null; - if (settings) return (0, import_react17.createElement)("div", { className: "bt-row-wrap", "data-bot-id": id }, row, settings); - return row; + } + const newest = (a, b) => (b.at ?? Infinity) - (a.at ?? Infinity); + for (const card of [...needs, ...progress, ...done]) { + card.activity = latestActivity(card, { roster, work, sessions }); + card.calls = toolCalls(card, { roster, work }); + } + const settled = done.sort(newest); + return { needs: needs.sort(newest), progress: progress.sort(newest), done: settled.slice(0, DONE_MAX), doneAll: settled, doneTotal: settled.length }; } -function keepSidebarStill(node) { - const reset = () => { - for (let at = node?.parentElement; at; at = at.parentElement) if (at.scrollLeft > 0) at.scrollLeft = 0; - const page = typeof document === "undefined" ? null : document.scrollingElement; - if (page && page.scrollLeft > 0) page.scrollLeft = 0; - }; - reset(); - if (typeof requestAnimationFrame === "function") requestAnimationFrame(() => requestAnimationFrame(reset)); +var needsCount = (roster, work, tasks, now, workflows = []) => inboxModel({ roster, work, tasks, now, workflows }).needs.length; +function startedAtOf(task) { + const started = (task.actors ?? []).filter((actor) => actor.status === "working" && typeof actor.at === "number"); + return started.length ? started.at(-1).at : task.updatedAt; } -function RemoteMark({ remote }) { - return (0, import_react17.createElement)( - "span", - { className: "bt-row-remote", title: remote.gone ? `${remote.handle} · ${t("no longer shared")}` : remote.handle, "aria-label": t("Friend's Bot") }, - (0, import_react17.createElement)( - "svg", - { width: 11, height: 11, viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: 2, strokeLinecap: "round", "aria-hidden": true }, - (0, import_react17.createElement)("circle", { cx: 12, cy: 12, r: 9 }), - (0, import_react17.createElement)("path", { d: "M3 12h18M12 3a14 14 0 0 1 0 18M12 3a14 14 0 0 0 0 18" }) - ) - ); +function visibleNeeds(model, answered) { + return { ...model, needs: model.needs.filter((card) => !answered?.[card.id]) }; } -function isPastFaceRoom(room2) { - if (room2?.origin !== "face-to-face") return false; - const status = room2.face?.status; - if (status === "closed" || status === "left") return true; - const expires = room2.face?.expiresAt; - return status === "candidate" && expires && Number.isFinite(Date.parse(expires)) && Date.parse(expires) <= Date.now(); +function inboxAnswer(actions, card, choice) { + if (card?.kind === "question") { + const reply = choice !== null && typeof choice === "object" ? choice : {}; + const selected = Array.isArray(reply.selected) ? reply.selected.filter((item) => typeof item === "string" && item !== "") : []; + const custom = typeof reply.custom === "string" ? reply.custom.trim() : ""; + if (selected.length === 0 && custom === "") return void 0; + return Promise.resolve(actions.answerQuestion(card.sessionId, card.question.id, { selected, custom })); + } + if (card?.kind === "task" && card.status === "input") { + const text2 = typeof choice === "string" ? choice.trim() : ""; + const questionId = card.task?.inputRequest?.id; + if (text2 === "" || typeof questionId !== "string") return void 0; + return Promise.resolve(actions.answerTaskInput(card.task.id, questionId, text2)); + } + if (card?.kind === "task" && card.status === "result") { + if (choice === "accept") return Promise.resolve(actions.acceptTaskResult(card.task.id)); + const note = choice !== null && typeof choice === "object" ? String(choice.note ?? "").trim() : ""; + if (note === "") return void 0; + return Promise.resolve(actions.sendBackTask(card.task.id, note)); + } + if (card?.kind === "approval" && card.approval?.answerable === true) { + return Promise.resolve(actions.answerApproval(card.approval.id, choice)); + } + return void 0; } -function FaceMark() { - return (0, import_react17.createElement)( +var answerSlots = /* @__PURE__ */ new Map(); +var answerSlot = (id) => { + let slot = answerSlots.get(id); + if (slot === void 0) { + slot = { text: "", picked: [] }; + answerSlots.set(id, slot); + } + return slot; +}; +var resultSlots = /* @__PURE__ */ new Map(); +var resultSlot = (id) => { + let slot = resultSlots.get(id); + if (slot === void 0) { + slot = { open: false, text: "" }; + resultSlots.set(id, slot); + } + return slot; +}; +function ResultActions({ card, busy, onAnswer }) { + const [, redraw] = (0, import_react14.useState)(0); + if (!(card.kind === "task" && card.status === "result")) return null; + const slot = resultSlot(card.id); + const send = () => { + const note = slot.text.trim(); + if (busy || note === "") return; + onAnswer(card, { note }); + }; + return (0, import_react14.createElement)( "span", - { className: "bt-row-remote bt-row-face", title: t("Face-to-face"), "aria-label": t("Face-to-face") }, - (0, import_react17.createElement)( - "svg", - { width: 11, height: 11, viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: 2, strokeLinecap: "round", "aria-hidden": true }, - (0, import_react17.createElement)("circle", { cx: 9, cy: 8, r: 3 }), - (0, import_react17.createElement)("circle", { cx: 16, cy: 9, r: 2.5 }), - (0, import_react17.createElement)("path", { d: "M4 18a5 5 0 0 1 10 0M14 18a4 4 0 0 1 6 0" }) - ) + { className: "bt-inbox-result" }, + slot.open ? (0, import_react14.createElement)("input", { + type: "text", + className: "bt-inbox-answer-text", + value: slot.text, + disabled: busy, + "aria-label": t("What should change"), + placeholder: t("What should change"), + onChange: (event) => { + slot.text = String(event.target?.value ?? ""); + redraw((count) => count + 1); + }, + onKeyDown: (event) => { + if (event.key !== "Enter" || event.shiftKey || event.nativeEvent.isComposing) return; + event.preventDefault(); + send(); + } + }) : null, + slot.open ? (0, import_react14.createElement)("button", { type: "button", className: "bt-send bt-soft-sm", disabled: busy, onClick: send }, t("Send it back")) : (0, import_react14.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", disabled: busy, onClick: () => { + slot.open = true; + redraw((count) => count + 1); + } }, t("Send back")), + (0, import_react14.createElement)("button", { type: "button", className: "bt-send bt-soft-sm", disabled: busy, onClick: () => onAnswer(card, "accept") }, t("Accept")) ); } -function RoomRow({ room: room2, roster, actions, useSessions, useSessionStatus, useSessionRetainInfo, useWork, wide, marked }) { - const id = room2.id; - const fallback = useSessions((list) => nameMentions(previewOf(list, id, [], [], botNames(roster)), roster)); - const preview = nameMentions(visibleText(room2.preview), roster) || fallback; - const active = useSessionRetainInfo(id, (info) => (info?.retainedBy?.mainView ?? 0) > 0); - const unread = useSessionStatus((map) => map.get(id)?.completionUnread === true) || marked && !active; - useMarkedUnread(id, active, marked, actions); - const approval = useApproval(useWork, (entry) => entry.roomId === id); - const question = roster.questions?.[id] !== void 0; - const members = workKeys(useGroupWork(useWork, (rooms) => (rooms[id] ?? []).map((entry) => `${entry.botId}|${entry.tool ?? ""}`))); - const first = roster.byId[members[0]?.id]; - const workingLine = first ? workLine(first.name, members[0].tool, members.length - 1) : null; - if (room2.hidden && !active) return null; - const pinned = room2.pinned === true; - const title = roomTitle(room2); - const from = room2.sharedFrom ? sharedRoomTitle(room2.sharedFrom.inviterName) : ""; - const hover = from && from !== title ? `${title} · ${from}` : title; - const waiting = approval || (room2.pendingCount ?? 0) > 0; - const alert = question || waiting; - const face = room2.origin === "face-to-face"; - const label = [hover, face ? t("Face-to-face") : "", pinned ? t("Pinned") : "", members.length > 0 ? t("Working") : "", waiting ? t("Waiting for your approval") : alert ? t("Waiting for your answer") : ""].filter(Boolean).join(", "); - return (0, import_react17.createElement)( - "button", - { +function AnswerFields({ card, busy, onAnswer }) { + const [, redraw] = (0, import_react14.useState)(0); + const question = card.kind === "question"; + const taskInput = card.kind === "task" && card.status === "input" && typeof card.task?.inputRequest?.id === "string"; + if (!question && !taskInput) return null; + const slot = answerSlot(card.id); + const multi = question && card.question?.multiSelect === true; + const options = question && Array.isArray(card.question?.options) ? card.question.options : []; + const send = () => { + if (busy) return; + const custom = slot.text.trim(); + if (question) { + const selected = multi ? slot.picked.filter((label) => label !== "") : []; + if (selected.length === 0 && custom === "") return; + onAnswer(card, { selected, custom }); + return; + } + if (custom === "") return; + onAnswer(card, custom); + }; + const choose = (label) => { + if (busy) return; + if (!multi) { + onAnswer(card, { selected: [label], custom: "" }); + return; + } + slot.picked = slot.picked.includes(label) ? slot.picked.filter((item) => item !== label) : [...slot.picked, label]; + redraw((count) => count + 1); + }; + return (0, import_react14.createElement)( + "span", + { className: "bt-inbox-answer" }, + ...options.map((option, index) => (0, import_react14.createElement)("button", { + key: `${option.label}-${index}`, type: "button", - className: "bt-row", - "aria-label": label, - "aria-current": active ? "page" : void 0, - // The full title on hover. The row wraps the number-first form so the second - // name is not cut to "SelfB…" (#1102, #304, #458). - title: hover, - "data-room-id": id, - onClick: () => actions.openSession(id), - onContextMenu: (event) => { + className: "bt-soft bt-soft-sm", + disabled: busy, + "aria-pressed": multi ? slot.picked.includes(option.label) : void 0, + onClick: () => choose(option.label) + }, option.label)), + (0, import_react14.createElement)("input", { + type: "text", + className: "bt-inbox-answer-text", + value: slot.text, + disabled: busy, + "aria-label": t("Your own answer"), + placeholder: t("Type your own answer"), + onChange: (event) => { + slot.text = String(event.target?.value ?? ""); + redraw((count) => count + 1); + }, + onKeyDown: (event) => { + if (event.key !== "Enter" || event.shiftKey || event.nativeEvent.isComposing) return; event.preventDefault(); - actions.openMenu({ kind: "room", id, x: event.clientX, y: event.clientY }); + send(); } - }, - (0, import_react17.createElement)(RoomAvatar, { room: room2, roster, size: 36 }), - pinned && !wide ? (0, import_react17.createElement)(PinMark) : null, - wide ? (0, import_react17.createElement)( - "span", - { className: "bt-row-body" }, - (0, import_react17.createElement)("span", { className: "bt-row-name" }, (0, import_react17.createElement)("span", { className: face ? "bt-row-title bt-row-title-face" : "bt-row-title" }, face ? faceRowTitle(title) : title), face ? (0, import_react17.createElement)(FaceMark) : room2.sharedFrom ? (0, import_react17.createElement)("span", { className: "bt-tag", title: from }, t("Shared")) : null, pinned ? (0, import_react17.createElement)(PinMark) : null), - workingLine ? (0, import_react17.createElement)("span", { className: "bt-row-preview" }, workingLine) : preview ? (0, import_react17.createElement)("span", { className: "bt-row-preview" }, preview) : null - ) : null, - wide && alert && !active ? (0, import_react17.createElement)("span", { className: "bt-unread bt-unread-wait", "aria-hidden": true }) : wide && unread && !active ? (0, import_react17.createElement)("span", { className: "bt-unread", "aria-hidden": true }) : null + }), + (0, import_react14.createElement)("button", { type: "button", className: "bt-send bt-soft-sm", disabled: busy, onClick: send }, t("Submit")) ); } -function AgentSessionRow({ session, agent, actions, renaming, index }) { - const input = (0, import_react17.useRef)(null); - (0, import_react17.useLayoutEffect)(() => { - if (!renaming) return; - input.current?.focus(); - input.current?.select(); - }, [renaming]); - if (renaming) { - const commit = (keep) => { - const value = input.current?.value ?? ""; - if (!keep || value.trim() === "" || value.trim() === session.title) { - actions.endAgentRename(); - return; - } - void (async () => { - try { - await actions.renameAgentSession(agent.id, session.id, value); - actions.endAgentRename(); - } catch (error) { - actions.reportError(error, "agent-rename"); - } - })(); - }; - return (0, import_react17.createElement)( - "div", - { className: "bt-agent-sub-row bt-agent-edit", style: { "--i": index } }, - (0, import_react17.createElement)("input", { - ref: input, - defaultValue: session.title, - "aria-label": t("Rename"), - onKeyDown: (event) => { - if (event.nativeEvent.isComposing) return; - if (event.key === "Enter") commit(true); - else if (event.key === "Escape") commit(false); - }, - onBlur: () => commit(true) - }) - ); - } - return (0, import_react17.createElement)( +function InboxRow({ wide, useRoster, useWork, useUi, actions }) { + const roster = useRoster((value) => value); + const work = useWork ? useWork((value) => value) : {}; + const open = useUi ? useUi((value) => Boolean(value.inbox)) : false; + const tasks = useTasks(actions); + const workflows = useWorkflows(actions); + const count = needsCount(roster, work, tasks, void 0, workflows); + const label = count > 0 ? t("Inbox, {count} need you", { count }) : t("Inbox"); + return (0, import_react14.createElement)( "button", { type: "button", - className: "bt-agent-sub-row", - "aria-current": session.active ? "page" : void 0, - style: { "--i": index }, - onClick: () => actions.openAgentSession(session.id), - onContextMenu: (event) => { - event.preventDefault(); - event.stopPropagation(); - actions.openMenu({ kind: "agent-session", id: session.id, agentId: agent.id, x: event.clientX, y: event.clientY }); - } + className: "bt-row bt-inbox-row", + "aria-label": label, + title: wide ? void 0 : label, + "aria-haspopup": "dialog", + "aria-expanded": open, + onClick: () => actions.openInbox() }, - (0, import_react17.createElement)("span", { className: "bt-agent-sub-title" }, session.title), - (0, import_react17.createElement)("span", { className: "bt-agent-sub-time" }, session.at > 0 ? relativeShort(session.at) : "") + (0, import_react14.createElement)("span", { className: "bt-inbox-mark" }, (0, import_react14.createElement)(InboxIcon)), + wide ? (0, import_react14.createElement)("span", { className: "bt-inbox-label" }, t("Inbox")) : null, + count > 0 ? (0, import_react14.createElement)("span", { className: "bt-inbox-count", "aria-hidden": true }, count > 99 ? "99+" : String(count)) : null ); } -function AgentRow({ agent, actions, useSessions, useSessionStatus, useUi, wide, marked }) { - const info = useSessions((list) => JSON.stringify(agent.sessions.map((id) => { - const row = list.byId[id]; - return { - id, - // A blank Session's title is the workspace folder name; show the label instead. - title: row?.blank === true ? "" : row?.displayTitle ?? row?.title ?? "", - at: row?.updatedAt ?? 0, - running: row?.running === true, - active: (row?.retainedBy?.mainView ?? 0) > 0 - }; - }).sort((a, b) => b.at - a.at))); - const statusInfo = useSessionStatus((map) => JSON.stringify(agent.sessions.map((id) => ({ - id, - running: map.get(id)?.running === true, - unread: map.get(id)?.completionUnread === true - })))); - const statuses = Object.fromEntries(JSON.parse(statusInfo).map((entry) => [entry.id, entry])); - const sessions = JSON.parse(info).map((session) => ({ - ...session, - running: statuses[session.id]?.running ?? session.running, - unread: statuses[session.id]?.unread ?? false, - title: session.title === "" ? t("New session") : session.title - })); - const renaming = useUi((value) => value.renameAgent); - const active = sessions.some((session) => session.active); - const running = sessions.some((session) => session.running); - const unread = sessions.some((session) => session.unread) || marked && !active; - useMarkedUnread(agent.id, active, marked, actions); - const preview = sessions[0]?.title ?? t("New session"); - const label = ["DSH Agent", running ? t("Working") : "", unread ? t("Unread activity") : ""].filter(Boolean).join(", "); - const open = active && wide; - return (0, import_react17.createElement)( +function Status({ status }) { + const { label, tone, Icon } = STATUSES[status]; + return (0, import_react14.createElement)("span", { className: "bt-inbox-status", "data-tone": tone }, (0, import_react14.createElement)(Icon), (0, import_react14.createElement)("span", null, t(label))); +} +function whenText(card, now) { + if (card.status === "running" || card.status === "task") { + return typeof card.since === "number" && now - card.since >= 6e4 ? t("for {time}", { time: elapsedLabel(now - card.since) }) : t("just started"); + } + return typeof card.at === "number" ? relativeShort(card.at) : ""; +} +function InboxCard({ card, roster, now, busy, error, onAnswer, onOpen }) { + const who = card.bot ? card.bot.name : card.room?.name ?? ""; + const produced = card.kind === "task" && card.status === "result" ? [card.bot?.model] : []; + const meta = [who, ...produced, card.place, whenText(card, now)].filter(Boolean).join(" · "); + const answerable = card.kind === "approval" && card.approval?.answerable === true; + const evidence = card.kind === "task" && card.status === "result" ? card.task?.result?.links ?? [] : []; + const sentBack = card.kind === "task" && card.status === "task" ? card.task?.followUp?.note : void 0; + const target = card.kind === "task" ? card.task?.target?.href : void 0; + const writeBack = card.kind === "task" && card.task?.writeBack?.state === "failed" ? card.task.writeBack : void 0; + const unconfirmed = card.kind === "task" && card.task?.writeBack?.state === "unconfirmed" ? card.task.writeBack : void 0; + return (0, import_react14.createElement)( "div", - { className: "bt-agent" }, - (0, import_react17.createElement)( - "button", - { - type: "button", - className: "bt-row", - "aria-label": label, - "aria-current": active ? "page" : void 0, - "data-agent-id": agent.id, - title: wide ? void 0 : "DSH Agent", - onClick: () => actions.openAgent(agent.id), - onContextMenu: (event) => { - event.preventDefault(); - actions.openMenu({ kind: "agent", id: agent.id, x: event.clientX, y: event.clientY }); - } - }, - (0, import_react17.createElement)("span", { className: `bt-agent-ava${running ? " bt-run" : ""}` }, (0, import_react17.createElement)(import_dsh_client_ui_primitives2.FishLogo, { size: 22 })), - wide ? (0, import_react17.createElement)( + { className: "bt-inbox-card", role: "group", "aria-label": `${t(STATUSES[card.status].label)}: ${card.title}`, "data-status": card.status, "data-run": card.kind === "workflow" ? card.run?.id : void 0 }, + (0, import_react14.createElement)("span", { className: "bt-inbox-ava" }, card.bot ? (0, import_react14.createElement)(BotAvatar, { bot: card.bot, size: 28, badge: false, live: false }) : card.room ? (0, import_react14.createElement)(RoomAvatar, { room: card.room, roster, size: 28 }) : null), + (0, import_react14.createElement)( + "span", + { className: "bt-inbox-copy" }, + (0, import_react14.createElement)( "span", - { className: "bt-row-body" }, - (0, import_react17.createElement)("span", { className: "bt-row-name" }, "DSH Agent"), - preview ? (0, import_react17.createElement)("span", { className: "bt-row-preview" }, preview) : null - ) : null, - wide && unread && !active ? (0, import_react17.createElement)("span", { className: "bt-unread", "aria-hidden": true }) : null + { className: "bt-inbox-top" }, + (0, import_react14.createElement)(Status, { status: card.status }), + (0, import_react14.createElement)("span", { className: "bt-inbox-meta", title: meta }, meta) + ), + (0, import_react14.createElement)("span", { className: "bt-inbox-title", title: card.title }, card.title), + card.calls > 0 ? (0, import_react14.createElement)("span", { className: "bt-inbox-calls" }, t("{count} tool calls", { count: card.calls })) : null, + card.reason ? (0, import_react14.createElement)("span", { className: "bt-inbox-reason", title: card.reason }, failureSentence(card.reason, t)) : null, + card.activity ? (0, import_react14.createElement)("span", { className: "bt-inbox-activity", title: card.activity }, t("Latest: {text}", { text: card.activity })) : null, + card.hint ? (0, import_react14.createElement)("span", { className: "bt-inbox-hint" }, card.hint) : null, + evidence.length > 0 ? (0, import_react14.createElement)("span", { className: "bt-inbox-evidence" }, ...evidence.map((href, index) => (0, import_react14.createElement)("a", { + key: `${href}-${index}`, + href, + target: "_blank", + rel: "noreferrer noopener", + className: "bt-inbox-evidence-link", + title: href + }, href))) : null, + sentBack ? (0, import_react14.createElement)("span", { className: "bt-inbox-reason", title: sentBack }, t("Sent back: {note}", { note: sentBack })) : null, + target ? (0, import_react14.createElement)("span", { className: "bt-inbox-evidence" }, (0, import_react14.createElement)("a", { + href: target, + target: "_blank", + rel: "noreferrer noopener", + className: "bt-inbox-evidence-link bt-inbox-target", + title: target + }, t("Comment goes to {target}", { target: target.replace(/^https:\/\/github\.com\//, "") }))) : null, + writeBack ? (0, import_react14.createElement)("span", { className: "bt-inbox-writeback", role: "alert", title: writeBack.error }, t("Could not comment on {target}: {error}", { target: writeBack.target, error: writeBack.error })) : null, + unconfirmed ? (0, import_react14.createElement)("span", { className: "bt-inbox-writeback", title: unconfirmed.target }, t("Could not confirm the comment on {target} went out", { target: unconfirmed.target })) : null, + (0, import_react14.createElement)(AnswerFields, { card, busy, onAnswer }), + (0, import_react14.createElement)(ResultActions, { card, busy, onAnswer }), + error ? (0, import_react14.createElement)("span", { className: "bt-inbox-error", role: "alert" }, error) : null ), - (0, import_react17.createElement)( + (0, import_react14.createElement)( + "span", + { className: "bt-inbox-actions" }, + answerable ? (0, import_react14.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", disabled: busy, onClick: () => onAnswer(card, "reject") }, t("Reject")) : null, + answerable ? (0, import_react14.createElement)("button", { type: "button", className: "bt-send bt-soft-sm", disabled: busy, onClick: () => onAnswer(card, "allow") }, t("Allow once")) : null, + ...(answerable ? alwaysChoices(card.approval, card.bot) : []).map(([choice, label]) => (0, import_react14.createElement)("button", { key: choice, type: "button", className: "bt-soft bt-soft-sm", "data-choice": choice, disabled: busy, onClick: () => onAnswer(card, choice) }, t(label))), + (0, import_react14.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", "aria-label": who ? t("Open {name}", { name: card.room?.name ?? who }) : t("Open"), onClick: () => onOpen(card) }, t("Open")) + ) + ); +} +function useNow2(ms) { + const [now, setNow] = (0, import_react14.useState)(Date.now); + (0, import_react14.useEffect)(() => { + const timer = setInterval(() => setNow(Date.now()), ms); + return () => clearInterval(timer); + }, [ms]); + return now; +} +function openCard(actions, card) { + actions.closeInbox(); + if (card?.kind === "workflow") { + actions.openSettings("workflow"); + return; + } + actions.openSession(card.chat); +} +function InboxDialog({ roster, useWork, useSessions, actions, leaving = false }) { + const work = useWork((value) => value); + const sessions = useSessions((value) => value); + const tasks = useTasks(actions); + const workflows = useWorkflows(actions); + const now = useNow2(3e4); + const [answered, setAnswered] = (0, import_react14.useState)({}); + const [busy, setBusy] = (0, import_react14.useState)(""); + const [errors, setErrors] = (0, import_react14.useState)({}); + const [feedback, setFeedback] = (0, import_react14.useState)(""); + const [allDone, setAllDone] = (0, import_react14.useState)(false); + const close = (0, import_react14.useCallback)(() => actions.closeInbox(), []); + useEscape(close); + (0, import_react14.useEffect)(() => { + if (!feedback) return void 0; + const timer = setTimeout(() => setFeedback(""), FEEDBACK_MS); + return () => clearTimeout(timer); + }, [feedback]); + const model = inboxModel({ roster, work, tasks, sessions, workflows, now }); + const groups = visibleNeeds(model, answered); + const answer = (card, choice) => { + setBusy(card.id); + setErrors((value) => ({ ...value, [card.id]: "" })); + const pending = inboxAnswer(actions, card, choice); + if (pending === void 0) { + setBusy(""); + return; + } + void Promise.resolve(pending).then(() => { + setAnswered((value) => ({ ...value, [card.id]: true })); + const name = card.bot?.name ?? ""; + setFeedback(card.kind === "approval" ? choice === "allow" ? t("Allowed once for {name}", { name }) : choice === "always-bot" ? t("Always allowed for {name}", { name }) : choice === "always-room" ? t("Always allowed in this group") : t("Rejected for {name}", { name }) : t("Answered")); + }, (failure) => setErrors((value) => ({ ...value, [card.id]: hostText(failure?.message ?? String(failure)) }))).finally(() => setBusy("")); + }; + const open = (card) => openCard(actions, card); + return (0, import_react14.createElement)( + "div", + { className: "bt-mem-layer bt-inbox-layer", "data-leaving": leaving || void 0 }, + (0, import_react14.createElement)("div", { className: "bt-scrim bt-mem-scrim", onMouseDown: close }), + (0, import_react14.createElement)( "div", - { className: "bt-agent-sub", "data-open": open || void 0 }, - (0, import_react17.createElement)( + { className: "bt-mem bt-inbox", role: "dialog", "aria-modal": true, "aria-labelledby": "bt-inbox-heading" }, + (0, import_react14.createElement)( "div", - { className: "bt-agent-sub-in" }, - (0, import_react17.createElement)( - "button", - { type: "button", className: "bt-agent-sub-row bt-agent-new", style: { "--i": 0 }, onClick: () => { - void actions.newAgentSession(agent.id).catch((error) => actions.reportError(error, "agent-session")); - } }, - (0, import_react17.createElement)(PlusIcon), - (0, import_react17.createElement)("span", { className: "bt-agent-sub-title" }, t("New session")) - ), - sessions.map((session, index) => (0, import_react17.createElement)(AgentSessionRow, { - key: session.id, - session, - agent, - actions, - index: index + 1, - renaming: renaming === session.id - })) + { className: "bt-mem-head" }, + (0, import_react14.createElement)("h2", { id: "bt-inbox-heading" }, t("Inbox")), + (0, import_react14.createElement)("span", { className: "bt-mem-status" }, t("Your Bots")), + (0, import_react14.createElement)("span", { className: "bt-mem-head-gap" }), + (0, import_react14.createElement)("button", { type: "button", className: "bt-icon-btn", "aria-label": t("Close inbox"), autoFocus: true, onClick: close }, (0, import_react14.createElement)(CloseIcon)) + ), + (0, import_react14.createElement)( + "div", + { className: "bt-mem-body bt-inbox-body" }, + (0, import_react14.createElement)("div", { className: "bt-inbox-feedback", role: "status", "aria-live": "polite" }, feedback), + GROUPS.map((group) => { + const settled = group.id === "done" ? model.doneAll ?? groups.done : null; + const canOpen = group.id === "done" && settled.length > DONE_MAX; + const rows = canOpen && allDone ? settled : groups[group.id]; + const hidden = group.id === "done" ? Math.max(0, (model.doneTotal ?? rows.length) - rows.length) : 0; + return (0, import_react14.createElement)( + "section", + { key: group.id, className: "bt-inbox-group", "aria-labelledby": `bt-inbox-${group.id}` }, + (0, import_react14.createElement)( + "h3", + { id: `bt-inbox-${group.id}`, className: "bt-inbox-head" }, + (0, import_react14.createElement)("span", null, t(group.title)), + rows.length > 0 ? (0, import_react14.createElement)("span", { className: "bt-inbox-head-count" }, String(rows.length)) : null + ), + rows.length === 0 ? (0, import_react14.createElement)("p", { className: "bt-inbox-empty" }, t(group.empty)) : (0, import_react14.createElement)("div", { className: "bt-inbox-list" }, rows.map((card) => (0, import_react14.createElement)(InboxCard, { + key: card.id, + card, + roster, + now, + busy: busy === card.id, + error: errors[card.id], + onAnswer: answer, + onOpen: open + }))), + hidden > 0 ? (0, import_react14.createElement)("div", { key: "more", className: "bt-inbox-more" }, t("{count} more not shown", { count: hidden })) : null, + canOpen ? (0, import_react14.createElement)("button", { + key: "all", + type: "button", + className: "bt-inbox-show-all", + "aria-expanded": allDone, + onClick: () => setAllDone((value) => !value) + }, t(allDone ? "Show fewer" : "Show all")) : null + ); + }) ) ) ); } -var GLIDE = springEasing(1e3, 63); -function useListGlide(listRef, layout, key) { - const tops = (0, import_react17.useRef)({ layout, map: /* @__PURE__ */ new Map() }); - (0, import_react17.useLayoutEffect)(() => { - const list = listRef.current; - if (!list) return; - const next = /* @__PURE__ */ new Map(); - const still = reducedMotion() || tops.current.layout !== layout; - for (const row of list.querySelectorAll("[data-bot-id],[data-room-id],[data-agent-id]")) { - const id = row.dataset.botId ?? row.dataset.roomId ?? row.dataset.agentId; - const top = row.offsetTop; - next.set(id, top); - const before = tops.current.map.get(id); - if (!still && before !== void 0 && Math.abs(before - top) > 0.5) { - row.animate([{ transform: `translateY(${before - top}px)` }, { transform: "none" }], GLIDE); - } - } - tops.current = { layout, map: next }; - }, [layout, key]); +var INBOX_CSS = ` +.bt-inbox-row{min-height:40px;padding:6px 8px;gap:10px} +.bt-inbox-mark{flex:none;width:36px;height:28px;display:inline-flex;align-items:center;justify-content:center;color:var(--bt-ink-2)} +.bt-inbox-mark svg{width:18px;height:18px} +.bt-inbox-label{flex:1 1 auto;min-width:0;font-size:14px;line-height:20px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis} +.bt-inbox-count{flex:none;min-width:20px;height:20px;padding:0 6px;border-radius:999px;background:var(--bt-accent);color:var(--bt-accent-ink,#fff);font-size:11.5px;line-height:20px;font-weight:600;text-align:center;font-variant-numeric:tabular-nums;box-sizing:border-box} +.bt-rail .bt-inbox-row{position:relative} +.bt-rail .bt-inbox-count{position:absolute;top:2px;right:calc(50% - 22px);min-width:16px;height:16px;padding:0 4px;font-size:10px;line-height:16px;box-shadow:0 0 0 1.5px var(--bt-sidebar)} +.bt-inbox-body{gap:18px} +.bt-inbox-feedback{min-height:0;font-size:13px;line-height:18px;color:var(--bt-ink-2)} +.bt-inbox-feedback:empty{display:none} +.bt-inbox-group{display:flex;flex-direction:column;gap:8px} +.bt-inbox-head{display:flex;align-items:center;gap:6px;margin:0;font-size:13px;line-height:18px;font-weight:600;color:var(--bt-ink-2)} +.bt-inbox-head-count{font-weight:500;color:var(--bt-ink-3);font-variant-numeric:tabular-nums} +.bt-inbox-empty{margin:0;font-size:13px;line-height:18px;color:var(--bt-ink-3)} +.bt-inbox-more{margin:0;font-size:12.5px;line-height:18px;color:var(--bt-ink-3);font-variant-numeric:tabular-nums} +.bt-inbox-show-all{align-self:flex-start;padding:0;border:0;background:none;font:inherit;font-size:12.5px;line-height:18px;color:var(--bt-accent);cursor:pointer} +.bt-inbox-show-all:hover{text-decoration:underline} +.bt-inbox-show-all:focus-visible{outline:2px solid color-mix(in srgb,var(--bt-accent) 45%,transparent);outline-offset:2px;border-radius:4px} +.bt-inbox-list{display:flex;flex-direction:column;gap:6px} +.bt-inbox-card{display:flex;align-items:flex-start;gap:10px;padding:10px 12px;border:.5px solid var(--bt-line-2);border-radius:12px;background:var(--bt-card,var(--bt-main))} +.bt-inbox-card:focus-within{box-shadow:0 0 0 2px color-mix(in srgb,var(--bt-accent) 35%,transparent)} +.bt-inbox-ava{flex:none;line-height:0;padding-top:2px} +.bt-inbox-copy{flex:1 1 auto;min-width:0;display:flex;flex-direction:column;gap:2px} +.bt-inbox-top{display:flex;align-items:center;gap:8px;min-width:0} +.bt-inbox-status{flex:none;display:inline-flex;align-items:center;gap:4px;height:20px;padding:0 7px 0 5px;border-radius:999px;font-size:11.5px;line-height:20px;font-weight:600;white-space:nowrap} +.bt-inbox-status svg{flex:none} +.bt-inbox-status[data-tone=ask]{background:color-mix(in srgb,var(--bt-star) 18%,transparent);color:color-mix(in srgb,var(--bt-star) 70%,var(--bt-ink))} +.bt-inbox-status[data-tone=danger]{background:rgba(224,33,53,.12);color:#c21d2e} +.bt-inbox-status[data-tone=run]{background:color-mix(in srgb,var(--bt-accent) 14%,transparent);color:color-mix(in srgb,var(--bt-accent) 80%,var(--bt-ink))} +.bt-inbox-status[data-tone=done]{background:color-mix(in srgb,var(--bt-green) 16%,transparent);color:color-mix(in srgb,var(--bt-green) 70%,var(--bt-ink))} +.bt-inbox-status[data-tone=muted]{background:var(--bt-hover);color:var(--bt-ink-2)} +.bt-inbox-meta{min-width:0;font-size:12px;line-height:16px;color:var(--bt-ink-3);white-space:nowrap;overflow:hidden;text-overflow:ellipsis} +.bt-inbox-title{font-size:13.5px;line-height:19px;color:var(--bt-ink);white-space:nowrap;overflow:hidden;text-overflow:ellipsis} +.bt-inbox-reason,.bt-inbox-hint,.bt-inbox-activity{font-size:12px;line-height:16px;color:var(--bt-ink-2);white-space:nowrap;overflow:hidden;text-overflow:ellipsis} +/* Needs you is answered on the card, so its ask is the whole text (#1177). The phone sheet clamps these two classes to two lines at a lower specificity; this unsets that clamp. In progress and Done stay one line. */ +.bt-inbox-card:is([data-status=approval],[data-status=held],[data-status=input],[data-status=result],[data-status=failed]) :is(.bt-inbox-title,.bt-inbox-reason){white-space:pre-wrap;overflow-wrap:anywhere;overflow:visible;text-overflow:clip;display:block;min-width:0;-webkit-line-clamp:unset;-webkit-box-orient:unset} +.bt-inbox-calls{font-size:12px;line-height:16px;color:var(--bt-ink-3);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;font-variant-numeric:tabular-nums} +.bt-inbox-activity{color:var(--bt-ink-3)} +.bt-inbox-error{font-size:12px;line-height:16px;color:#c21d2e;overflow-wrap:anywhere} +.bt-inbox-writeback{font-size:12px;line-height:16px;color:#c21d2e;overflow-wrap:anywhere} +.bt-inbox-evidence{display:flex;flex-wrap:wrap;gap:6px;font-size:12px;line-height:16px} +.bt-inbox-evidence-link{color:var(--bt-blue,#3a7afe);text-decoration:none;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:100%} +.bt-inbox-evidence-link:hover{text-decoration:underline} +.bt-inbox-result{display:flex;flex-wrap:wrap;align-items:center;gap:6px;margin-top:6px} +.bt-inbox-result .bt-send,.bt-inbox-result .bt-soft{height:28px;font-size:12px} +.bt-inbox-answer{display:flex;flex-wrap:wrap;align-items:center;gap:6px;margin-top:6px} +.bt-inbox-answer-text{flex:1 1 140px;min-width:0;height:28px;padding:0 8px;border:.5px solid var(--bt-line-2);border-radius:8px;background:transparent;color:var(--bt-ink);font:inherit;font-size:12px} +.bt-inbox-answer .bt-send,.bt-inbox-answer .bt-soft{height:28px;font-size:12px} +.bt-inbox-answer button[aria-pressed=true]{background:var(--bt-active);color:var(--bt-ink)} +.bt-inbox-actions{flex:none;display:flex;align-items:center;gap:6px;align-self:center} +.bt-inbox-actions .bt-send{height:28px;font-size:12px} +`; + +// src/shared/board-card.js +function taskCardKey(id) { + const text2 = String(id ?? "").trim(); + return text2 === "" ? null : `task:${text2}`; +} +function taskCard(task) { + const key = taskCardKey(task?.id); + if (key === null) return null; + return { + source: "task", + key, + id: String(task.id), + title: String(task.title ?? "") + }; } -var NodeActions = class extends import_react17.Component { - constructor(props) { - super(props); - this.state = { failed: false }; - } - static getDerivedStateFromError() { - return { failed: true }; - } - componentDidCatch(error) { - console.error("flybot: nodeActions failed", error); - } - render() { - return this.state.failed ? null : (0, import_react17.createElement)(this.props.component, { bot: this.props.bot }); - } + +// src/client/board-card.js +var import_react15 = require("react"); +var TASK_SKIN = { + card: "bt-board-card", + top: "bt-board-card-top", + id: "bt-board-id", + badge: "bt-board-badge", + title: "bt-board-title", + labels: "bt-board-labels", + label: "bt-board-label", + owner: "bt-board-who", + note: "bt-board-note" }; -function TreeNode({ node, rowProps, markedUnread, collapsed, toggle, actions }) { - const { bot, depth, children } = node; - const closed = collapsed[bot.id] === true; - return (0, import_react17.createElement)( - import_react17.Fragment, - null, - (0, import_react17.createElement)( - "div", - { className: "bt-tree-item", "data-depth": depth, style: { paddingLeft: `${depth * 16}px` } }, - children.length ? (0, import_react17.createElement)( - "button", - { type: "button", className: "bt-tree-toggle", "aria-expanded": !closed, "aria-label": `${closed ? t("Expand") : t("Collapse")} ${bot.name}`, onClick: () => toggle(bot.id) }, - (0, import_react17.createElement)("span", { className: "bt-tree-chev", "aria-hidden": true }, "▾") - ) : (0, import_react17.createElement)("span", { className: "bt-tree-toggle bt-tree-leaf", "aria-hidden": true }), - (0, import_react17.createElement)(BotRow, { ...rowProps, bot, asRow: true, marked: markedUnread[bot.id] === true }), - typeof actions === "function" ? (0, import_react17.createElement)(NodeActions, { component: actions, bot }) : null +function BoardCard({ + card, + skin = TASK_SKIN, + attrs = {}, + idText = "", + topExtra = null, + badge = "", + meta = null, + note = "", + footer = null, + actions = null, + emptyTitle = "", + draggable = false, + dragValue = "", + onDragStart = null, + onDragEnd = null +}) { + const labels = Array.isArray(card?.labels) ? card.labels : []; + return (0, import_react15.createElement)( + "article", + { + ...attrs, + className: skin.card, + "data-source": card?.source, + "data-key": card?.key ?? void 0, + draggable: draggable || void 0, + onDragStart: draggable ? (event) => { + event.dataTransfer?.setData("text/plain", dragValue); + if (event.dataTransfer) event.dataTransfer.effectAllowed = "move"; + onDragStart?.(); + } : void 0, + onDragEnd: draggable ? () => onDragEnd?.() : void 0 + }, + (0, import_react15.createElement)( + "span", + { className: skin.top }, + (0, import_react15.createElement)("span", { className: skin.id }, idText), + topExtra, + badge ? (0, import_react15.createElement)("span", { className: skin.badge, "data-badge": card?.badge ?? void 0 }, badge) : null ), - closed ? null : children.map((child) => (0, import_react17.createElement)(TreeNode, { key: child.bot.id, node: child, rowProps, markedUnread, collapsed, toggle, actions })) + (0, import_react15.createElement)("span", { className: skin.title }, card?.title || emptyTitle), + labels.length ? (0, import_react15.createElement)("span", { className: skin.labels }, labels.map((name) => (0, import_react15.createElement)("span", { key: name, className: skin.label, "data-label": name }, name))) : null, + card?.owner ? (0, import_react15.createElement)("span", { className: skin.owner, "data-kind": card.ownerKind ?? "human" }, card.owner) : null, + note ? (0, import_react15.createElement)("span", { className: skin.note }, note) : null, + meta, + footer, + actions ); } -function TeamSidebar(props) { - const { wide, useRoster, useUi, useSessions, actions } = props; - const listRef = (0, import_react17.useRef)(null); - const roster = useRoster((value) => value); - const markedUnread = useUi((value) => value.unread); - props.useRegistry((value) => value); - const updated = useSessions((list) => { - const stamps = {}; - for (const id of list.ids) stamps[id] = list.byId[id]?.updatedAt ?? 0; - return JSON.stringify(stamps); - }); - const order = (0, import_react17.useMemo)(() => { - const stamps = JSON.parse(updated); - const entries2 = [ - // Own Bots, friends' Bots, groups and face rooms share one list. A remote chat - // still uses BotRow's globe; gone / hidden / home-level remotes stay out. - ...roster.bots.filter((bot) => bot.id !== roster.mainBotId && isShownRemoteChat(bot)).map((bot) => ({ kind: "bot", item: bot, at: stamps[currentPart(bot)] ?? bot.createdAt })), - ...roster.rooms.filter((room2) => !isPastFaceRoom(room2)).map((room2) => ({ kind: "room", item: room2, at: stamps[room2.id] ?? room2.createdAt })), - // The Agent sorts by its most recent Session, falling back to when it was added. - ...(roster.agents ?? []).map((agent) => ({ kind: "agent", item: agent, at: Math.max(0, ...agent.sessions.map((id) => stamps[id] ?? 0)) || agent.createdAt })) - ]; - const foreign = (entry) => entry.kind === "bot" && entry.item.remote || entry.kind === "room" && (entry.item.origin === "face-to-face" || entry.item.sharedFrom); - return entries2.sort((a, b) => Number(Boolean(b.item.pinned)) - Number(Boolean(a.item.pinned)) || Number(foreign(a)) - Number(foreign(b)) || b.at - a.at); - }, [roster, updated]); - const main = roster.byId[roster.mainBotId]; - const readOnlySaid = typeof roster.readOnly === "string" && roster.readOnly.trim() !== ""; - const structure = useSidebarStructure(); - const [collapsed, setCollapsed] = (0, import_react17.useState)({}); - const toggle = (id) => setCollapsed((value) => ({ ...value, [id]: !value[id] })); - const inTree = (entry) => entry.kind === "bot" && !entry.item.remote; - const listed = (entry) => inTree(entry) && structure?.include?.(entry.item.id) !== false; - const tree = structure && wide && main ? botTree([main, ...order.filter(listed).map((entry) => entry.item)], main.id, (id) => structure.parentOf(id)) : null; - useListGlide(listRef, wide, order.map((entry) => entry.item.id).join(",")); - useLandOnMainBot(roster, useSessions, actions); - const rowProps = { roster, actions, useSessions, useUi, useSessionStatus: props.useSessionStatus, useSessionRetainInfo: props.useSessionRetainInfo, useActivity: props.useActivity, useWork: props.useWork, wide }; - const typeToSearch = (event) => { - if (event.defaultPrevented || event.key.length !== 1 || event.key === " " || event.metaKey || event.ctrlKey || event.altKey || event.nativeEvent.isComposing) return; - if (event.target instanceof Element && event.target.closest("input,textarea,[contenteditable]")) return; - event.preventDefault(); - actions.openPalette(event.key); - }; - return (0, import_react17.createElement)( - "div", - { className: wide ? "bt-side" : "bt-side bt-rail", onKeyDown: typeToSearch }, - wide ? (0, import_react17.createElement)( - "div", - { className: "bt-side-top" }, - (0, import_react17.createElement)("span", { className: "bt-brand" }, (0, import_react17.createElement)(BrandMark, { size: 22 }), (0, import_react17.createElement)(BrandName)), - (0, import_react17.createElement)(UpdateButton, { useUpdate: props.useUpdate, actions }), - (0, import_react17.createElement)("button", { type: "button", className: "bt-round", "aria-label": t("Search"), title: t("Search"), onClick: () => actions.openPalette() }, (0, import_react17.createElement)(SearchIcon)), - (0, import_react17.createElement)("button", { type: "button", className: "bt-round", "aria-label": t("New…"), title: t("New…"), onClick: () => actions.openNewChat("new") }, (0, import_react17.createElement)(PlusIcon)) - ) : (0, import_react17.createElement)( - import_react17.Fragment, - null, - (0, import_react17.createElement)(UpdateButton, { useUpdate: props.useUpdate, actions }), - (0, import_react17.createElement)("button", { type: "button", className: "bt-round", "aria-label": t("New…"), title: t("New…"), onClick: () => actions.openNewChat("new") }, (0, import_react17.createElement)(PlusIcon)) - ), - wide && readOnlySaid ? (0, import_react17.createElement)("div", { className: "bt-hint bt-read-only", role: "status" }, hostText(roster.readOnly)) : null, - !wide && readOnlySaid ? (0, import_react17.createElement)("div", { className: "bt-hint bt-read-only", role: "status", title: hostText(roster.readOnly), "aria-label": hostText(roster.readOnly) }, hostText(roster.readOnly)) : null, - (0, import_react17.createElement)(InboxRow, { wide, useRoster, useWork: props.useWork, useUi, actions }), - (0, import_react17.createElement)(KanbanRow, { wide, useUi, actions }), - (0, import_react17.createElement)(ProjectsNav, { wide }), - (0, import_react17.createElement)( - "div", - { ref: listRef, className: "bt-list", role: "region", "aria-label": t("Bot list") }, - tree ? (0, import_react17.createElement)("div", { className: "bt-tree", role: "group", "aria-label": t("Bot tree") }, tree.map((node) => (0, import_react17.createElement)(TreeNode, { key: node.bot.id, node, rowProps, markedUnread, collapsed, toggle, actions: structure?.nodeActions }))) : main ? (0, import_react17.createElement)(BotRow, { ...rowProps, bot: main, marked: markedUnread[main.id] === true }) : roster.ready ? null : (0, import_react17.createElement)("div", { className: "bt-hint" }, t("Setting up your Main Bot…")), - order.filter((entry) => !(tree && inTree(entry)) && !(inTree(entry) && structure?.include?.(entry.item.id) === false)).map((entry) => entry.kind === "bot" ? (0, import_react17.createElement)(BotRow, { key: entry.item.id, ...rowProps, bot: entry.item, marked: markedUnread[entry.item.id] === true }) : entry.kind === "room" ? (0, import_react17.createElement)(RoomRow, { key: entry.item.id, ...rowProps, room: entry.item, marked: markedUnread[entry.item.id] === true }) : (0, import_react17.createElement)(AgentRow, { key: entry.item.id, ...rowProps, agent: entry.item, marked: markedUnread[entry.item.id] === true })), - wide ? (0, import_react17.createElement)(FlyBotSections, { roster, actions, renderBot: (bot) => (0, import_react17.createElement)(BotRow, { key: bot.id, ...rowProps, bot, asRow: true, marked: markedUnread[bot.id] === true }) }) : null - ) + +// src/client/kanban.js +var import_react16 = require("react"); +var POLL_MS4 = 15e3; +function BoardIcon() { + return (0, import_react16.createElement)( + "svg", + { width: 18, height: 18, viewBox: "0 0 20 20", fill: "none", stroke: "currentColor", strokeWidth: 1.6, strokeLinejoin: "round", "aria-hidden": true }, + (0, import_react16.createElement)("rect", { x: 2.5, y: 3, width: 4, height: 14, rx: 1 }), + (0, import_react16.createElement)("rect", { x: 8, y: 3, width: 4, height: 9, rx: 1 }), + (0, import_react16.createElement)("rect", { x: 13.5, y: 3, width: 4, height: 12, rx: 1 }) ); } -function UpdateButton({ useUpdate, actions }) { - const update = useUpdate((value) => value); - const [open, setOpen] = (0, import_react17.useState)(false); - const [box, setBox] = (0, import_react17.useState)(null); - const [error, setError] = (0, import_react17.useState)(""); - const [dshError, setDshError] = (0, import_react17.useState)(""); - const button = (0, import_react17.useRef)(null); - (0, import_react17.useEffect)(() => { - if (!open) return void 0; - const onDown = (event) => { - if (event.target instanceof Element && (event.target.closest(".bt-update-pop") || button.current?.contains(event.target))) return; - setOpen(false); - }; - const onKey = (event) => { - if (event.key === "Escape") { - event.stopPropagation(); - setOpen(false); - button.current?.focus(); - } - }; - window.addEventListener("mousedown", onDown); - window.addEventListener("keydown", onKey, true); - return () => { - window.removeEventListener("mousedown", onDown); - window.removeEventListener("keydown", onKey, true); - }; - }, [open]); - const phase = update?.phase ?? "idle"; - const dsh = update?.dsh; - const dshPhase = dsh?.phase ?? "idle"; - const dshRow = showsDshRow(dsh, update?.app); - if (!update?.available && !dshRow && phase === "idle" && dshPhase === "idle") return null; - const cloudPhase = phase === "installing" || dshPhase === "installing" ? "installing" : phase !== "idle" ? phase : dshPhase; - const toggle = () => { - const rect = button.current?.getBoundingClientRect(); - if (rect) setBox({ left: Math.max(8, Math.min(rect.left, window.innerWidth - 288)), top: rect.bottom + 6 }); - setOpen((value) => !value); - }; - const start = () => { - setError(""); - void actions.installUpdate().catch((failure) => setError(hostText(failure?.message ?? String(failure)))); - }; - const startDsh = () => { - setDshError(""); - void actions.installDshUpdate().catch((failure) => setDshError(hostText(failure?.message ?? String(failure)))); - }; - const label = phase === "installing" ? t("Updating FlyBot core…") : dshPhase === "installing" ? t("Updating DSH…") : phase === "installed" ? t("FlyBot core {version} is installed. Restart DSH to use it.", { version: update.installed ?? update.latest }) : dshPhase === "installed" ? t("Restart DSH to start using DSH {version}.", { version: dsh.installed ?? dsh.verified }) : update?.available ? t("FlyBot core {version} is available", { version: update.latest }) : t("DSH {version} is available", { version: dsh?.verified }); - const message = error || (phase === "failed" ? update.error : ""); - const dshMessage = dshError || (dshPhase === "failed" ? dsh?.error : ""); - const dshText = dshPhase === "installed" ? t("Restart DSH to start using DSH {version}.", { version: dsh.installed ?? dsh.verified }) : dsh?.available === false && dsh?.blockedBy ? t("Update FlyBot core {version} first.", { version: dsh.blockedBy }) : t("You have {version}.", { version: dsh?.current }); - return (0, import_react17.createElement)( - import_react17.Fragment, - null, - (0, import_react17.createElement)("button", { - ref: button, - type: "button", - className: "bt-round bt-update", - "data-phase": cloudPhase, - "aria-label": label, - title: label, - "aria-haspopup": "dialog", - "aria-expanded": open, - onClick: toggle - }, (0, import_react17.createElement)(CloudDownloadIcon, { done: phase === "installed" }), phase === "failed" || dshPhase === "failed" ? (0, import_react17.createElement)("span", { className: "bt-update-dot", "aria-hidden": true }) : null), - open && box ? (0, import_react_dom4.createPortal)((0, import_react17.createElement)( - "div", - { className: "bt-update-pop", role: "dialog", "aria-label": update?.available || phase !== "idle" ? t("FlyBot core update") : t("DSH update"), style: box }, - update?.available || phase !== "idle" ? (0, import_react17.createElement)( - import_react17.Fragment, - null, - (0, import_react17.createElement)("div", { className: "bt-update-title" }, phase === "installed" ? t("Update installed") : t("FlyBot core {version}", { version: update.latest })), - (0, import_react17.createElement)("div", { className: "bt-update-text" }, phase === "installed" ? t("Restart DSH to start using FlyBot core {version}.", { version: update.installed ?? update.latest }) : t("You have {version}.", { version: update.current })), - message ? (0, import_react17.createElement)("div", { className: "bt-update-error", role: "alert" }, t("The update did not finish: {reason}", { reason: message })) : null, - (0, import_react17.createElement)( - "div", - { className: "bt-update-actions" }, - update.notesUrl ? (0, import_react17.createElement)("a", { className: "bt-update-notes", href: update.notesUrl, target: "_blank", rel: "noopener noreferrer" }, t("What's new")) : null, - phase === "installed" ? null : (0, import_react17.createElement)("button", { - type: "button", - className: "bt-update-go", - disabled: phase === "installing", - onClick: start - }, phase === "installing" ? t("Updating…") : phase === "failed" ? t("Try again") : t("Update")) - ) - ) : null, - dshRow ? (0, import_react17.createElement)( - "div", - { className: "bt-update-dsh" }, - (0, import_react17.createElement)("div", { className: "bt-update-title" }, t("DSH {version}", { version: dsh.verified })), - (0, import_react17.createElement)("div", { className: "bt-update-text" }, dshText), - dshMessage ? (0, import_react17.createElement)("div", { className: "bt-update-error", role: "alert" }, t("The update did not finish: {reason}", { reason: dshMessage })) : null, - (0, import_react17.createElement)( - "div", - { className: "bt-update-actions" }, - dsh.kind === "app" ? dsh.available ? (0, import_react17.createElement)("button", { type: "button", className: "bt-update-go", onClick: () => void actions.openAppUpdates(dsh.verified).catch(() => { - }) }, t("Open updates")) : null : !dsh.available && dshPhase === "idle" ? null : dshPhase === "installed" ? null : (0, import_react17.createElement)("button", { - type: "button", - className: "bt-update-go", - disabled: dshPhase === "installing", - onClick: startDsh - }, dshPhase === "installing" ? t("Updating…") : dshPhase === "failed" ? t("Try again") : t("Update")) - ) - ) : null - ), document.body) : null - ); +function shortId(id) { + const hex = String(id ?? "").replace(/^task-/, ""); + return `#${hex.slice(-6)}`; } -function ConnectApps({ wide, actions }) { - if (!wide) return null; - return (0, import_react17.createElement)( - "span", - { className: "bt-connect-wrap" }, - (0, import_react17.createElement)( - "button", - { type: "button", className: "bt-connect bt-connect-foot", onClick: () => actions.openConnectors() }, - (0, import_react17.createElement)(PlugIcon), - t("Connect apps") - ) - ); +var ARCHIVE_SHOWN = 20; +function boardTasksFor(tasks, archive, limit = ARCHIVE_SHOWN) { + const newest = [...archive ?? []].sort((a, b) => (b.updatedAt ?? 0) - (a.updatedAt ?? 0) || (b.createdAt ?? 0) - (a.createdAt ?? 0)); + return [...tasks ?? [], ...newest.slice(0, limit)]; +} +function boardColumns(tasks) { + const columns = { "not-started": [], "in-progress": [], done: [] }; + for (const task of tasks ?? []) { + const column = columnOf(task); + (columns[column] ?? columns["not-started"]).push(task); + } + for (const list of Object.values(columns)) list.sort((a, b) => (b.updatedAt ?? 0) - (a.updatedAt ?? 0)); + return columns; +} +function columnTree(tasks) { + const ids = new Set((tasks ?? []).map((task) => task.id)); + const childrenOf = /* @__PURE__ */ new Map(); + const nested = /* @__PURE__ */ new Set(); + for (const task of tasks ?? []) { + if (!task.parentId || !ids.has(task.parentId)) continue; + const parent = tasks.find((item) => item.id === task.parentId); + if (parent?.kind !== "group") continue; + const list = childrenOf.get(parent.id) ?? []; + list.push(task); + childrenOf.set(parent.id, list); + nested.add(task.id); + } + const nests = []; + const loose = []; + for (const task of tasks ?? []) { + if (nested.has(task.id)) continue; + if (task.kind === "group") nests.push({ task, children: childrenOf.get(task.id) ?? [] }); + else loose.push(task); + } + return { nests, loose }; } -function YouAvatar() { - return (0, import_react17.createElement)( - "span", - { className: "bt-you-wrap" }, - (0, import_react17.createElement)("span", { className: "bt-you", "aria-hidden": true }, (0, import_react17.createElement)(BotMark, { look: MAIN_LOOK, size: 24 })), - (0, import_react17.createElement)("span", { style: { position: "absolute", width: 1, height: 1, overflow: "hidden", clip: "rect(0 0 0 0)" } }, t("Settings")) - ); +function adminOf(roster, room2) { + const me = roster?.self?.memberId; + if (!me) return room2?.origin !== "face-to-face"; + const human = (room2?.participants ?? []).find((person) => person.kind === "human" && person.id === me && person.leftSeq === void 0); + if (human?.role === "admin") return true; + const admin = roster?.byId?.[room2?.admin]; + return Boolean(admin && !admin.remote && admin.ownerId === me); } -var openShellSettings = null; -var openShellOnboarding = null; -var shellShortcuts = null; -function holdShellShortcuts(shortcuts) { - shellShortcuts = shortcuts; - return () => { - if (shellShortcuts === shortcuts) shellShortcuts = null; +function boardRail(roster) { + const me = roster?.self?.memberId; + const bots = (roster?.bots ?? []).filter((bot) => bot && !bot.remote && (!me || !bot.ownerId || bot.ownerId === me)); + const ids = new Set(bots.map((bot) => bot.id)); + const children = /* @__PURE__ */ new Map(); + for (const bot of bots) { + const parent = bot.createdBy && ids.has(bot.createdBy) ? bot.createdBy : ""; + const list = children.get(parent) ?? []; + list.push(bot); + children.set(parent, list); + } + for (const list of children.values()) list.sort((a, b) => (a.createdAt ?? 0) - (b.createdAt ?? 0) || String(a.name).localeCompare(String(b.name))); + const rows = []; + const walk = (parent, depth) => { + for (const bot of children.get(parent) ?? []) { + rows.push({ kind: "bot", id: bot.id, name: bot.name, depth, bot }); + walk(bot.id, depth + 1); + } }; + walk("", 0); + const groups = (roster?.rooms ?? []).filter((room2) => room2 && room2.hidden !== true && room2.origin !== "face-to-face" && adminOf(roster, room2)).map((room2) => ({ kind: "group", id: room2.id, name: room2.displayName || room2.name || t("Group"), room: room2 })); + return { bots: rows, groups }; } -function openHarnessSettings() { - requestAnimationFrame(() => { - if (openShellSettings) { - openShellSettings(); - return; - } - const desktop = document.querySelector('button[aria-haspopup="menu"][data-signed-out]'); - if (desktop) openThroughMenu(desktop); - else pressSettingsShortcut(); - }); +function canMoveTask(roster, task) { + const me = roster?.self?.memberId; + if (!me) return true; + const bot = roster?.byId?.[task?.ownerBotId]; + return Boolean(bot && !bot.remote && (!bot.ownerId || bot.ownerId === me)); } -function openHarnessOnboarding(id) { - requestAnimationFrame(() => { - if (openShellOnboarding) openShellOnboarding(id); - else openHarnessSettings(); - }); +var TERMINAL_STATUSES = /* @__PURE__ */ new Set(["accepted", "cancelled", "failed"]); +function finishedAt(task) { + const terminal = (task?.actors ?? []).find((entry) => TERMINAL_STATUSES.has(entry?.status)); + if (Number.isFinite(terminal?.at)) return terminal.at; + return Number.isFinite(task?.updatedAt) ? task.updatedAt : void 0; } -var MODELS_STEP_ID = "flybot-models"; -var MODELS_STEP_ORDER = 1e3; -function ModelsSettingsStep({ explicit, complete, openSection }) { - (0, import_react17.useLayoutEffect)(() => { - if (explicit) openSection("models"); - complete(); - }, []); - return null; +function priorityLabel(priority) { + if (priority === "urgent") return t("Highest"); + if (priority === "high") return t("High"); + if (priority === "low") return t("Low"); + return t("Normal"); } -function openHarnessModels() { - openHarnessOnboarding(MODELS_STEP_ID); +function statusLabel(status) { + switch (status) { + case "claimed": + return t("Claimed"); + case "working": + return t("In progress"); + case "blocked": + return t("Blocked"); + case "result-ready": + return t("Result ready"); + case "accepted": + return t("Done"); + case "cancelled": + return t("Cancelled"); + case "failed": + return t("Failed"); + default: + return t("Not started"); + } } -function openThroughMenu(trigger) { - const before = new Set(document.querySelectorAll('[role="menu"]')); - trigger.click(); - let frames = 0; - const look = () => { - const menu = [...document.querySelectorAll('[role="menu"]')].find((node) => !before.has(node)); - const item = menu?.querySelector('button[role="menuitem"]'); - if (item) item.click(); - else if (++frames < 30) requestAnimationFrame(look); - else pressSettingsShortcut(); - }; - requestAnimationFrame(look); +function dropLabel(column) { + if (column === "in-progress") return t("Start task"); + if (column === "done") return t("Complete task"); + return t("Not started"); } -function pressSettingsShortcut() { - const binding = shellShortcuts?.catalog.getSnapshot().find((row) => row.id === "settings.open")?.binding; - if (!binding) return; - const held = new Set(binding.modifiers); - window.dispatchEvent(new KeyboardEvent("keydown", { - code: binding.code, - key: binding.code === "Comma" ? "," : "", - bubbles: true, - cancelable: true, - ctrlKey: held.has("control"), - altKey: held.has("alt"), - shiftKey: held.has("shift"), - metaKey: held.has("meta") - })); +function columnTitle(column) { + if (column === "in-progress") return t("In progress"); + if (column === "done") return t("Done"); + return t("Not started"); } -function AccountLauncher({ seat = true, settingsOpen, openSettings, openOnboarding, actions }) { - const [open, setOpen] = (0, import_react17.useState)(false); - (0, import_react17.useLayoutEffect)(() => { - if (!seat) return void 0; - actions.seatAccount(true); - return () => actions.seatAccount(false); - }, [seat]); - const [box, setBox] = (0, import_react17.useState)(null); - const button = (0, import_react17.useRef)(null); - (0, import_react17.useEffect)(() => { - if (settingsOpen) setOpen(false); - }, [settingsOpen]); - (0, import_react17.useEffect)(() => { - if (!openSettings) return void 0; - openShellSettings = openSettings; - return () => { - if (openShellSettings === openSettings) openShellSettings = null; - }; - }, [openSettings]); - (0, import_react17.useEffect)(() => { - if (!openOnboarding) return void 0; - openShellOnboarding = openOnboarding; - return () => { - if (openShellOnboarding === openOnboarding) openShellOnboarding = null; - }; - }, [openOnboarding]); - (0, import_react17.useEffect)(() => { - if (!open) return void 0; - const onDown = (event) => { - if (event.target instanceof Element && (event.target.closest(".bt-account-menu") || button.current?.contains(event.target))) return; - setOpen(false); - }; - const onKey = (event) => { - if (event.key === "Escape") { - event.stopPropagation(); - setOpen(false); - button.current?.focus(); - } - }; - window.addEventListener("mousedown", onDown); - window.addEventListener("keydown", onKey, true); - return () => { - window.removeEventListener("mousedown", onDown); - window.removeEventListener("keydown", onKey, true); - }; - }, [open]); - const toggle = () => { - const rect = button.current?.getBoundingClientRect(); - if (rect) setBox({ left: rect.left, bottom: window.innerHeight - rect.top + 5 }); - setOpen((value) => !value); - }; - const groups = [ - [ - [t("Usage"), GaugeIcon, () => actions.openSettings("usage")], - [t("Connect apps"), ConnectorIcon, () => actions.openConnectors()], - [t("Plugins"), PlugIcon, () => actions.selectPanel("plugins")], - [t("Scheduled tasks"), ClockIcon, () => actions.openSettings("schedules")], - [t("Settings"), GearIcon, () => actions.openSettings("general")] - ], - [ - [t("Switch to classic Agent mode"), SwitchIcon, () => actions.setSurface("agent")] - ] - ]; - return (0, import_react17.createElement)( - import_react17.Fragment, - null, - (0, import_react17.createElement)( - "button", - { ref: button, type: "button", className: "bt-account", "aria-label": t("Account"), "aria-haspopup": "menu", "aria-expanded": open, onClick: toggle }, - (0, import_react17.createElement)("span", { className: "bt-you", "aria-hidden": true }, (0, import_react17.createElement)(BotMark, { look: MAIN_LOOK, size: 24 })) - ), - open && box ? (0, import_react_dom4.createPortal)((0, import_react17.createElement)( - "div", - { className: "bt-menu bt-menu-up bt-account-menu", role: "menu", "aria-label": t("Account"), style: box }, - (0, import_react17.createElement)(FlyBotAccountHeader, { actions }), - groups.map((group, index) => (0, import_react17.createElement)( - import_react17.Fragment, - { key: index }, - index > 0 ? (0, import_react17.createElement)("div", { className: "bt-menu-sep", role: "separator" }) : null, - group.map(([label, Icon, run, hint]) => (0, import_react17.createElement)("button", { - key: label, - type: "button", - role: "menuitem", - onClick: () => { - setOpen(false); - void Promise.resolve(run()).catch((error) => actions.reportError(error, "menu")); - } - }, (0, import_react17.createElement)(Icon), (0, import_react17.createElement)("span", { className: "bt-menu-label" }, label), hint ? (0, import_react17.createElement)("span", { className: "bt-menu-hint" }, hint) : null)) - )) - ), document.body) : null - ); +function botName(roster, task) { + return roster?.byId?.[task.ownerBotId]?.name || task.ownerBotName || ""; } -function useLandOnMainBot(roster, useSessions, actions) { - const mainView = useSessions((list) => { - const row = Object.values(list.byId).find((session) => (session?.retainedBy?.mainView ?? 0) > 0); - return row ? `${row.id}|${row.blank ? 1 : 0}` : ""; - }); - const lastBot = (0, import_react17.useRef)(null); - (0, import_react17.useEffect)(() => { - if (!roster.ready || !roster.mainBotId) return void 0; - const [id, blank3] = mainView.split("|"); - const bot = id === "" ? void 0 : roster.byId[id]; - if (bot !== void 0) { - lastBot.current = { botId: bot.id, sessionId: id }; - if (currentPart(bot) !== id) actions.landOn(bot.id); - return void 0; - } - if (id !== "" && (roster.roomsById[id] || (actions.isAgentSession?.(id) ?? roster.agentOf[id] !== void 0) || blank3 !== "1")) { - lastBot.current = null; - return void 0; - } - const last = lastBot.current; - const timer = setTimeout(() => actions.landOn(roster.mainBotId, last, { stay: id === "", view: id }), last ? 0 : 400); - return () => clearTimeout(timer); - }, [mainView, roster]); +function lanesOf(tasks, roster) { + const order = []; + const map = /* @__PURE__ */ new Map(); + for (const task of tasks) { + const id = task.ownerBotId ?? ""; + if (!map.has(id)) { + map.set(id, []); + order.push(id); + } + map.get(id).push(task); + } + return order.map((id) => ({ id, name: botName(roster, map.get(id)[0]) || id, tasks: map.get(id) })); } -function FooterAccount(props) { - const seated = props.useUi((value) => value.accountSeated === true); - if (seated) return null; - return (0, import_react17.createElement)("span", { className: "bt-foot-account" }, (0, import_react17.createElement)(AccountLauncher, { ...props, seat: false })); +var upsert = (list, task) => list.some((item) => item.id === task.id) ? list.map((item) => item.id === task.id ? task : item) : [...list, task]; +var without = (list, id) => list.filter((item) => item.id !== id); +function useBoardTasks(actions, scope) { + const [own, setOwn] = (0, import_react16.useState)([]); + const [groupTasks, setGroupTasks] = (0, import_react16.useState)([]); + const [archive, setArchive] = (0, import_react16.useState)([]); + const key = scope?.kind === "group" ? `group:${scope.roomId}` : `bot:${scope.botId ?? ""}`; + (0, import_react16.useEffect)(() => { + let stopped = false; + const load = () => { + const query = scope?.kind === "group" ? { roomId: scope.roomId } : {}; + const archiveQuery = scope?.kind === "bot" ? { botId: scope.botId } : query; + const job = scope?.kind === "group" ? Promise.resolve(actions?.tasks?.(query)) : Promise.resolve(actions?.tasks?.()); + void job.then((list) => { + if (stopped || !Array.isArray(list)) return; + if (scope?.kind === "group") setGroupTasks(list); + else setOwn(list); + }).catch(() => void 0); + void Promise.resolve(actions?.tasks?.({ ...archiveQuery, archive: true, limit: ARCHIVE_SHOWN })).then((list) => { + if (stopped || !Array.isArray(list)) return; + setArchive(list.filter((task) => columnOf(task) === "done")); + }).catch(() => void 0); + }; + load(); + const timer = setInterval(load, POLL_MS4); + return () => { + stopped = true; + clearInterval(timer); + }; + }, [key]); + const tasks = scope?.kind === "group" ? groupTasks : own.filter((task) => !scope?.botId || task.ownerBotId === scope.botId); + const replace = (task) => { + const done2 = columnOf(task) === "done"; + if (scope?.kind === "group") setGroupTasks((list) => done2 ? without(list, task.id) : upsert(list, task)); + else setOwn((list) => done2 ? without(list, task.id) : upsert(list, task)); + setArchive((list) => done2 ? [task, ...without(list, task.id)] : without(list, task.id)); + }; + const done = scope?.kind === "group" ? archive : archive.filter((task) => !scope?.botId || task.ownerBotId === scope.botId); + return { own, groupTasks, tasks, archive: done, replace }; } -function BotReturn({ wide, useSurface, actions }) { - const mode = useSurface((value) => value.mode); - if (mode !== "agent") return null; - return (0, import_react17.createElement)( +function RailButton({ row, selected, meta, onSelect }) { + return (0, import_react16.createElement)( "button", - { type: "button", className: "bt-return", "aria-label": t("FlyBot"), title: t("FlyBot"), onClick: () => actions.setSurface("bot") }, - (0, import_react17.createElement)(FlyBotMark, { size: 20 }), - wide ? (0, import_react17.createElement)("span", { className: "bt-return-name" }, t("FlyBot")) : null + { + type: "button", + className: "bt-board-rail-item", + "data-kind": row.kind, + "data-id": row.id, + "data-selected": selected || void 0, + "aria-current": selected ? "true" : void 0, + style: row.depth ? { paddingLeft: `${10 + row.depth * 14}px` } : void 0, + onClick: () => onSelect(row.kind === "group" ? { kind: "group", roomId: row.id } : { kind: "bot", botId: row.id }) + }, + (0, import_react16.createElement)("span", { className: "bt-board-rail-name" }, row.name), + meta ? (0, import_react16.createElement)("span", { className: "bt-board-rail-meta" }, meta) : null ); } -var RETURN_CSS = ` -.bt-return{display:inline-flex;align-items:center;justify-content:center;gap:8px;min-height:36px;padding:0 14px;border:0;border-radius:999px;background:none;color:inherit;font:inherit;font-size:14px;line-height:20px;cursor:pointer;transition:background-color .12s ease} -.bt-return:hover{background:rgba(127,127,127,.14)} -.bt-return:active{transform:scale(.96)} -.bt-return .bt-mark,.bt-return .bt-flybot-mark{display:inline-flex;flex:none;line-height:0} -.bt-return .bt-mark svg,.bt-return .bt-flybot-mark{width:20px;height:20px;overflow:visible} -/* Shell styles carry --bt-ink-deepseek and Agent mode withdraws them; pin it here. */ -.bt-return .bt-mark{--bt-ink-deepseek:#4D6BFE} -.bt-return-name{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} -`; -function BrandMark({ size = 24 }) { - return (0, import_react17.createElement)("span", { className: "bt-brand-mark" }, (0, import_react17.createElement)(FlyBotMark, { size })); -} -function BrandName() { - return (0, import_react17.createElement)("span", { style: { fontWeight: 600, fontSize: 14 } }, t("FlyBot")); -} -var SIDEBAR_CSS = ` -.bt-side-top .bt-brand{cursor:default;padding-left:6px} -.bt-side-top .bt-brand:hover{background:none} -.bt-brand .bt-mark{flex:none} -.bt-pin{flex:none;display:inline-flex;color:var(--bt-ink-3)} -.bt-rail .bt-row{position:relative} -.bt-rail .bt-pin{position:absolute;top:3px;left:calc(50% + 12px)} -.bt-side .bt-read-only{margin:4px 8px 6px;padding:8px 10px;border-radius:10px;background:var(--bt-hover);font-size:12px;line-height:1.45;text-align:left} -.bt-rail .bt-read-only{width:100%;margin:4px 0 6px;padding:4px 2px;font-size:10px;line-height:1.3;text-align:center;white-space:nowrap;overflow:hidden;text-overflow:ellipsis} -[class*="_footArea"]:has(.bt-foot-account){display:flex!important;flex-direction:row!important;align-items:center;gap:6px} -[class*="_footArea"]:has(.bt-foot-account)>[class*="_footerActions"],[class*="_footArea"]:has(.bt-foot-account)>[class*="_footerActions"]>div{display:contents!important} -.bt-foot-account{order:-1;flex:none;display:inline-flex} -[class*="_footArea"]:has(.bt-foot-account) .bt-connect-foot{order:0;flex:1 1 auto;min-width:0;width:auto} -[class*="_footArea"]:has(.bt-foot-account)>[class*="_settingsArea"]{order:1;flex:none} -[class*="_footArea"]:has(.bt-foot-account)>[class*="_settingsArea"] button[class*="_trigger"] [class*="_label"]{display:none!important} - -/* The update cloud springs in when a newer DS Bot shows up; its arrow drops in a - loop while the install runs. */ -.bt-round.bt-update{position:relative;color:var(--bt-accent,#4D6BFE);animation:bt-update-in ${SPRING_SHAPE.duration}ms ${SPRING_SHAPE.easing}} -.bt-round.bt-update svg{width:17px;height:17px} -.bt-round.bt-update[data-phase=installed]{color:var(--bt-ink-2)} -@keyframes bt-update-in{from{opacity:0;transform:scale(.6);filter:blur(4px)}} -.bt-update[data-phase=installing] .bt-cloud-arrow{animation:bt-cloud-drop 1.1s cubic-bezier(.45,0,.55,1) infinite} -@keyframes bt-cloud-drop{0%{transform:translateY(-3px);opacity:0}35%{opacity:1}70%{transform:translateY(1px);opacity:1}100%{transform:translateY(2px);opacity:0}} -.bt-update-dot{position:absolute;top:6px;right:6px;width:7px;height:7px;border-radius:50%;background:#E5484D;box-shadow:0 0 0 1.5px var(--bt-sidebar)} -.bt-update-pop{position:fixed;z-index:130;width:280px;padding:14px 14px 12px;border-radius:14px;background:var(--bt-main);border:.5px solid var(--bt-line-2);box-shadow:0 10px 20px -3px rgba(0,0,0,.1),0 4px 6px -4px rgba(0,0,0,.1);color:var(--bt-ink);box-sizing:border-box;transform-origin:top left;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",system-ui,sans-serif;animation:bt-update-pop ${SPRING_SHAPE.duration}ms ${SPRING_SHAPE.easing}} -@keyframes bt-update-pop{from{opacity:0;transform:scale(.96);filter:blur(4px)}} -.bt-update-title{font-size:14px;line-height:20px;font-weight:600} -.bt-update-text{margin-top:2px;font-size:12.5px;line-height:18px;color:var(--bt-ink-2)} -.bt-update-error{margin-top:8px;font-size:12px;line-height:17px;color:#c21d2e;overflow-wrap:anywhere;max-height:90px;overflow:auto} -.bt-update-actions{display:flex;align-items:center;justify-content:flex-end;gap:10px;margin-top:12px} -.bt-update-notes{margin-right:auto;font-size:12.5px;color:var(--bt-ink-2);text-decoration:none} -.bt-update-notes:hover{color:var(--bt-ink);text-decoration:underline} -.bt-update-go{height:30px;padding:0 14px;border:0;border-radius:999px;background:var(--bt-accent,#4D6BFE);color:var(--bt-accent-ink,#fff);font:inherit;font-size:13px;font-weight:500;cursor:pointer;transition:opacity .12s ease,transform .12s ease} -.bt-update-go:hover:not(:disabled){opacity:.9} -.bt-update-go:active:not(:disabled){transform:scale(.96)} -.bt-update-go:disabled{opacity:.6;cursor:default} -.bt-update-dsh:not(:first-child){margin-top:12px;padding-top:12px;border-top:.5px solid var(--bt-line-2)} -@media (prefers-reduced-motion:reduce){.bt-round.bt-update,.bt-update-pop,.bt-update .bt-cloud-arrow{animation:none}} - -/* The DSH Agent row and its expanding Session list. The black whale is the - harness's own mark, so the row reads as "plain dsh" next to the Bot marks. */ -.bt-agent-ava{flex:none;width:36px;height:36px;border-radius:50%;display:inline-flex;align-items:center;justify-content:center;background:var(--bt-hover);color:var(--bt-ink)} -.bt-agent-ava.bt-run{animation:bt-agent-pulse 1.4s ease-in-out infinite} -@keyframes bt-agent-pulse{0%,100%{opacity:1}50%{opacity:.55}} -.bt-agent-sub{display:grid;grid-template-rows:0fr;transition:grid-template-rows ${SPRING_SHAPE.duration}ms ${SPRING_SHAPE.easing}} -.bt-agent-sub[data-open]{grid-template-rows:1fr} -.bt-agent-sub-in{overflow:hidden;min-height:0;margin-left:44px} -.bt-agent-sub-row{display:flex;align-items:center;gap:8px;width:100%;padding:5px 10px 5px 8px;border:0;border-radius:8px;background:none;color:var(--bt-ink-2);font:inherit;font-size:13px;line-height:20px;text-align:left;cursor:pointer} -.bt-agent-sub-row:hover{background:var(--bt-hover);color:var(--bt-ink)} -.bt-agent-sub-row[aria-current=page]{background:var(--bt-hover);color:var(--bt-ink);font-weight:500} -.bt-agent-sub-title{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} -.bt-agent-sub-time{flex:none;font-size:11px;color:var(--bt-ink-3)} -.bt-agent-new{color:var(--bt-ink-3)} -.bt-agent-new svg{width:14px;height:14px;flex:none} -.bt-agent-edit{padding:3px 10px 3px 4px} -.bt-agent-edit input{width:100%;border:0;border-radius:6px;background:var(--bt-hover);color:var(--bt-ink);font:inherit;font-size:13px;line-height:20px;padding:2px 8px;outline:none;box-shadow:inset 0 0 0 .5px var(--bt-line-2)} -.bt-agent-sub[data-open] .bt-agent-sub-row{animation:bt-agent-row-in .24s ease both;animation-delay:calc(var(--i, 0)*30ms)} -@keyframes bt-agent-row-in{from{opacity:0;filter:blur(4px)}to{opacity:1;filter:blur(0)}} -@media (prefers-reduced-motion:reduce){.bt-agent-sub{transition:none}.bt-agent-sub .bt-agent-sub-row{animation:none}} - -.bt-row-wrap{position:relative;display:flex;align-items:center;width:100%;min-width:0} -.bt-row-wrap>.bt-row{flex:1 1 auto;min-width:0;width:100%} -.bt-tree-item>.bt-row-wrap{flex:1 1 auto;width:auto;min-width:0} -/* Gear sits over the row so hover/selected fill the same full-width band as a plain row (#384). */ -.bt-row-settings{position:absolute;right:6px;top:50%;transform:translateY(-50%);z-index:1;flex:none;display:inline-flex;align-items:center;justify-content:center;width:28px;height:28px;padding:0;border:0;border-radius:8px;background:transparent;color:var(--bt-ink-3);cursor:pointer;opacity:0;pointer-events:none;transition:background-color .12s ease,color .12s ease,opacity .12s ease} -.bt-row-wrap:hover>.bt-row,.bt-row-wrap:focus-within>.bt-row{padding-right:36px} -.bt-row-wrap:hover .bt-row-settings,.bt-row-wrap:focus-within .bt-row-settings{opacity:1;pointer-events:auto;color:var(--bt-ink-2);background:var(--bt-hover)} -.bt-row-settings:hover{opacity:1;color:var(--bt-ink-2);background:var(--bt-hover)} -.bt-row-settings svg{width:15px;height:15px} -`; - -// src/client/busy-ack-logic.js -function busyAckText(hostAck) { - return typeof hostAck?.text === "string" ? hostAck.text.trim() : ""; +function BoardCard2({ task, roster, movable, onMove, onDrag, partOf: partOf2 }) { + const column = columnOf(task); + const priority = priorityOf(task); + const progress = progressOf(task); + const who = botName(roster, task); + const question = task.waitingFor === "input" ? task.inputRequest?.question : ""; + const overall = task.kind === "group"; + const finished = column === "done" ? finishedAt(task) : void 0; + return (0, import_react16.createElement)(BoardCard, { + card: taskCard(task), + skin: TASK_SKIN, + attrs: { + "data-task": task.id, + "data-column": column, + "data-priority": priority, + "data-kind": overall ? "group" : void 0 + }, + idText: shortId(task.id), + emptyTitle: t("Untitled task"), + topExtra: [ + (0, import_react16.createElement)("span", { key: "priority", className: "bt-board-priority", "data-priority": priority }, priorityLabel(priority)), + (0, import_react16.createElement)("span", { key: "status", className: "bt-board-status" }, statusLabel(task.status)) + ], + note: task.note ?? "", + meta: [ + overall ? (0, import_react16.createElement)("span", { key: "kind", className: "bt-board-kind" }, t("Group task")) : null, + partOf2 ? (0, import_react16.createElement)("span", { key: "part", className: "bt-board-part" }, t("Part of {title}", { title: partOf2 })) : null, + question ? (0, import_react16.createElement)("span", { key: "wait", className: "bt-board-wait" }, question) : null + ], + footer: (0, import_react16.createElement)( + "span", + { className: "bt-board-foot" }, + who ? (0, import_react16.createElement)("span", { className: "bt-board-who" }, who) : null, + Number.isFinite(finished) && finished > 0 ? (0, import_react16.createElement)("span", { key: "finished", className: "bt-board-when" }, relativeShort(finished)) : null, + (0, import_react16.createElement)( + "span", + { className: "bt-board-progress" }, + (0, import_react16.createElement)( + "span", + { className: "bt-board-progress-bar", role: "progressbar", "aria-valuemin": 0, "aria-valuemax": 100, "aria-valuenow": progress, "aria-label": t("Progress") }, + (0, import_react16.createElement)("span", { style: { width: `${progress}%` } }) + ), + (0, import_react16.createElement)("span", { className: "bt-board-percent" }, t("{percent}%", { percent: progress })) + ) + ), + actions: [ + movable && column === "not-started" ? (0, import_react16.createElement)("button", { key: "start", type: "button", className: "bt-soft bt-soft-sm", onClick: () => onMove?.(task.id, "in-progress") }, t("Start")) : null, + movable && column === "in-progress" && task.status !== "blocked" ? (0, import_react16.createElement)("button", { key: "complete", type: "button", className: "bt-soft bt-soft-sm", onClick: () => onMove?.(task.id, "done") }, t("Complete")) : null + ], + draggable: movable, + dragValue: task.id, + onDragStart: () => onDrag?.(task.id), + onDragEnd: () => onDrag?.(null) + }); } -function busyAckPending(hostAck) { - return busyAckText(hostAck) === ""; +function cardProps(task, roster, onMove, onDrag, partOf2, locked) { + return { task, roster, movable: !locked && canMoveTask(roster, task), onMove, onDrag, partOf: partOf2 }; } -function pendingBusyPlacement(submissions) { - if (!Array.isArray(submissions)) return false; - return submissions.some((item) => item?.placement === "queued" || item?.placement === "steering"); +function BoardColumn({ column, tasks, roster, lanes, dragging, onMove, onDrag, archiveFull, locked }) { + const tree = lanes ? columnTree(tasks) : null; + const linked = tree ? tree.loose.filter((task) => task.parentId || task.parentTitle) : []; + const plain = tree ? tree.loose.filter((task) => !task.parentId && !task.parentTitle) : tasks; + return (0, import_react16.createElement)( + "section", + { + className: "bt-board-column", + "data-column": column, + "aria-label": columnTitle(column), + onDragOver: (event) => { + if (locked || !dragging) return; + event.preventDefault(); + }, + onDrop: (event) => { + if (locked) return; + event.preventDefault(); + const id = event.dataTransfer?.getData("text/plain"); + if (id) onMove?.(id, column); + onDrag?.(null); + } + }, + (0, import_react16.createElement)( + "h3", + { className: "bt-board-column-head" }, + (0, import_react16.createElement)("span", null, columnTitle(column)), + // #2045: the Done column is one archive page. `tasks.length` is what was drawn, not what the + // owner has, so a full page says `20 newest` instead of claiming the page is the total. + (0, import_react16.createElement)("span", { className: "bt-board-count" }, column === "done" && archiveFull ? t("{count} newest", { count: tasks.length }) : String(tasks.length)) + ), + dragging ? (0, import_react16.createElement)("div", { className: "bt-board-drop" }, dropLabel(column)) : null, + tasks.length === 0 && !dragging ? (0, import_react16.createElement)("p", { className: "bt-board-empty" }, t("Nothing in this column.")) : null, + tree ? tree.nests.map((nest) => (0, import_react16.createElement)( + "div", + { key: nest.task.id, className: "bt-board-nest" }, + (0, import_react16.createElement)(BoardCard2, cardProps(nest.task, roster, onMove, onDrag, void 0, locked)), + nest.children.map((child) => (0, import_react16.createElement)( + "div", + { key: child.id, className: "bt-board-subtask", "data-parent": nest.task.id }, + (0, import_react16.createElement)(BoardCard2, cardProps(child, roster, onMove, onDrag, void 0, locked)) + )) + )) : null, + linked.map((task) => (0, import_react16.createElement)(BoardCard2, { key: task.id, ...cardProps(task, roster, onMove, onDrag, task.parentTitle || "", locked) })), + lanes ? lanesOf(plain, roster).map((lane) => (0, import_react16.createElement)( + "div", + { key: lane.id, className: "bt-board-lane" }, + (0, import_react16.createElement)("span", { className: "bt-board-lane-name" }, lane.name), + lane.tasks.map((task) => (0, import_react16.createElement)(BoardCard2, { key: task.id, ...cardProps(task, roster, onMove, onDrag, void 0, locked) })) + )) : plain.map((task) => (0, import_react16.createElement)(BoardCard2, { key: task.id, ...cardProps(task, roster, onMove, onDrag, void 0, locked) })) + ); } -function busyAckVisible({ hostAck, pendingBusy, streamingText }) { - if (streamingText) return false; - return busyAckText(hostAck) !== "" || pendingBusy === true; +function BoardView({ roster, tasks, rail, scope, counts: counts2, dragging, error, draft, onDraft, onSelect, onMove, onDrag, onCreate, archiveFull, locked }) { + const columns = boardColumns(tasks); + const lanes = scope?.kind === "group"; + const selectedBot = scope?.kind === "bot" ? scope.botId : ""; + const selectedRoom = scope?.kind === "group" ? scope.roomId : ""; + return (0, import_react16.createElement)( + "div", + { className: "bt-board-body" }, + (0, import_react16.createElement)( + "aside", + { className: "bt-board-rail", "aria-label": t("Task board") }, + (0, import_react16.createElement)("h3", { className: "bt-board-rail-head" }, t("Your Bots")), + rail.bots.length === 0 ? (0, import_react16.createElement)("p", { className: "bt-board-empty" }, t("No tasks yet.")) : rail.bots.map((row) => { + const count = counts2?.[row.id] ?? { done: 0, total: 0 }; + const capped = archiveFull && (scope?.kind === "group" ? count.done > 0 : count.done >= ARCHIVE_SHOWN); + const meta = count.total === 0 ? "" : capped ? t("{count}+ done", { count: count.done }) : t("{done}/{total}", { done: count.done, total: count.total }); + return (0, import_react16.createElement)(RailButton, { + key: row.id, + row, + selected: row.id === selectedBot, + onSelect, + meta + }); + }), + rail.groups.length > 0 ? (0, import_react16.createElement)("h3", { className: "bt-board-rail-head" }, t("Groups you admin")) : null, + rail.groups.map((row) => (0, import_react16.createElement)(RailButton, { key: row.id, row, selected: row.id === selectedRoom, onSelect })) + ), + (0, import_react16.createElement)( + "div", + { className: "bt-board-main" }, + error ? (0, import_react16.createElement)("p", { className: "bt-board-error", role: "alert" }, error) : null, + lanes ? (0, import_react16.createElement)("p", { className: "bt-board-hint" }, t("Assign, claim, and progress live on this board: one overall task, a subtask per part, an assignee, a column, and progress. @ only wakes someone.")) : null, + scope?.kind === "bot" && onCreate && !locked ? (0, import_react16.createElement)( + "form", + { className: "bt-board-add", onSubmit: (event) => { + event.preventDefault(); + onCreate(draft); + } }, + (0, import_react16.createElement)("input", { "aria-label": t("Task title"), placeholder: t("New task"), value: draft ?? "", onChange: (event) => onDraft?.(event.target.value) }), + (0, import_react16.createElement)("button", { type: "submit", className: "bt-soft bt-soft-sm" }, t("Add task")) + ) : null, + (0, import_react16.createElement)( + "div", + { className: "bt-board-columns" }, + ["not-started", "in-progress", "done"].map((column) => (0, import_react16.createElement)(BoardColumn, { + key: column, + column, + tasks: columns[column], + roster, + lanes, + dragging, + onMove, + onDrag, + archiveFull, + locked + })) + ) + ) + ); } -function roomBusyAck(room2) { - const entries2 = Object.entries(room2?.busyAcks ?? {}).filter(([, ack2]) => ack2 !== void 0 && ack2 !== null); - if (entries2.length === 0) return null; - const [botId, ack] = entries2.sort((a, b) => (b[1].at ?? 0) - (a[1].at ?? 0))[0]; - return { botId, text: busyAckText(ack), at: ack.at ?? 0 }; +function KanbanRow({ wide, useUi, actions }) { + const open = useUi ? useUi((value) => Boolean(value.board)) : false; + const label = t("Tasks"); + return (0, import_react16.createElement)( + "button", + { + type: "button", + className: "bt-row bt-board-row", + "aria-label": label, + title: wide ? void 0 : label, + "aria-haspopup": "dialog", + "aria-expanded": open, + onClick: () => actions.openBoard() + }, + (0, import_react16.createElement)("span", { className: "bt-board-mark" }, (0, import_react16.createElement)(BoardIcon)), + wide ? (0, import_react16.createElement)("span", { className: "bt-board-label" }, label) : null + ); } -function roomBusyAckVisible({ ack, pendingBusy }) { - if (ack === null || ack === void 0) return false; - return ack.text !== "" || pendingBusy === true; +function scopeName(roster, scope) { + if (scope?.kind === "group") return roster.roomsById?.[scope.roomId]?.name || t("Group"); + if (scope?.kind === "bot") return roster.byId?.[scope.botId]?.name || ""; + return ""; } - -// src/client/busy-ack.js -var import_react18 = require("react"); -var import_react_dom5 = require("react-dom"); -function useRunningHost2(active) { - const [host, setHost] = (0, import_react18.useState)(null); - (0, import_react18.useEffect)(() => { - if (!active) { - setHost(null); - return void 0; +function KanbanDialog({ roster, actions, leaving = false }) { + const locked = typeof roster?.readOnly === "string" && roster.readOnly.trim() !== ""; + const rail = boardRail(roster); + const [scope, setScope] = (0, import_react16.useState)(() => rail.bots[0] ? { kind: "bot", botId: rail.bots[0].id } : rail.groups[0] ? { kind: "group", roomId: rail.groups[0].id } : null); + const [dragging, setDragging] = (0, import_react16.useState)(null); + const [error, setError] = (0, import_react16.useState)(""); + const [draft, setDraft] = (0, import_react16.useState)(""); + const { own, groupTasks, tasks, archive, replace } = useBoardTasks(actions, scope); + const boardTasks = boardTasksFor(tasks, archive); + const close = (0, import_react16.useCallback)(() => actions.closeBoard(), []); + useEscape(close); + (0, import_react16.useEffect)(() => { + if (typeof window === "undefined") return; + if (!window.location.hash.startsWith("#task=")) return; + const [kind, id] = window.location.hash.slice("#task=".length).split(":"); + window.location.hash = ""; + if (id === void 0 || id === "") return; + if (kind === "bot" && roster.byId?.[id]) setScope({ kind: "bot", botId: id }); + if (kind === "group" && roster.roomsById?.[id]) setScope({ kind: "group", roomId: id }); + }, []); + const archiveFull = Array.isArray(archive) && archive.length >= ARCHIVE_SHOWN; + const counts2 = {}; + for (const task of [...scope?.kind === "group" ? groupTasks : own, ...(archive ?? []).slice(0, ARCHIVE_SHOWN)]) { + const id = task.ownerBotId; + const bucket = counts2[id] ?? (counts2[id] = { done: 0, total: 0 }); + if (task.status === "cancelled" || task.status === "failed") continue; + bucket.total += 1; + if (columnOf(task) === "done") bucket.done += 1; + } + const move = async (id, column) => { + if (locked) return; + const task = tasks.find((item) => item.id === id) ?? own.find((item) => item.id === id); + if (!task || !canMoveTask(roster, task) || columnOf(task) === column) return; + setError(""); + try { + replace(await actions.taskUpdate({ id, column })); + } catch (failure) { + setError(failure?.message ?? String(failure)); } - let frame = 0; - const find = () => { - frame = 0; - const node = document.querySelector(RUNNING_HOST); - setHost((prev) => prev === node ? prev : node); - }; - const observer = new MutationObserver(() => { - if (!frame) frame = requestAnimationFrame(find); - }); - observer.observe(document.querySelector("[data-conversation-scroll]") ?? document.body, { childList: true, subtree: true }); - find(); - return () => { - cancelAnimationFrame(frame); - observer.disconnect(); - }; - }, [active]); - return host; -} -function BusyAckBubble({ sessionId, useRoster, useBusyAcks, useSessionStatus, useSession, useLive }) { - const roster = useRoster((value) => value); - const room2 = roster.roomsById?.[sessionId]; - const memberAck = room2 ? roomBusyAck(room2) : null; - const hostAck = useBusyAcks((map) => map?.[sessionId] ?? null); - const running = useSessionStatus((map) => map.get(sessionId)?.running === true); - const pendingBusy = useSession((snapshot) => pendingBusyPlacement(snapshot?.pendingSubmissions)); - const streamingText = useLive((state) => Object.values(state[sessionId] ?? {}).some((entry) => entry?.text)); - const visible = room2 ? roomBusyAckVisible({ ack: memberAck, pendingBusy }) : running && busyAckVisible({ hostAck, pendingBusy, streamingText }); - const bot = room2 ? roster.byId[memberAck?.botId] : roster.byId[sessionId]; - const host = useRunningHost2(visible); - const shown = (0, import_react18.useRef)(null); - if (visible) shown.current = room2 ? { text: memberAck?.text ?? "", at: memberAck?.at ?? Date.now() } : { text: busyAckText(hostAck), at: hostAck?.at ?? Date.now() }; - const text2 = shown.current?.text ?? ""; - if (!visible || !host || !shown.current || !bot) return null; - return (0, import_react_dom5.createPortal)((0, import_react18.createElement)( + }; + const create = async (title) => { + if (locked) return; + const text2 = String(title ?? "").trim(); + if (!text2 || scope?.kind !== "bot") return; + setError(""); + try { + replace(await actions.taskCreate({ botId: scope.botId, title: text2, column: "not-started" })); + setDraft(""); + } catch (failure) { + setError(failure?.message ?? String(failure)); + } + }; + const name = scopeName(roster, scope); + return (0, import_react16.createElement)( "div", - { - className: "bt-busy-ack", - "data-flybot": "busy-ack", - role: "status", - "aria-live": "polite", - "aria-busy": text2 ? void 0 : "true", - "data-pending": text2 ? void 0 : "" - }, - (0, import_react18.createElement)(BotMark, { bot, size: 28, state: "thinking", live: true }), - (0, import_react18.createElement)( + { className: "bt-mem-layer bt-board-layer", "data-leaving": leaving || void 0 }, + (0, import_react16.createElement)("div", { className: "bt-scrim", onClick: close }), + (0, import_react16.createElement)( "div", - { className: "bt-astack" }, - (0, import_react18.createElement)( + { className: "bt-mem bt-board", role: "dialog", "aria-modal": true, "aria-labelledby": "bt-board-heading" }, + (0, import_react16.createElement)( "div", - { className: "bt-msg" }, - (0, import_react18.createElement)( - "div", - { className: "bt-msg-line" }, - text2 ? (0, import_react18.createElement)("div", { className: "bt-bubble" }, text2) : (0, import_react18.createElement)( - "div", - { className: "bt-bubble bt-busy-dots", "aria-hidden": "true" }, - (0, import_react18.createElement)("span", null), - (0, import_react18.createElement)("span", null), - (0, import_react18.createElement)("span", null) - ) - ) - ) + { className: "bt-mem-head" }, + (0, import_react16.createElement)("h2", { id: "bt-board-heading" }, t("Board")), + (0, import_react16.createElement)("span", { className: "bt-mem-status" }, name), + // #1854: the left sidebar's Projects row is the one entry to a Project board; this header + // used to draw a second door (#1701), so the sidebar row was the only way in for the rest. + (0, import_react16.createElement)("span", { className: "bt-mem-head-gap" }), + (0, import_react16.createElement)("button", { type: "button", className: "bt-icon-btn", "aria-label": t("Close board"), autoFocus: true, onClick: close }, (0, import_react16.createElement)(CloseIcon)) + ), + (0, import_react16.createElement)(BoardView, { + roster, + tasks: boardTasks, + rail, + scope, + counts: counts2, + dragging, + error, + draft, + archiveFull, + locked, + onDraft: setDraft, + onSelect: setScope, + onMove: move, + onDrag: setDragging, + onCreate: create + }) ) - ), host); + ); +} +var KANBAN_CSS = ` +.bt-board-row{min-height:40px;padding:6px 8px;gap:10px} +.bt-board-mark{flex:none;width:36px;height:28px;display:inline-flex;align-items:center;justify-content:center;color:var(--bt-ink-2)} +.bt-board-mark svg{width:18px;height:18px} +.bt-board-label{flex:1 1 auto;min-width:0;font-size:14px;line-height:20px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis} +.bt-mem.bt-board{width:min(1080px,calc(100vw - 32px));height:min(760px,calc(100vh - 48px))} +.bt-board-body{flex:1 1 auto;min-height:0;display:flex;gap:0} +.bt-board-rail{flex:none;width:232px;overflow:auto;border-right:.5px solid var(--bt-line);padding:8px 8px 16px;display:flex;flex-direction:column;gap:2px} +.bt-board-rail-head{margin:10px 8px 4px;font-size:12px;line-height:16px;font-weight:600;color:var(--bt-ink-3)} +.bt-board-rail-item{display:flex;align-items:center;gap:8px;width:100%;min-height:36px;padding:6px 10px;border:0;border-radius:10px;background:transparent;color:var(--bt-ink);text-align:left;cursor:pointer} +.bt-board-rail-item:hover{background:var(--bt-hover)} +.bt-board-rail-item[data-selected]{background:var(--bt-active,var(--bt-hover))} +.bt-board-rail-name{flex:1 1 auto;min-width:0;font-size:13.5px;line-height:18px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis} +.bt-board-rail-meta{flex:none;font-size:12px;line-height:16px;color:var(--bt-ink-3);font-variant-numeric:tabular-nums} +.bt-board-main{flex:1 1 auto;min-width:0;display:flex;flex-direction:column;gap:10px;padding:12px 14px 16px} +.bt-board-add{display:flex;gap:8px} +.bt-board-add input{flex:1 1 auto;min-width:0;height:32px;padding:0 10px;border:.5px solid var(--bt-line-2);border-radius:10px;background:var(--bt-card,var(--bt-main));color:var(--bt-ink);font:inherit} +.bt-board-columns{flex:1 1 auto;min-height:0;display:flex;gap:10px} +.bt-board-column{flex:1 1 0;min-width:0;display:flex;flex-direction:column;gap:8px;padding:8px;border-radius:14px;background:var(--bt-hover);overflow:auto} +.bt-board-column-head{display:flex;align-items:center;gap:6px;margin:0;padding:2px 4px;font-size:13px;line-height:18px;font-weight:600;color:var(--bt-ink-2)} +.bt-board-count{font-weight:500;color:var(--bt-ink-3);font-variant-numeric:tabular-nums} +.bt-board-drop{margin:0;padding:18px 8px;border:1px dashed var(--bt-line-2);border-radius:12px;text-align:center;font-size:13px;line-height:18px;color:var(--bt-ink-3)} +.bt-board-empty{margin:0;padding:8px 4px;font-size:13px;line-height:18px;color:var(--bt-ink-3)} +.bt-board-hint{margin:0;font-size:13px;line-height:18px;color:var(--bt-ink-2)} +.bt-board-error{margin:0;font-size:13px;line-height:18px;color:#c21d2e} +.bt-board-lane{display:flex;flex-direction:column;gap:6px} +.bt-board-lane-name{font-size:12px;line-height:16px;font-weight:600;color:var(--bt-ink-3);padding:0 2px} +.bt-board-nest{display:flex;flex-direction:column;gap:6px} +.bt-board-subtask{margin-left:12px;padding-left:8px;border-left:2px solid var(--bt-line)} +.bt-board-kind{font-size:11px;line-height:16px;font-weight:600;color:var(--bt-ink-2)} +.bt-board-part{font-size:12px;line-height:16px;color:var(--bt-ink-3)} +.bt-board-wait{font-size:12px;line-height:16px;color:var(--bt-ink-2)} +.bt-board-card{display:flex;flex-direction:column;gap:6px;padding:10px;border:.5px solid var(--bt-line-2);border-radius:12px;background:var(--bt-card,var(--bt-main));cursor:grab} +.bt-board-card-top{display:flex;align-items:center;gap:6px;min-width:0} +.bt-board-id{flex:none;font-size:12px;line-height:16px;color:var(--bt-ink-3);font-variant-numeric:tabular-nums} +.bt-board-priority{flex:none;height:18px;padding:0 6px;border-radius:999px;font-size:11px;line-height:18px;font-weight:600} +.bt-board-priority[data-priority=urgent]{background:rgba(224,33,53,.12);color:#c21d2e} +.bt-board-priority[data-priority=high]{background:rgba(217,119,6,.14);color:#b45309} +.bt-board-priority[data-priority=normal]{background:color-mix(in srgb,var(--bt-accent) 14%,transparent);color:color-mix(in srgb,var(--bt-accent) 80%,var(--bt-ink))} +.bt-board-priority[data-priority=low]{background:color-mix(in srgb,var(--bt-green) 16%,transparent);color:color-mix(in srgb,var(--bt-green) 70%,var(--bt-ink))} +.bt-board-status{margin-left:auto;font-size:11.5px;line-height:16px;color:var(--bt-ink-3);white-space:nowrap} +.bt-board-title{font-size:13.5px;line-height:19px;color:var(--bt-ink)} +.bt-board-note{font-size:12px;line-height:16px;color:var(--bt-ink-2)} +.bt-board-foot{display:flex;align-items:center;gap:8px} +.bt-board-who{flex:none;max-width:40%;font-size:12px;line-height:16px;color:var(--bt-ink-2);white-space:nowrap;overflow:hidden;text-overflow:ellipsis} +.bt-board-when{flex:none;font-size:12px;line-height:16px;color:var(--bt-ink-3);white-space:nowrap} +.bt-board-progress{flex:1 1 auto;min-width:0;display:flex;align-items:center;gap:6px} +.bt-board-progress-bar{flex:1 1 auto;height:4px;border-radius:999px;background:var(--bt-line);overflow:hidden} +.bt-board-progress-bar>span{display:block;height:100%;background:var(--bt-accent)} +.bt-board-percent{flex:none;font-size:11.5px;line-height:16px;color:var(--bt-ink-3);font-variant-numeric:tabular-nums} +.bt-board-card .bt-soft{align-self:flex-start} +@media (max-width:800px){ + .bt-mem.bt-board{width:min(100vw,calc(100vw - 16px));height:100%} + .bt-board-body{flex-direction:column} + .bt-board-rail{width:auto;max-height:180px;border-right:0;border-bottom:.5px solid var(--bt-line)} + .bt-board-columns{overflow:auto} + .bt-board-column{min-width:220px} +} +@media (max-width:640px){ + .bt-mem.bt-board{width:100vw;border-radius:0;border:0} + .bt-board-columns{flex-direction:column} + .bt-board-column{min-width:0} } -var BUSY_ACK_CSS = ` -.bt-busy-ack{display:flex;align-items:flex-end;gap:8px;width:100%;margin-top:var(--dsh-chat-flow-gap,12px);margin-bottom:4px} -.bt-busy-ack .bt-astack{align-items:flex-start} -.bt-busy-ack .bt-bubble{opacity:.92} -.bt-busy-dots{display:inline-flex;align-items:center;gap:4px;height:22px} -.bt-busy-dots>span{width:5px;height:5px;border-radius:50%;background:var(--bt-ink-3);animation:bt-busy-blink 1.2s ease-in-out infinite} -.bt-busy-dots>span:nth-child(2){animation-delay:.2s}.bt-busy-dots>span:nth-child(3){animation-delay:.4s} -@keyframes bt-busy-blink{0%,80%,100%{opacity:.25}40%{opacity:1}} -@media (prefers-reduced-motion:reduce){.bt-busy-dots>span{animation:none;opacity:.6}} `; -// src/client/project-board.js -var import_react19 = require("react"); -var PROJECT_BOARD_CSS = ` -/* #1750: the board was a short dialog in the page flow — no stylesheet defines 'bt-overlay', so the - bot tree and workflows stayed legible under its rows. It is a modal now, on the same tier as the - Board and Settings layers, with kanban's own 'bt-scrim' sibling and 'bt-mem' surface. */ -.bt-project-overlay{position:fixed;inset:0;z-index:121;isolation:isolate;display:flex;align-items:center;justify-content:center;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",system-ui,sans-serif} -.bt-mem.bt-project-dialog{width:min(1080px,calc(100vw - 32px));height:min(760px,calc(100vh - 48px))} -.bt-project-body{flex:1 1 auto;min-height:0;overflow:auto} -.bt-project-board{padding:16px 18px} -.bt-project-group{margin:0 0 14px} -.bt-project-group h4{margin:0 0 6px;font-size:12px;letter-spacing:.04em;text-transform:uppercase;opacity:.7} -.bt-project-row{display:flex;gap:10px;align-items:center;padding:8px 10px;border-radius:8px;background:var(--bt-card,rgba(127,127,127,.08));margin:0 0 6px;cursor:default} -.bt-project-row[data-column="done"]{opacity:.7} -.bt-project-title-wrap{display:flex;flex-direction:column;gap:2px;min-width:0} -.bt-project-audit{font-size:11px;line-height:1.45;white-space:pre-wrap;opacity:.65;font-family:var(--bt-mono,ui-monospace,monospace)} -.bt-project-row .bt-project-ref{margin-left:auto;font-size:11px;opacity:.6} -.bt-project-empty{padding:22px 4px;opacity:.7} -/* #1767: the record beside the cards. The same surface as a row, one step quieter, and no new - component language: a heading, a list, and a monospace hash the reader can compare. */ -.bt-project-record{margin:18px 0 0;border-top:1px solid var(--bt-line,rgba(127,127,127,.22));padding:12px 0 0} -.bt-project-record h4{margin:0 0 6px;font-size:12px;letter-spacing:.04em;text-transform:uppercase;opacity:.7} -.bt-project-record h5{margin:10px 0 4px;font-size:11px;letter-spacing:.04em;text-transform:uppercase;opacity:.55} -.bt-project-decision,.bt-project-meeting{display:flex;gap:10px;align-items:baseline;padding:6px 10px;border-radius:8px;background:var(--bt-card,rgba(127,127,127,.08));margin:0 0 6px} -.bt-project-decision .bt-project-date,.bt-project-meeting .bt-project-date{font-size:11px;opacity:.6;min-width:76px;font-variant-numeric:tabular-nums} -.bt-project-decision .bt-project-hash{margin-left:auto;font-size:11px;opacity:.55;font-family:var(--bt-mono,ui-monospace,monospace)} -.bt-project-decision .bt-project-title,.bt-project-meeting .bt-project-title{min-width:0} -/* #2041: the decision row became a button, so it keeps the row's look and drops the control's. */ -button.bt-project-decision{width:100%;font:inherit;color:inherit;text-align:left;border:0;cursor:pointer} -button.bt-project-decision:hover{background:var(--bt-card-hover,rgba(127,127,127,.16))} -button.bt-project-decision:focus-visible{outline:2px solid var(--bt-accent,#4c8dff);outline-offset:1px} -button.bt-project-decision[data-open="true"]{outline:1px solid var(--bt-accent,#4c8dff)} -.bt-project-decision-wrap{margin:0} -.bt-project-decision-text{margin:0 0 8px;padding:8px 10px;border-radius:8px;background:var(--bt-card,rgba(127,127,127,.08));white-space:pre-wrap;overflow-wrap:anywhere;max-height:320px;overflow:auto;font-size:12px;font-family:var(--bt-mono,ui-monospace,monospace)} -.bt-project-decision-refusal{opacity:.75} -.bt-project-path{font-size:11px;opacity:.6;font-family:var(--bt-mono,ui-monospace,monospace)} -.bt-project-missing{font-size:11px;opacity:.6} -.bt-project-status{font-size:11px;text-transform:uppercase;letter-spacing:.04em;opacity:.6;margin-left:auto} -/* #1831: the card's level, the people it is addressed to, and the identity a row or a comment - carries. A severe card is the only one painted louder, because it is the only one that waits. */ -.bt-project-level{font-size:10px;text-transform:uppercase;letter-spacing:.06em;padding:1px 6px;border-radius:999px;border:1px solid var(--bt-line,rgba(127,127,127,.32));opacity:.75} -.bt-project-level[data-level="severe"]{opacity:1;border-color:#c2410c;color:#c2410c} -.bt-project-recipients{font-size:11px;opacity:.65} -.bt-project-paused{font-size:11px;opacity:.75;color:#c2410c} -.bt-project-holder{font-size:11px;opacity:.7} -.bt-project-speaks-for{font-size:11px;opacity:.7;font-style:italic} -.bt-project-actions{display:flex;gap:8px;margin:0 0 14px} -.bt-project-metrics{margin:0 0 14px} -.bt-project-metric-cards{display:grid;grid-template-columns:repeat(auto-fit,minmax(132px,1fr));gap:8px} -.bt-project-metric{display:flex;flex-direction:column;gap:2px;padding:8px 10px;border:1px solid rgba(127,127,127,.24);border-radius:8px} -.bt-project-metric-value{font-size:18px;font-weight:600} -.bt-project-metric-label{font-size:12px;font-weight:600} -.bt-project-metric-caption{font-size:10px;opacity:.6;line-height:1.3} -.bt-project-metrics-note{margin:0 0 6px;font-size:11px;opacity:.7} -.bt-project-running{margin:0 0 14px} -.bt-project-running-head,.bt-project-running-row{display:grid;grid-template-columns:2fr 1fr 1fr .8fr .8fr 1.2fr;gap:8px;font-size:12px;padding:3px 0} -.bt-project-running-head{opacity:.6;font-size:10px;letter-spacing:.03em} -.bt-project-running-row[role="button"]{cursor:pointer} -.bt-project-running-row[data-overdue="true"]{color:#c2410c} -.bt-project-counts{display:flex;gap:14px;align-items:center;margin:0 0 12px;font-size:12px;letter-spacing:.03em;opacity:.8} -.bt-project-counts b{font-weight:600} -.bt-project-group[data-queue="waiting"] .bt-project-row{cursor:default} -.bt-project-role{font-size:11px;opacity:.65;font-family:var(--bt-mono,ui-monospace,monospace)} -.bt-project-wait{font-size:11px;opacity:.65;margin-left:auto} -.bt-project-state{font-size:11px;margin-left:auto} -.bt-project-row[data-state="stalled"]{border-left:2px solid #d9822b} -.bt-project-reason{font-size:11px;opacity:.7} - -/* #1855: the finished cards are one group of their own, closed until the reader opens it — a - Project with months of history reads as the work in flight, not as its archive. */ -.bt-project-done{margin:18px 0 0;border-top:1px solid var(--bt-line,rgba(127,127,127,.22));padding:12px 0 0} -.bt-project-done>summary{cursor:pointer;font-size:12px;letter-spacing:.04em;text-transform:uppercase;opacity:.7;list-style:none} -.bt-project-done>summary::-webkit-details-marker{display:none} -.bt-project-done>summary::before{content:'\\25B8';display:inline-block;width:12px;opacity:.6} -.bt-project-done[open]>summary::before{content:'\\25BE'} -.bt-project-done[open]>summary{margin:0 0 8px} - -/* #1749: management panels. Same surface as the board, one form row per control, and a status - line that quotes the host's own answer — a refused save reads where the reader pressed it. */ -.bt-project-panel{padding:16px 18px;display:flex;flex-direction:column;gap:12px} -.bt-project-panel h3{margin:0} -.bt-project-field{display:flex;flex-direction:column;gap:4px} -.bt-project-field label{font-size:12px;letter-spacing:.04em;text-transform:uppercase;opacity:.7} -.bt-project-field input,.bt-project-field select,.bt-project-field textarea{font:inherit;padding:6px 8px;border-radius:8px;border:1px solid var(--bt-line,rgba(127,127,127,.32));background:var(--bt-card,rgba(127,127,127,.08));color:inherit} -.bt-project-field textarea{min-height:88px;resize:vertical} -/* #1887: the counter under the memory box. It stays quiet until the text passes the bound, and - past it turns the one colour that says so; the disabled Save is the state a reader acts on. */ -.bt-project-count{font-size:11px;opacity:.6;font-variant-numeric:tabular-nums} -.bt-project-count[data-over="true"]{opacity:1;color:#c2410c} -.bt-project-row-actions{display:flex;gap:8px;align-items:center} -.bt-project-members{list-style:none;margin:0;padding:0;display:flex;flex-direction:column;gap:6px} -.bt-project-member{display:flex;gap:10px;align-items:baseline;padding:8px 10px;border-radius:8px;background:var(--bt-card,rgba(127,127,127,.08))} -.bt-project-member .bt-project-member-kind{margin-left:auto;font-size:11px;opacity:.6} -.bt-project-status-line{font-size:12px;opacity:.75;min-height:16px} -.bt-project-note{font-size:12px;opacity:.65;margin:0} - -/* #1860: the Project team — who runs the Project, who speaks for the reader, and the role pool. - One quiet block above the cards, and a Bot row is a button because a reader is looking a - person up. The pool row keeps the rank it plays and the ranks the host said it may switch to. */ -.bt-project-team{margin:0 0 14px} -.bt-project-team h4{margin:0 0 6px;font-size:12px;letter-spacing:.04em;text-transform:uppercase;opacity:.7} -.bt-project-team-row{display:flex;gap:10px;align-items:baseline;flex-wrap:wrap;padding:8px 10px;border-radius:8px;background:var(--bt-card,rgba(127,127,127,.08));margin:0 0 6px} -.bt-project-team-row[data-open-bot]{cursor:pointer} -.bt-project-team-row[data-open-bot]:hover{background:var(--bt-hover,rgba(127,127,127,.14))} -.bt-project-team-label{font-size:12px;opacity:.7;min-width:96px} -.bt-project-team-bot{font-size:12px;font-family:var(--bt-mono,ui-monospace,monospace)} -.bt-project-team-roles{display:flex;gap:10px;flex-wrap:wrap;font-size:11px;opacity:.75} -.bt-project-team-role{font-family:var(--bt-mono,ui-monospace,monospace)} -.bt-project-team-myrole{margin-left:auto;font-size:11px;opacity:.65} +// src/client/role-labels.js +var ROLE_LABELS = { owner: "Owner", maintainer: "Maintainer", member: "Member" }; +function roleText(role) { + if (role === null || role === void 0 || role === "") return ""; + return t(ROLE_LABELS[role] ?? String(role)); +} -/* #1864: the team dossier. A Dossier control on each team row opens one read-only panel; the - owner's own chat header carries the same entry to the Project assistant's chat. */ -.bt-project-team-dossier{margin-left:auto;font-size:11px;padding:1px 8px;border-radius:999px;border:1px solid var(--bt-line,rgba(127,127,127,.32));background:transparent;color:inherit;cursor:pointer} -.bt-project-team-dossier:hover{background:var(--bt-hover,rgba(127,127,127,.14))} -.bt-project-dossier{padding:16px 18px;display:flex;flex-direction:column;gap:12px} -.bt-project-dossier-section{display:flex;flex-direction:column;gap:4px} -.bt-project-dossier-section h4{margin:0;font-size:12px;letter-spacing:.04em;text-transform:uppercase;opacity:.7} -.bt-project-dossier-text{margin:0;font-size:13px;line-height:1.5;white-space:pre-wrap;overflow-wrap:anywhere} -.bt-project-dossier-meta{display:flex;gap:12px;flex-wrap:wrap;font-size:11px;opacity:.7} -.bt-project-dossier-topics{display:flex;gap:6px;flex-wrap:wrap} -.bt-project-dossier-topic{font-size:11px;padding:1px 6px;border-radius:999px;background:var(--bt-card,rgba(127,127,127,.08))} -.bt-project-agent-entry{display:inline-flex;align-items:center;gap:4px;font-size:11px;padding:1px 8px;border-radius:999px;border:1px solid var(--bt-line,rgba(127,127,127,.32));background:transparent;color:inherit;cursor:pointer} -.bt-project-agent-entry:hover{background:var(--bt-hover,rgba(127,127,127,.14))} +// src/client/projects-nav.js +var import_react17 = require("react"); +var import_react_dom3 = require("react-dom"); +var PROJECTS_NAV_CSS = ` +.bt-projects-row { display: flex; align-items: center; gap: 8px; } +.bt-projects-chevron { margin-left: auto; display: inline-flex; transition: transform 120ms ease; } +.bt-projects-row[aria-expanded="true"] .bt-projects-chevron { transform: rotate(90deg); } +.bt-projects-list { display: flex; flex-direction: column; margin: 0 0 4px; padding-left: 30px; } +.bt-project-entry { display: flex; align-items: baseline; gap: 8px; width: 100%; text-align: left; border: 0; background: none; color: inherit; font: inherit; padding: 4px 8px; border-radius: 6px; cursor: pointer; } +.bt-project-entry:hover, .bt-project-entry:focus-visible { background: var(--bt-hover, rgba(127,127,127,.14)); outline: none; } +.bt-project-entry[aria-current="true"] { background: var(--bt-hover, rgba(127,127,127,.14)); } +.bt-project-entry-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.bt-project-entry-role { margin-left: auto; opacity: .62; font-size: .85em; white-space: nowrap; } +.bt-projects-empty, .bt-projects-note { padding: 2px 8px 6px; opacity: .7; font-size: .85em; } +.bt-project-menu { position: fixed; z-index: 60; min-width: 168px; padding: 4px; border-radius: 8px; background: var(--bt-menu-bg, #1f2126); border: 1px solid var(--bt-menu-border, rgba(255,255,255,.12)); box-shadow: 0 8px 24px rgba(0,0,0,.32); } +.bt-project-menu-item { display: flex; align-items: center; gap: 8px; width: 100%; border: 0; background: none; color: inherit; font: inherit; text-align: left; padding: 6px 8px; border-radius: 6px; cursor: pointer; } +.bt-project-menu-item:hover, .bt-project-menu-item:focus-visible { background: var(--bt-hover, rgba(127,127,127,.14)); outline: none; } +.bt-project-menu-item[aria-checked="true"]::before { content: "✓"; margin-right: 2px; } +.bt-project-menu-item:not([aria-checked="true"])::before { content: " "; margin-right: 2px; } +.bt-project-menu-item[disabled] { opacity: .6; cursor: default; } `; -var PROJECT_SOURCES = ["bot", "group", "workflow"]; -var SOURCE_LABELS = { bot: "Bots", group: "Groups", workflow: "Workflows" }; -var text = (value) => typeof value === "string" ? value.trim() : ""; -var MEMORY_TEXT_MAX = 600; -var memoryLength = (value) => [...text(value)].length; -var DONE_COLUMN = "done"; -var boardOfRow = (row, task) => { - const stated = row?.board; - if (stated !== null && typeof stated === "object") { - const id = text(stated.id); - if (id !== "" && (stated.kind === "group" || stated.kind === "bot")) return { kind: stated.kind, id }; - } - if (task === void 0) return null; - if (row.source === "group") { - const id = text(task.origin?.roomId); - return id === "" ? null : { kind: "group", id }; - } - if (row.source === "bot") { - const id = text(task.ownerBotId); - return id === "" ? null : { kind: "bot", id }; +function projectHash(id) { + return `#project=${encodeURIComponent(String(id ?? ""))}`; +} +function projectName(project) { + const name = typeof project?.name === "string" ? project.name.trim() : ""; + return name === "" ? `#${project?.id ?? ""}` : name; +} +function projectMenuItems(project) { + const current = project?.role ?? null; + const choices = Array.isArray(project?.choices) ? project.choices : []; + const items = current === null ? [] : [{ role: current, label: roleText(current), current: true, pick: false }]; + for (const role of choices) { + if (role === current) continue; + items.push({ role, label: roleText(role), current: false, pick: true }); } - return null; -}; -var decisionOf = (file) => { - if (file === null || typeof file !== "object") return null; - const path = text(file.path); - if (path === "") return null; - return { - key: `decision:${path}`, - path, - name: text(file.name) || path.slice(path.lastIndexOf("/") + 1), - date: text(file.date), - title: text(file.title), - sha256: text(file.sha256), - shortHash: text(file.sha256).slice(0, 12) - }; -}; -var meetingOf = (meeting, status) => { - if (meeting === null || typeof meeting !== "object") return null; - const id = text(meeting.id); - if (id === "") return null; - return { - key: `meeting:${id}`, - id, - date: text(meeting.date), - question: text(meeting.question) || text(meeting.title), - owner: text(meeting.owner) || null, - status, - path: text(meeting.path), - blockingQuestion: text(meeting.blockingQuestion) || null, - // #1831: the level and recipients the host answered, and the refusal to advance a severe card. - // Nothing is inferred here: a record written before the slice carries an ordinary card and no - // recipients, exactly as the host reads it. - level: text(meeting.level) || "ordinary", - recipients: Array.isArray(meeting.recipients) ? meeting.recipients.map((item) => text(item)).filter((name) => name !== "") : [], - advance: meeting.advance !== null && typeof meeting.advance === "object" ? { allowed: meeting.advance.allowed === true, reason: text(meeting.advance.reason) || null } : { allowed: true, reason: null }, - // #1861: the host's participants with who each position speaks for, so the row can say a - // Project Chief or an agent held it for someone rather than for themselves. - participants: Array.isArray(meeting.participants) ? meeting.participants.map((participant) => ({ name: text(participant?.name), speaksFor: text(participant?.speaksFor) || null })).filter((participant) => participant.name !== "") : [] - }; -}; -var listOf = (value) => Array.isArray(value) ? value.map((item) => decisionOf(item)).filter((item) => item !== null) : []; -function projectBoardRecord({ decisions, meetings } = {}) { - const dir = text(decisions?.dir) || null; - const files = listOf(decisions?.files); - const decided = Array.isArray(meetings?.decided) ? meetings.decided.map((item) => meetingOf(item, "decided")).filter((item) => item !== null) : []; - const pending = Array.isArray(meetings?.pending) ? meetings.pending.map((item) => meetingOf(item, item?.status ?? "deliberating")).filter((item) => item !== null) : []; - const totalOf = (value) => typeof value === "number" && Number.isFinite(value) ? value : null; - const hiddenOf = (total, rows) => total === null ? 0 : Math.max(0, total - rows); - const decisionsTotal = totalOf(decisions?.total); - const decidedTotal = totalOf(meetings?.decidedTotal); - const pendingTotal = totalOf(meetings?.pendingTotal); - return { - decisions: { - dir, - files, - count: files.length, - total: decisionsTotal, - hidden: hiddenOf(decisionsTotal, files.length), - empty: files.length === 0, - emptyText: text(decisions?.reason) || (dir === null ? "This Project names no working folder." : `No decision file in ${dir}/decisions.`) + return items; +} +function roleNote(answer) { + if (answer?.changed === true) return t("Role changed."); + const reason = typeof answer?.reason === "string" && answer.reason !== "" ? answer.reason : t("refused"); + return t("Role unchanged: {reason}", { reason }); +} +function ProjectList({ projects, error, note, onOpen, onRole }) { + const rows = projects === null ? (0, import_react17.createElement)("div", { className: "bt-projects-note" }, t("Reading Projects…")) : error !== "" ? (0, import_react17.createElement)("div", { className: "bt-projects-note" }, error) : projects.length === 0 ? (0, import_react17.createElement)("div", { className: "bt-projects-empty" }, t("No Projects yet")) : projects.map((project) => (0, import_react17.createElement)( + "button", + { + key: String(project.id), + type: "button", + className: "bt-project-entry", + "data-project-id": String(project.id), + "aria-current": project.current === true ? "true" : void 0, + title: roleText(project.role) === "" ? projectName(project) : `${projectName(project)} · ${roleText(project.role)}`, + onClick: () => onOpen?.(project.id), + onContextMenu: (event) => { + event.preventDefault(); + onRole?.(project, event); + } }, - meetings: { - decided, - pending, - count: decided.length + pending.length, - decidedTotal, - pendingTotal, - decidedHidden: hiddenOf(decidedTotal, decided.length), - pendingHidden: hiddenOf(pendingTotal, pending.length), - empty: decided.length === 0 && pending.length === 0, - emptyText: text(meetings?.reason) || "No meeting of this Project is recorded." + (0, import_react17.createElement)("span", { className: "bt-project-entry-name" }, projectName(project)), + roleText(project.role) === "" ? null : (0, import_react17.createElement)("span", { className: "bt-project-entry-role" }, roleText(project.role)) + )); + return (0, import_react17.createElement)( + "div", + { className: "bt-projects-list", role: "group", "aria-label": t("Projects") }, + rows, + note === "" || note === void 0 ? null : (0, import_react17.createElement)("div", { className: "bt-projects-note", role: "status" }, note) + ); +} +function Chevron() { + return (0, import_react17.createElement)( + "svg", + { width: 12, height: 12, viewBox: "0 0 20 20", fill: "none", stroke: "currentColor", strokeWidth: 2, strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": true }, + (0, import_react17.createElement)("path", { d: "M7.5 4.5L13 10l-5.5 5.5" }) + ); +} +function ProjectsMark() { + return (0, import_react17.createElement)( + "svg", + { width: 18, height: 18, viewBox: "0 0 20 20", fill: "none", stroke: "currentColor", strokeWidth: 1.6, strokeLinejoin: "round", "aria-hidden": true }, + (0, import_react17.createElement)("path", { d: "M2.5 5.5h6l1.5 2h7.5v8a1 1 0 0 1-1 1h-13a1 1 0 0 1-1-1z" }), + (0, import_react17.createElement)("path", { d: "M2.5 5.5v-1a1 1 0 0 1 1-1h4l1.5 2" }) + ); +} +function ProjectRoleMenu({ project, x, y, onPick, onClose }) { + const ref = (0, import_react17.useRef)(null); + (0, import_react17.useEffect)(() => { + const onDown = (event) => { + if (!ref.current?.contains(event.target)) onClose(); + }; + const onKey = (event) => { + if (event.key === "Escape") onClose(); + }; + window.addEventListener("mousedown", onDown, true); + window.addEventListener("keydown", onKey, true); + return () => { + window.removeEventListener("mousedown", onDown, true); + window.removeEventListener("keydown", onKey, true); + }; + }, [onClose]); + const items = projectMenuItems(project); + return (0, import_react_dom3.createPortal)((0, import_react17.createElement)( + "div", + { + ref, + className: "bt-project-menu", + role: "menu", + "aria-label": t("My role"), + style: { left: `${x}px`, top: `${y}px` }, + "data-project-id": String(project?.id ?? "") + }, + (0, import_react17.createElement)("div", { className: "bt-projects-note" }, projectName(project)), + items.length === 0 ? (0, import_react17.createElement)("div", { className: "bt-projects-note" }, t("No role to switch here.")) : items.map((item) => (0, import_react17.createElement)("button", { + key: item.role, + type: "button", + role: "menuitemradio", + className: "bt-project-menu-item", + "aria-checked": item.current ? "true" : "false", + disabled: !item.pick, + onClick: () => { + if (item.pick) onPick(item.role); + else onClose(); + } + }, item.label)) + ), document.body); +} +function ProjectsNav({ wide = true, request, onOpen } = {}) { + const read = request ?? call; + const open = (0, import_react17.useCallback)((id) => { + if (onOpen) { + onOpen(id); + return; + } + if (typeof window !== "undefined") window.location.hash = projectHash(id); + }, [onOpen]); + const [expanded, setExpanded] = (0, import_react17.useState)(false); + const [projects, setProjects] = (0, import_react17.useState)(null); + const [error, setError] = (0, import_react17.useState)(""); + const [note, setNote] = (0, import_react17.useState)(""); + const [menu, setMenu] = (0, import_react17.useState)(null); + const load = (0, import_react17.useCallback)(async () => { + try { + const listing = await read("projects"); + const list = Array.isArray(listing?.projects) ? listing.projects : []; + setProjects(list); + setError(""); + } catch (failure) { + setProjects([]); + setError(failure?.message ?? String(failure)); + } + }, [read]); + (0, import_react17.useEffect)(() => { + if (!expanded) return; + void load(); + }, [expanded, load]); + const toggle = () => { + setNote(""); + if (!wide) { + void open(""); + return; } + setExpanded((value) => !value); }; + const listed = expanded && wide; + return (0, import_react17.createElement)( + "div", + { className: "bt-projects" }, + (0, import_react17.createElement)( + "button", + { + type: "button", + className: "bt-row bt-projects-row", + "aria-expanded": listed ? "true" : "false", + "aria-label": t("Projects"), + title: wide ? void 0 : t("Projects"), + onClick: toggle + }, + (0, import_react17.createElement)("span", { className: "bt-projects-mark" }, (0, import_react17.createElement)(ProjectsMark)), + wide ? (0, import_react17.createElement)("span", { className: "bt-projects-label" }, t("Projects")) : null, + wide ? (0, import_react17.createElement)("span", { className: "bt-projects-chevron", "aria-hidden": true }, (0, import_react17.createElement)(Chevron)) : null + ), + listed ? (0, import_react17.createElement)(ProjectList, { projects, error, note, onOpen: open, onRole: (project, event) => setMenu({ project, x: event.clientX, y: event.clientY }) }) : null, + menu === null ? null : (0, import_react17.createElement)(ProjectRoleMenu, { + project: menu.project, + x: menu.x, + y: menu.y, + onClose: () => setMenu(null), + onPick: async (role) => { + const project = menu.project; + setMenu(null); + try { + const answer = await read("project-role", { id: project.id, role }); + setNote(roleNote(answer)); + if (answer?.changed === true) void load(); + } catch (failure) { + setNote(failure?.message ?? String(failure)); + } + } + }) + ); } -function projectBoardView({ project, rows, done, canManage, tasks, decisions, meetings, queues, metrics } = {}) { - const isRow = (row) => row !== null && typeof row === "object" && text(row.id) !== ""; - const byId = new Map((Array.isArray(tasks) ? tasks : []).filter((task) => task !== null && typeof task === "object" && text(task.id) !== "").map((task) => [text(task.id), task])); - const list = []; - const archived = []; - const seen = /* @__PURE__ */ new Set(); - for (const row of [...Array.isArray(done) ? done : [], ...Array.isArray(rows) ? rows : []]) { - if (!isRow(row)) continue; - const id = text(row.id); - if (seen.has(id)) continue; - seen.add(id); - const column = text(row.column) || "not-started"; - (column === DONE_COLUMN ? archived : list).push(row); - } - const projectRow = (row) => { - const task = byId.get(text(row.id)); - return { - id: text(row.id), - key: `task:${text(row.id)}`, - column: text(row.column) || "not-started", - assignee: text(row.assignee) || null, - runId: text(row.runId) || null, - nodeId: text(row.nodeId) || null, - title: text(row.title) || text(task?.title) || text(task?.note) || null, - priority: text(row.priority) || text(task?.priority) || null, - audit: text(row.audit) || null, - // #1831: who is doing the card, and who is speaking for whom. Both are host truth; - // a card carrying neither renders exactly as before. - holder: text(row.holder) || null, - speaksFor: text(row.speaksFor) || null, - board: boardOfRow(row, task) - }; + +// src/client/sidebar.js +var import_react18 = require("react"); +var import_react_dom4 = require("react-dom"); +var import_dsh_client_ui_primitives2 = require("@deepseek-ai/dsh-client-ui-primitives"); +function useMarkedUnread(id, active, marked, actions) { + (0, import_react18.useEffect)(() => { + if (active && marked) actions.clearUnread(id); + }, [active, marked]); +} +var PinMark = () => (0, import_react18.createElement)("span", { className: "bt-pin", "aria-hidden": true }, (0, import_react18.createElement)(PinMarkIcon)); +var useApproval = (useWork, match) => useWork ? useWork((work) => (work.approvals ?? []).some(match)) : false; +var useGroupWork = (useWork, pick2) => useWork ? useWork((work) => pick2(work.rooms ?? {}).join(",")) : ""; +var workKeys = (text2) => text2 === "" ? [] : text2.split(",").map((key) => { + const [id, tool] = key.split("|"); + return { id, tool: tool || void 0 }; +}); +var workLine = (lead, tool, more) => (0, import_react18.createElement)(Shimmer, null, `${lead} · ${workPhrase(tool)}…${more > 0 ? ` +${more}` : ""}`); +function BotRow({ bot, roster, actions, useSessions, useSessionStatus, useSessionRetainInfo, useActivity, useWork, wide, marked, asRow = false }) { + const id = bot.id; + const part = currentPart(bot); + const preview = useSessions((list) => nameMentions(previewOf(list, part, roster.exchangeTurns[part], unsentReplyTurns(bot, part), botNames(roster)), roster)); + const running = useSessionStatus((map) => map.get(part)?.running === true); + const active = useSessionRetainInfo(part, (info) => (info?.retainedBy?.mainView ?? 0) > 0); + const unread = useSessionStatus((map) => map.get(part)?.completionUnread === true || map.get(part)?.pendingInteraction !== void 0) || marked && !active; + useMarkedUnread(id, active, marked, actions); + const main = isMainOf(roster, id); + const tile = roster.mainBotId === id && !asRow; + const approval = useApproval(useWork, (entry) => entry.botId === id); + const waiting = approval || (bot.pendingCount ?? 0) > 0; + const alert = roster.questions?.[part] !== void 0 || waiting; + const doing = useActivity((map) => map[id]); + const groups = workKeys(useGroupWork(useWork, (rooms) => Object.entries(rooms).flatMap(([roomId, entries2]) => entries2.filter((entry) => entry.botId === id).map((entry) => `${roomId}|${entry.tool ?? ""}`)))); + const away = !running && groups.length > 0; + const state = botState(running || away, doing, alert); + const shownAway = away ? awayGroups(groups, roster.roomsById) : []; + const awayLine = shownAway.length > 0 ? workLine(roster.roomsById[shownAway[0].id]?.name ?? t("a group chat"), shownAway[0].tool, shownAway.length - 1) : null; + if (!isShownRemoteChat(bot) && !active && !main) return null; + const onContextMenu = (event) => { + event.preventDefault(); + actions.openMenu({ kind: "bot", id, x: event.clientX, y: event.clientY }); }; - const groups = PROJECT_SOURCES.map((source) => ({ - source, - label: SOURCE_LABELS[source], - rows: list.filter((row) => row.source === source).map(projectRow) - })).filter((group) => group.rows.length > 0); - const countOf = (value) => value === void 0 || value === null ? null : value; - const queueItems = (items, annotate) => (Array.isArray(items) ? items : []).filter((item) => item !== null && typeof item === "object" && text(item.id) !== "").map((item) => { - const id = text(item.id); - const source = text([...list, ...archived].find((row) => text(row.id) === id)?.source); - const task = byId.get(id); - return { id, key: `task:${id}`, title: text(task?.title) || text(task?.note) || null, priority: text(task?.priority) || null, board: boardOfRow({ source }, task), ...annotate(item) }; - }); - const waiting = queueItems(queues?.waiting, (item) => ({ role: text(item.role) || null, waitedMs: Number.isFinite(item.waitedMs) ? item.waitedMs : null })); - const inflight = queueItems(queues?.inflight, (item) => ({ holder: text(item.holder) || null, state: item.stalled === true ? "stalled" : "running" })); - const blocked = queueItems(queues?.blocked, (item) => ({ reason: text(item.reason) || null, waitedMs: Number.isFinite(item.waitedMs) ? item.waitedMs : null })); - const queueGroups = [ - { group: "waiting", label: "Waiting", items: waiting }, - { group: "inflight", label: "In flight", items: inflight }, - { group: "blocked", label: "Blocked", items: blocked } - ].filter((entry) => entry.items.length > 0); - const rawCounts = queues?.counts ?? null; - return { - projectId: text(project?.id) || null, - title: text(project?.name) || "Project", - canManage: canManage === true, - groups, - // #1855: one group, closed until the reader opens it. Rows keep the row shape above, so a - // finished card is still a card — only its place on the board changed. - done: { group: "done", label: "Done cards", collapsed: true, rows: archived.map(projectRow) }, - queues: queueGroups, - counts: rawCounts === null ? null : { waiting: countOf(rawCounts.waiting), inflight: countOf(rawCounts.inflight), blocked: countOf(rawCounts.blocked), done: countOf(rawCounts.done) }, - count: list.length, - empty: list.length === 0 && queueGroups.length === 0 && archived.length === 0, - emptyText: "No cards yet.", - record: projectBoardRecord({ decisions, meetings }), - metrics: metricsView(metrics, { tasks }) + const pinned = bot.pinned === true && !tile; + const remote = Boolean(bot.remote); + const shown = remote ? friendBotTitle(bot) : bot.name; + const secondLine = awayLine || preview || (remote ? bot.remote?.gone ? t("no longer shared") : t("Friend's Bot") : ""); + const label = [shown, main ? t("Main Bot") : "", pinned ? t("Pinned") : "", running || away ? t("Working") : "", waiting ? t("Waiting for your approval") : alert ? t("Waiting for your answer") : "", unread ? t("Unread activity") : ""].filter(Boolean).join(", "); + if (tile && wide) { + return (0, import_react18.createElement)("div", { className: "bt-tile-wrap" }, (0, import_react18.createElement)( + "button", + { + type: "button", + className: "bt-tile", + "aria-label": label, + "aria-current": active ? "page" : void 0, + "data-bot-id": id, + title: preview || bot.name, + onClick: () => actions.openSession(id), + onContextMenu + }, + (0, import_react18.createElement)(BotAvatar, { bot, size: 60, state, main: true }), + (0, import_react18.createElement)("span", { className: "bt-tile-name" }, shown), + // The Main Bot's tile gets the same unread dot as a row (#420); a wait shows on its avatar. + unread && !waiting && !active ? (0, import_react18.createElement)("span", { className: "bt-unread bt-tile-unread", "aria-hidden": true }) : null, + bot.role ? (0, import_react18.createElement)("span", { className: "bt-chip" }, bot.role) : null + )); + } + const unreadMark = wide && waiting && !active ? (0, import_react18.createElement)("span", { className: "bt-unread bt-unread-wait", "aria-hidden": true }) : wide && unread && !active ? (0, import_react18.createElement)("span", { className: "bt-unread", "aria-hidden": true }) : null; + const row = (0, import_react18.createElement)( + "button", + { + type: "button", + className: "bt-row", + "aria-label": label, + "aria-current": active ? "page" : void 0, + // An own Bot's hover text names its model (#366); a friend's Bot runs on its owner's model. + "data-bot-id": id, + title: remote ? shown : botRowTitle(shown, modelOf(bot, roster)?.name, wide), + onClick: () => actions.openSession(id), + onContextMenu + }, + (0, import_react18.createElement)(BotAvatar, { bot, size: 36, state, main }), + pinned && !wide ? (0, import_react18.createElement)(PinMark) : null, + wide ? (0, import_react18.createElement)( + "span", + { className: "bt-row-body" }, + (0, import_react18.createElement)( + "span", + { className: "bt-row-name" }, + (0, import_react18.createElement)("span", { className: "bt-row-title" }, shown), + bot.remote ? (0, import_react18.createElement)(RemoteMark, { remote: bot.remote }) : null, + pinned ? (0, import_react18.createElement)(PinMark) : null + ), + secondLine ? (0, import_react18.createElement)("span", { className: "bt-row-preview" }, secondLine) : null + ) : null, + unreadMark + ); + const settingsLabel = bot.remote ? t("Bot settings") : t("Edit Bot"); + const settings = wide ? (0, import_react18.createElement)("button", { + type: "button", + className: "bt-row-settings", + "aria-label": settingsLabel, + title: settingsLabel, + onFocus: (event) => keepSidebarStill(event.currentTarget), + onClick: (event) => { + event.stopPropagation(); + keepSidebarStill(event.currentTarget); + actions.openSettings("bots", id); + } + }, (0, import_react18.createElement)(GearIcon)) : null; + if (settings) return (0, import_react18.createElement)("div", { className: "bt-row-wrap", "data-bot-id": id }, row, settings); + return row; +} +function keepSidebarStill(node) { + const reset = () => { + for (let at = node?.parentElement; at; at = at.parentElement) if (at.scrollLeft > 0) at.scrollLeft = 0; + const page = typeof document === "undefined" ? null : document.scrollingElement; + if (page && page.scrollLeft > 0) page.scrollLeft = 0; }; + reset(); + if (typeof requestAnimationFrame === "function") requestAnimationFrame(() => requestAnimationFrame(reset)); } -function projectRowNode(row, onOpen) { - const open = row.board === null ? void 0 : () => onOpen?.(row.board); - return (0, import_react19.createElement)( - "div", +function RemoteMark({ remote }) { + return (0, import_react18.createElement)( + "span", + { className: "bt-row-remote", title: remote.gone ? `${remote.handle} · ${t("no longer shared")}` : remote.handle, "aria-label": t("Friend's Bot") }, + (0, import_react18.createElement)( + "svg", + { width: 11, height: 11, viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: 2, strokeLinecap: "round", "aria-hidden": true }, + (0, import_react18.createElement)("circle", { cx: 12, cy: 12, r: 9 }), + (0, import_react18.createElement)("path", { d: "M3 12h18M12 3a14 14 0 0 1 0 18M12 3a14 14 0 0 0 0 18" }) + ) + ); +} +function isPastFaceRoom(room2) { + if (room2?.origin !== "face-to-face") return false; + const status = room2.face?.status; + if (status === "closed" || status === "left") return true; + const expires = room2.face?.expiresAt; + return status === "candidate" && expires && Number.isFinite(Date.parse(expires)) && Date.parse(expires) <= Date.now(); +} +function FaceMark() { + return (0, import_react18.createElement)( + "span", + { className: "bt-row-remote bt-row-face", title: t("Face-to-face"), "aria-label": t("Face-to-face") }, + (0, import_react18.createElement)( + "svg", + { width: 11, height: 11, viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: 2, strokeLinecap: "round", "aria-hidden": true }, + (0, import_react18.createElement)("circle", { cx: 9, cy: 8, r: 3 }), + (0, import_react18.createElement)("circle", { cx: 16, cy: 9, r: 2.5 }), + (0, import_react18.createElement)("path", { d: "M4 18a5 5 0 0 1 10 0M14 18a4 4 0 0 1 6 0" }) + ) + ); +} +function RoomRow({ room: room2, roster, actions, useSessions, useSessionStatus, useSessionRetainInfo, useWork, wide, marked }) { + const id = room2.id; + const fallback = useSessions((list) => nameMentions(previewOf(list, id, [], [], botNames(roster)), roster)); + const preview = nameMentions(visibleText(room2.preview), roster) || fallback; + const active = useSessionRetainInfo(id, (info) => (info?.retainedBy?.mainView ?? 0) > 0); + const unread = useSessionStatus((map) => map.get(id)?.completionUnread === true) || marked && !active; + useMarkedUnread(id, active, marked, actions); + const approval = useApproval(useWork, (entry) => entry.roomId === id); + const question = roster.questions?.[id] !== void 0; + const members = workKeys(useGroupWork(useWork, (rooms) => (rooms[id] ?? []).map((entry) => `${entry.botId}|${entry.tool ?? ""}`))); + const first = roster.byId[members[0]?.id]; + const workingLine = first ? workLine(first.name, members[0].tool, members.length - 1) : null; + if (room2.hidden && !active) return null; + const pinned = room2.pinned === true; + const title = roomTitle(room2); + const from = room2.sharedFrom ? sharedRoomTitle(room2.sharedFrom.inviterName) : ""; + const hover = from && from !== title ? `${title} · ${from}` : title; + const waiting = approval || (room2.pendingCount ?? 0) > 0; + const alert = question || waiting; + const face = room2.origin === "face-to-face"; + const label = [hover, face ? t("Face-to-face") : "", pinned ? t("Pinned") : "", members.length > 0 ? t("Working") : "", waiting ? t("Waiting for your approval") : alert ? t("Waiting for your answer") : ""].filter(Boolean).join(", "); + return (0, import_react18.createElement)( + "button", { - key: row.key, - className: "bt-project-row", - "data-task-id": row.id, - "data-column": row.column, - "data-priority": row.priority ?? void 0, - role: open === void 0 ? void 0 : "button", - tabIndex: open === void 0 ? void 0 : 0, - onClick: open, - onKeyDown: open === void 0 ? void 0 : (event) => { - if (event.key === "Enter" || event.key === " ") open(); + type: "button", + className: "bt-row", + "aria-label": label, + "aria-current": active ? "page" : void 0, + // The full title on hover. The row wraps the number-first form so the second + // name is not cut to "SelfB…" (#1102, #304, #458). + title: hover, + "data-room-id": id, + onClick: () => actions.openSession(id), + onContextMenu: (event) => { + event.preventDefault(); + actions.openMenu({ kind: "room", id, x: event.clientX, y: event.clientY }); } }, - (0, import_react19.createElement)("span", { className: "bt-project-column" }, t(row.column)), - (0, import_react19.createElement)( + (0, import_react18.createElement)(RoomAvatar, { room: room2, roster, size: 36 }), + pinned && !wide ? (0, import_react18.createElement)(PinMark) : null, + wide ? (0, import_react18.createElement)( "span", - { className: "bt-project-title-wrap" }, - (0, import_react19.createElement)("span", { className: "bt-project-title" }, row.title ?? (row.assignee ? `@${row.assignee}` : "—")), - // The audit text the row already carries (#1730): shown inside the card detail, never a - // second read. A row without one renders exactly as before. - row.audit === null ? null : (0, import_react19.createElement)("span", { className: "bt-project-audit" }, row.audit) - ), - // "Who is doing" is the claim, never the assignee label; a card the Assistant speaks for - // says so, so the three identities (Bot, Assistant, user) never read as one. - row.holder === null ? null : (0, import_react19.createElement)("span", { className: "bt-project-holder", "data-holder": row.holder }, `@${row.holder}`), - row.speaksFor === null ? null : (0, import_react19.createElement)("span", { className: "bt-project-speaks-for", "data-speaks-for": row.speaksFor }, t("for {user}", { user: row.speaksFor })), - row.priority === null ? null : (0, import_react19.createElement)("span", { className: "bt-project-priority" }, row.priority), - row.runId === null ? null : (0, import_react19.createElement)("span", { className: "bt-project-ref" }, row.nodeId === null ? row.runId : `${row.runId} · ${row.nodeId}`) + { className: "bt-row-body" }, + (0, import_react18.createElement)("span", { className: "bt-row-name" }, (0, import_react18.createElement)("span", { className: face ? "bt-row-title bt-row-title-face" : "bt-row-title" }, face ? faceRowTitle(title) : title), face ? (0, import_react18.createElement)(FaceMark) : room2.sharedFrom ? (0, import_react18.createElement)("span", { className: "bt-tag", title: from }, t("Shared")) : null, pinned ? (0, import_react18.createElement)(PinMark) : null), + workingLine ? (0, import_react18.createElement)("span", { className: "bt-row-preview" }, workingLine) : preview ? (0, import_react18.createElement)("span", { className: "bt-row-preview" }, preview) : null + ) : null, + wide && alert && !active ? (0, import_react18.createElement)("span", { className: "bt-unread bt-unread-wait", "aria-hidden": true }) : wide && unread && !active ? (0, import_react18.createElement)("span", { className: "bt-unread", "aria-hidden": true }) : null ); } -function ProjectBoardRows({ view, onOpen }) { - if (view.empty) return (0, import_react19.createElement)("div", { className: "bt-project-empty" }, t(view.emptyText)); - return view.groups.map((group) => (0, import_react19.createElement)( - "section", - { key: group.source, className: "bt-project-group", "data-source": group.source }, - (0, import_react19.createElement)("h4", null, t(group.label)), - group.rows.map((row) => projectRowNode(row, onOpen)) - )); +function AgentSessionRow({ session, agent, actions, renaming, index }) { + const input = (0, import_react18.useRef)(null); + (0, import_react18.useLayoutEffect)(() => { + if (!renaming) return; + input.current?.focus(); + input.current?.select(); + }, [renaming]); + if (renaming) { + const commit = (keep) => { + const value = input.current?.value ?? ""; + if (!keep || value.trim() === "" || value.trim() === session.title) { + actions.endAgentRename(); + return; + } + void (async () => { + try { + await actions.renameAgentSession(agent.id, session.id, value); + actions.endAgentRename(); + } catch (error) { + actions.reportError(error, "agent-rename"); + } + })(); + }; + return (0, import_react18.createElement)( + "div", + { className: "bt-agent-sub-row bt-agent-edit", style: { "--i": index } }, + (0, import_react18.createElement)("input", { + ref: input, + defaultValue: session.title, + "aria-label": t("Rename"), + onKeyDown: (event) => { + if (event.nativeEvent.isComposing) return; + if (event.key === "Enter") commit(true); + else if (event.key === "Escape") commit(false); + }, + onBlur: () => commit(true) + }) + ); + } + return (0, import_react18.createElement)( + "button", + { + type: "button", + className: "bt-agent-sub-row", + "aria-current": session.active ? "page" : void 0, + style: { "--i": index }, + onClick: () => actions.openAgentSession(session.id), + onContextMenu: (event) => { + event.preventDefault(); + event.stopPropagation(); + actions.openMenu({ kind: "agent-session", id: session.id, agentId: agent.id, x: event.clientX, y: event.clientY }); + } + }, + (0, import_react18.createElement)("span", { className: "bt-agent-sub-title" }, session.title), + (0, import_react18.createElement)("span", { className: "bt-agent-sub-time" }, session.at > 0 ? relativeShort(session.at) : "") + ); } -function ProjectDoneGroup({ view, onOpen }) { - if (view.done.rows.length === 0) return null; - return (0, import_react19.createElement)( - "details", - { className: "bt-project-group bt-project-done", "data-group": "done" }, - (0, import_react19.createElement)("summary", { className: "bt-project-done-summary" }, `${t(view.done.label)} · ${view.done.rows.length}`), - view.done.rows.map((row) => projectRowNode(row, onOpen)) +function AgentRow({ agent, actions, useSessions, useSessionStatus, useUi, wide, marked }) { + const info = useSessions((list) => JSON.stringify(agent.sessions.map((id) => { + const row = list.byId[id]; + return { + id, + // A blank Session's title is the workspace folder name; show the label instead. + title: row?.blank === true ? "" : row?.displayTitle ?? row?.title ?? "", + at: row?.updatedAt ?? 0, + running: row?.running === true, + active: (row?.retainedBy?.mainView ?? 0) > 0 + }; + }).sort((a, b) => b.at - a.at))); + const statusInfo = useSessionStatus((map) => JSON.stringify(agent.sessions.map((id) => ({ + id, + running: map.get(id)?.running === true, + unread: map.get(id)?.completionUnread === true + })))); + const statuses = Object.fromEntries(JSON.parse(statusInfo).map((entry) => [entry.id, entry])); + const sessions = JSON.parse(info).map((session) => ({ + ...session, + running: statuses[session.id]?.running ?? session.running, + unread: statuses[session.id]?.unread ?? false, + title: session.title === "" ? t("New session") : session.title + })); + const renaming = useUi((value) => value.renameAgent); + const active = sessions.some((session) => session.active); + const running = sessions.some((session) => session.running); + const unread = sessions.some((session) => session.unread) || marked && !active; + useMarkedUnread(agent.id, active, marked, actions); + const preview = sessions[0]?.title ?? t("New session"); + const label = ["DSH Agent", running ? t("Working") : "", unread ? t("Unread activity") : ""].filter(Boolean).join(", "); + const open = active && wide; + return (0, import_react18.createElement)( + "div", + { className: "bt-agent" }, + (0, import_react18.createElement)( + "button", + { + type: "button", + className: "bt-row", + "aria-label": label, + "aria-current": active ? "page" : void 0, + "data-agent-id": agent.id, + title: wide ? void 0 : "DSH Agent", + onClick: () => actions.openAgent(agent.id), + onContextMenu: (event) => { + event.preventDefault(); + actions.openMenu({ kind: "agent", id: agent.id, x: event.clientX, y: event.clientY }); + } + }, + (0, import_react18.createElement)("span", { className: `bt-agent-ava${running ? " bt-run" : ""}` }, (0, import_react18.createElement)(import_dsh_client_ui_primitives2.FishLogo, { size: 22 })), + wide ? (0, import_react18.createElement)( + "span", + { className: "bt-row-body" }, + (0, import_react18.createElement)("span", { className: "bt-row-name" }, "DSH Agent"), + preview ? (0, import_react18.createElement)("span", { className: "bt-row-preview" }, preview) : null + ) : null, + wide && unread && !active ? (0, import_react18.createElement)("span", { className: "bt-unread", "aria-hidden": true }) : null + ), + (0, import_react18.createElement)( + "div", + { className: "bt-agent-sub", "data-open": open || void 0 }, + (0, import_react18.createElement)( + "div", + { className: "bt-agent-sub-in" }, + (0, import_react18.createElement)( + "button", + { type: "button", className: "bt-agent-sub-row bt-agent-new", style: { "--i": 0 }, onClick: () => { + void actions.newAgentSession(agent.id).catch((error) => actions.reportError(error, "agent-session")); + } }, + (0, import_react18.createElement)(PlusIcon), + (0, import_react18.createElement)("span", { className: "bt-agent-sub-title" }, t("New session")) + ), + sessions.map((session, index) => (0, import_react18.createElement)(AgentSessionRow, { + key: session.id, + session, + agent, + actions, + index: index + 1, + renaming: renaming === session.id + })) + ) + ) ); } -function waitedLabel(ms) { - if (!Number.isFinite(ms) || ms < 0) return null; - if (ms < 6e4) return `${ms === 0 ? 0 : Math.max(1, Math.floor(ms / 1e3))}s`; - const minutes = Math.floor(ms / 6e4); - if (minutes < 60) return `${minutes}m`; - const hours = Math.floor(minutes / 60); - if (hours < 24) return `${hours}h`; - return `${Math.floor(hours / 24)}d`; +var GLIDE = springEasing(1e3, 63); +function useListGlide(listRef, layout, key) { + const tops = (0, import_react18.useRef)({ layout, map: /* @__PURE__ */ new Map() }); + (0, import_react18.useLayoutEffect)(() => { + const list = listRef.current; + if (!list) return; + const next = /* @__PURE__ */ new Map(); + const still = reducedMotion() || tops.current.layout !== layout; + for (const row of list.querySelectorAll("[data-bot-id],[data-room-id],[data-agent-id]")) { + const id = row.dataset.botId ?? row.dataset.roomId ?? row.dataset.agentId; + const top = row.offsetTop; + next.set(id, top); + const before = tops.current.map.get(id); + if (!still && before !== void 0 && Math.abs(before - top) > 0.5) { + row.animate([{ transform: `translateY(${before - top}px)` }, { transform: "none" }], GLIDE); + } + } + tops.current = { layout, map: next }; + }, [layout, key]); } -var METRIC_CARDS = [ - { key: "prsOpenedToday", label: "PRs opened today", caption: "Natural day in your time zone, by created_at." }, - { key: "prsMergedToday", label: "PRs merged today", caption: "Natural day in your time zone, by merged_at." }, - { key: "issuesOpenedToday", label: "Issues opened today", caption: "Natural day in your time zone, by created_at." }, - { key: "issuesClosedToday", label: "Issues closed today", caption: "Natural day in your time zone, by closed_at." }, - { key: "prMergeMedianMs", label: "PR merge time", caption: "Median of the last 7 days, opened to merged." }, - { key: "issueCloseMedianMs", label: "Issue close time", caption: "Median of the last 7 days, opened to closed." }, - { key: "taskDoneMedianMs", label: "Task completion", caption: "Median of the last 7 days, created to done." } -]; -var METRIC_DASH = "—"; -var DURATION_KEYS = /* @__PURE__ */ new Set(["prMergeMedianMs", "issueCloseMedianMs", "taskDoneMedianMs"]); -var RUNNING_OVERDUE_MS = 2 * 60 * 60 * 1e3; -var metricText = (key, value) => { - if (!Number.isFinite(value)) return METRIC_DASH; - if (DURATION_KEYS.has(key)) return waitedLabel(value) ?? METRIC_DASH; - return String(value); +var NodeActions = class extends import_react18.Component { + constructor(props) { + super(props); + this.state = { failed: false }; + } + static getDerivedStateFromError() { + return { failed: true }; + } + componentDidCatch(error) { + console.error("flybot: nodeActions failed", error); + } + render() { + return this.state.failed ? null : (0, import_react18.createElement)(this.props.component, { bot: this.props.bot }); + } }; -function metricsView(metrics, { tasks } = {}) { - const source = metrics !== null && typeof metrics === "object" ? metrics : null; - const values = source?.metrics !== null && typeof source?.metrics === "object" ? source.metrics : {}; - const byId = new Map((Array.isArray(tasks) ? tasks : []).filter((task) => task !== null && typeof task === "object" && text(task.id) !== "").map((task) => [text(task.id), task])); - const running = (Array.isArray(source?.running) ? source.running : []).filter((row) => row !== null && typeof row === "object" && text(row.id) !== "").map((row) => { - const id = text(row.id); - const task = byId.get(id); - return { - id, - key: "task:" + id, - title: text(row.title) || text(task?.title) || text(task?.note) || id, - status: text(row.status), - who: text(row.who) || null, - sinceMs: Number.isFinite(row.sinceMs) ? row.sinceMs : null, - source: text(row.source) || null, - lastActivityAt: Number.isFinite(row.lastActivityAt) ? row.lastActivityAt : null, - overdue: Number.isFinite(row.sinceMs) && row.sinceMs > RUNNING_OVERDUE_MS, - board: boardOfRow({ source: text(row.source) }, task) - }; +function TreeNode({ node, rowProps, markedUnread, collapsed, toggle, actions }) { + const { bot, depth, children } = node; + const closed = collapsed[bot.id] === true; + return (0, import_react18.createElement)( + import_react18.Fragment, + null, + (0, import_react18.createElement)( + "div", + { className: "bt-tree-item", "data-depth": depth, style: { paddingLeft: `${depth * 16}px` } }, + children.length ? (0, import_react18.createElement)( + "button", + { type: "button", className: "bt-tree-toggle", "aria-expanded": !closed, "aria-label": `${closed ? t("Expand") : t("Collapse")} ${bot.name}`, onClick: () => toggle(bot.id) }, + (0, import_react18.createElement)("span", { className: "bt-tree-chev", "aria-hidden": true }, "▾") + ) : (0, import_react18.createElement)("span", { className: "bt-tree-toggle bt-tree-leaf", "aria-hidden": true }), + (0, import_react18.createElement)(BotRow, { ...rowProps, bot, asRow: true, marked: markedUnread[bot.id] === true }), + typeof actions === "function" ? (0, import_react18.createElement)(NodeActions, { component: actions, bot }) : null + ), + closed ? null : children.map((child) => (0, import_react18.createElement)(TreeNode, { key: child.bot.id, node: child, rowProps, markedUnread, collapsed, toggle, actions })) + ); +} +function TeamSidebar(props) { + const { wide, useRoster, useUi, useSessions, actions } = props; + const listRef = (0, import_react18.useRef)(null); + const roster = useRoster((value) => value); + const markedUnread = useUi((value) => value.unread); + props.useRegistry((value) => value); + const updated = useSessions((list) => { + const stamps = {}; + for (const id of list.ids) stamps[id] = list.byId[id]?.updatedAt ?? 0; + return JSON.stringify(stamps); }); - return { - available: source?.ok === true, - reason: text(source?.reason) || null, - cards: METRIC_CARDS.map((card) => ({ ...card, value: metricText(card.key, values[card.key]) })), - running, - empty: source === null, - emptyText: "No metrics read yet." + const order = (0, import_react18.useMemo)(() => { + const stamps = JSON.parse(updated); + const entries2 = [ + // Own Bots, friends' Bots, groups and face rooms share one list. A remote chat + // still uses BotRow's globe; gone / hidden / home-level remotes stay out. + ...roster.bots.filter((bot) => bot.id !== roster.mainBotId && isShownRemoteChat(bot)).map((bot) => ({ kind: "bot", item: bot, at: stamps[currentPart(bot)] ?? bot.createdAt })), + ...roster.rooms.filter((room2) => !isPastFaceRoom(room2)).map((room2) => ({ kind: "room", item: room2, at: stamps[room2.id] ?? room2.createdAt })), + // The Agent sorts by its most recent Session, falling back to when it was added. + ...(roster.agents ?? []).map((agent) => ({ kind: "agent", item: agent, at: Math.max(0, ...agent.sessions.map((id) => stamps[id] ?? 0)) || agent.createdAt })) + ]; + const foreign = (entry) => entry.kind === "bot" && entry.item.remote || entry.kind === "room" && (entry.item.origin === "face-to-face" || entry.item.sharedFrom); + return entries2.sort((a, b) => Number(Boolean(b.item.pinned)) - Number(Boolean(a.item.pinned)) || Number(foreign(a)) - Number(foreign(b)) || b.at - a.at); + }, [roster, updated]); + const main = roster.byId[roster.mainBotId]; + const readOnlySaid = typeof roster.readOnly === "string" && roster.readOnly.trim() !== ""; + const structure = useSidebarStructure(); + const [collapsed, setCollapsed] = (0, import_react18.useState)({}); + const toggle = (id) => setCollapsed((value) => ({ ...value, [id]: !value[id] })); + const inTree = (entry) => entry.kind === "bot" && !entry.item.remote; + const listed = (entry) => inTree(entry) && structure?.include?.(entry.item.id) !== false; + const tree = structure && wide && main ? botTree([main, ...order.filter(listed).map((entry) => entry.item)], main.id, (id) => structure.parentOf(id)) : null; + useListGlide(listRef, wide, order.map((entry) => entry.item.id).join(",")); + useLandOnMainBot(roster, useSessions, actions); + const rowProps = { roster, actions, useSessions, useUi, useSessionStatus: props.useSessionStatus, useSessionRetainInfo: props.useSessionRetainInfo, useActivity: props.useActivity, useWork: props.useWork, wide }; + const typeToSearch = (event) => { + if (event.defaultPrevented || event.key.length !== 1 || event.key === " " || event.metaKey || event.ctrlKey || event.altKey || event.nativeEvent.isComposing) return; + if (event.target instanceof Element && event.target.closest("input,textarea,[contenteditable]")) return; + event.preventDefault(); + actions.openPalette(event.key); }; -} -function ProjectMetrics({ view }) { - return (0, import_react19.createElement)( - "section", - { className: "bt-project-metrics", "data-available": view.available ? "true" : "false" }, - view.available || view.reason === null ? null : (0, import_react19.createElement)("p", { className: "bt-project-metrics-note" }, t("Metrics unavailable") + ": " + view.reason), - (0, import_react19.createElement)( + return (0, import_react18.createElement)( + "div", + { className: wide ? "bt-side" : "bt-side bt-rail", onKeyDown: typeToSearch }, + wide ? (0, import_react18.createElement)( "div", - { className: "bt-project-metric-cards" }, - view.cards.map((card) => (0, import_react19.createElement)( - "div", - { key: card.key, className: "bt-project-metric", "data-metric": card.key }, - (0, import_react19.createElement)("span", { className: "bt-project-metric-value" }, card.value), - (0, import_react19.createElement)("span", { className: "bt-project-metric-label" }, t(card.label)), - (0, import_react19.createElement)("span", { className: "bt-project-metric-caption" }, t(card.caption)) - )) + { className: "bt-side-top" }, + (0, import_react18.createElement)("span", { className: "bt-brand" }, (0, import_react18.createElement)(BrandMark, { size: 22 }), (0, import_react18.createElement)(BrandName)), + (0, import_react18.createElement)(UpdateButton, { useUpdate: props.useUpdate, actions }), + (0, import_react18.createElement)("button", { type: "button", className: "bt-round", "aria-label": t("Search"), title: t("Search"), onClick: () => actions.openPalette() }, (0, import_react18.createElement)(SearchIcon)), + (0, import_react18.createElement)("button", { type: "button", className: "bt-round", "aria-label": t("New…"), title: t("New…"), onClick: () => actions.openNewChat("new") }, (0, import_react18.createElement)(PlusIcon)) + ) : (0, import_react18.createElement)( + import_react18.Fragment, + null, + (0, import_react18.createElement)(UpdateButton, { useUpdate: props.useUpdate, actions }), + (0, import_react18.createElement)("button", { type: "button", className: "bt-round", "aria-label": t("New…"), title: t("New…"), onClick: () => actions.openNewChat("new") }, (0, import_react18.createElement)(PlusIcon)) + ), + wide && readOnlySaid ? (0, import_react18.createElement)("div", { className: "bt-hint bt-read-only", role: "status" }, hostText(roster.readOnly)) : null, + !wide && readOnlySaid ? (0, import_react18.createElement)("div", { className: "bt-hint bt-read-only", role: "status", title: hostText(roster.readOnly), "aria-label": hostText(roster.readOnly) }, hostText(roster.readOnly)) : null, + (0, import_react18.createElement)(InboxRow, { wide, useRoster, useWork: props.useWork, useUi, actions }), + (0, import_react18.createElement)(KanbanRow, { wide, useUi, actions }), + (0, import_react18.createElement)(ProjectsNav, { wide }), + (0, import_react18.createElement)( + "div", + { ref: listRef, className: "bt-list", role: "region", "aria-label": t("Bot list") }, + tree ? (0, import_react18.createElement)("div", { className: "bt-tree", role: "group", "aria-label": t("Bot tree") }, tree.map((node) => (0, import_react18.createElement)(TreeNode, { key: node.bot.id, node, rowProps, markedUnread, collapsed, toggle, actions: structure?.nodeActions }))) : main ? (0, import_react18.createElement)(BotRow, { ...rowProps, bot: main, marked: markedUnread[main.id] === true }) : roster.ready ? null : (0, import_react18.createElement)("div", { className: "bt-hint" }, t("Setting up your Main Bot…")), + order.filter((entry) => !(tree && inTree(entry)) && !(inTree(entry) && structure?.include?.(entry.item.id) === false)).map((entry) => entry.kind === "bot" ? (0, import_react18.createElement)(BotRow, { key: entry.item.id, ...rowProps, bot: entry.item, marked: markedUnread[entry.item.id] === true }) : entry.kind === "room" ? (0, import_react18.createElement)(RoomRow, { key: entry.item.id, ...rowProps, room: entry.item, marked: markedUnread[entry.item.id] === true }) : (0, import_react18.createElement)(AgentRow, { key: entry.item.id, ...rowProps, agent: entry.item, marked: markedUnread[entry.item.id] === true })), + wide ? (0, import_react18.createElement)(FlyBotSections, { roster, actions, renderBot: (bot) => (0, import_react18.createElement)(BotRow, { key: bot.id, ...rowProps, bot, asRow: true, marked: markedUnread[bot.id] === true }) }) : null ) ); } -function ProjectRunningTable({ view, onOpen }) { - if (view.running.length === 0) return (0, import_react19.createElement)("section", { className: "bt-project-running", "data-running": "0" }, (0, import_react19.createElement)("h4", null, t("Running tasks")), (0, import_react19.createElement)("div", { className: "bt-project-empty" }, t("Nothing is running."))); - return (0, import_react19.createElement)( - "section", - { className: "bt-project-running", "data-running": String(view.running.length) }, - (0, import_react19.createElement)("h4", null, t("Running tasks")), - (0, import_react19.createElement)( +function UpdateButton({ useUpdate, actions }) { + const update = useUpdate((value) => value); + const [open, setOpen] = (0, import_react18.useState)(false); + const [box, setBox] = (0, import_react18.useState)(null); + const [error, setError] = (0, import_react18.useState)(""); + const [dshError, setDshError] = (0, import_react18.useState)(""); + const button = (0, import_react18.useRef)(null); + (0, import_react18.useEffect)(() => { + if (!open) return void 0; + const onDown = (event) => { + if (event.target instanceof Element && (event.target.closest(".bt-update-pop") || button.current?.contains(event.target))) return; + setOpen(false); + }; + const onKey = (event) => { + if (event.key === "Escape") { + event.stopPropagation(); + setOpen(false); + button.current?.focus(); + } + }; + window.addEventListener("mousedown", onDown); + window.addEventListener("keydown", onKey, true); + return () => { + window.removeEventListener("mousedown", onDown); + window.removeEventListener("keydown", onKey, true); + }; + }, [open]); + const phase = update?.phase ?? "idle"; + const dsh = update?.dsh; + const dshPhase = dsh?.phase ?? "idle"; + const dshRow = showsDshRow(dsh, update?.app); + if (!update?.available && !dshRow && phase === "idle" && dshPhase === "idle") return null; + const cloudPhase = phase === "installing" || dshPhase === "installing" ? "installing" : phase !== "idle" ? phase : dshPhase; + const toggle = () => { + const rect = button.current?.getBoundingClientRect(); + if (rect) setBox({ left: Math.max(8, Math.min(rect.left, window.innerWidth - 288)), top: rect.bottom + 6 }); + setOpen((value) => !value); + }; + const start = () => { + setError(""); + void actions.installUpdate().catch((failure) => setError(hostText(failure?.message ?? String(failure)))); + }; + const startDsh = () => { + setDshError(""); + void actions.installDshUpdate().catch((failure) => setDshError(hostText(failure?.message ?? String(failure)))); + }; + const label = phase === "installing" ? t("Updating FlyBot core…") : dshPhase === "installing" ? t("Updating DSH…") : phase === "installed" ? t("FlyBot core {version} is installed. Restart DSH to use it.", { version: update.installed ?? update.latest }) : dshPhase === "installed" ? t("Restart DSH to start using DSH {version}.", { version: dsh.installed ?? dsh.verified }) : update?.available ? t("FlyBot core {version} is available", { version: update.latest }) : t("DSH {version} is available", { version: dsh?.verified }); + const message = error || (phase === "failed" ? update.error : ""); + const dshMessage = dshError || (dshPhase === "failed" ? dsh?.error : ""); + const dshText = dshPhase === "installed" ? t("Restart DSH to start using DSH {version}.", { version: dsh.installed ?? dsh.verified }) : dsh?.available === false && dsh?.blockedBy ? t("Update FlyBot core {version} first.", { version: dsh.blockedBy }) : t("You have {version}.", { version: dsh?.current }); + return (0, import_react18.createElement)( + import_react18.Fragment, + null, + (0, import_react18.createElement)("button", { + ref: button, + type: "button", + className: "bt-round bt-update", + "data-phase": cloudPhase, + "aria-label": label, + title: label, + "aria-haspopup": "dialog", + "aria-expanded": open, + onClick: toggle + }, (0, import_react18.createElement)(CloudDownloadIcon, { done: phase === "installed" }), phase === "failed" || dshPhase === "failed" ? (0, import_react18.createElement)("span", { className: "bt-update-dot", "aria-hidden": true }) : null), + open && box ? (0, import_react_dom4.createPortal)((0, import_react18.createElement)( "div", - { className: "bt-project-running-head" }, - (0, import_react19.createElement)("span", null, t("Task")), - (0, import_react19.createElement)("span", null, t("Status")), - (0, import_react19.createElement)("span", null, t("Who")), - (0, import_react19.createElement)("span", null, t("Claimed for")), - (0, import_react19.createElement)("span", null, t("Source")), - (0, import_react19.createElement)("span", null, t("Last activity")) - ), - view.running.map((row) => { - const open = row.board === null ? void 0 : () => onOpen?.(row.board); - return (0, import_react19.createElement)( + { className: "bt-update-pop", role: "dialog", "aria-label": update?.available || phase !== "idle" ? t("FlyBot core update") : t("DSH update"), style: box }, + update?.available || phase !== "idle" ? (0, import_react18.createElement)( + import_react18.Fragment, + null, + (0, import_react18.createElement)("div", { className: "bt-update-title" }, phase === "installed" ? t("Update installed") : t("FlyBot core {version}", { version: update.latest })), + (0, import_react18.createElement)("div", { className: "bt-update-text" }, phase === "installed" ? t("Restart DSH to start using FlyBot core {version}.", { version: update.installed ?? update.latest }) : t("You have {version}.", { version: update.current })), + message ? (0, import_react18.createElement)("div", { className: "bt-update-error", role: "alert" }, t("The update did not finish: {reason}", { reason: message })) : null, + (0, import_react18.createElement)( + "div", + { className: "bt-update-actions" }, + update.notesUrl ? (0, import_react18.createElement)("a", { className: "bt-update-notes", href: update.notesUrl, target: "_blank", rel: "noopener noreferrer" }, t("What's new")) : null, + phase === "installed" ? null : (0, import_react18.createElement)("button", { + type: "button", + className: "bt-update-go", + disabled: phase === "installing", + onClick: start + }, phase === "installing" ? t("Updating…") : phase === "failed" ? t("Try again") : t("Update")) + ) + ) : null, + dshRow ? (0, import_react18.createElement)( "div", - { - key: row.key, - className: "bt-project-running-row", - "data-task-id": row.id, - "data-overdue": row.overdue ? "true" : "false", - role: open === void 0 ? void 0 : "button", - tabIndex: open === void 0 ? void 0 : 0, - onClick: open - }, - (0, import_react19.createElement)("span", { className: "bt-project-running-title" }, row.title), - (0, import_react19.createElement)("span", { className: "bt-project-running-status" }, t(row.status)), - (0, import_react19.createElement)("span", null, row.who ?? METRIC_DASH), - (0, import_react19.createElement)("span", null, metricText("since", row.sinceMs)), - (0, import_react19.createElement)("span", null, row.source === null ? METRIC_DASH : t(SOURCE_LABELS[row.source] ?? row.source)), - (0, import_react19.createElement)("span", null, row.lastActivityAt === null ? METRIC_DASH : new Date(row.lastActivityAt).toLocaleString(dateLocale())) - ); - }) + { className: "bt-update-dsh" }, + (0, import_react18.createElement)("div", { className: "bt-update-title" }, t("DSH {version}", { version: dsh.verified })), + (0, import_react18.createElement)("div", { className: "bt-update-text" }, dshText), + dshMessage ? (0, import_react18.createElement)("div", { className: "bt-update-error", role: "alert" }, t("The update did not finish: {reason}", { reason: dshMessage })) : null, + (0, import_react18.createElement)( + "div", + { className: "bt-update-actions" }, + dsh.kind === "app" ? dsh.available ? (0, import_react18.createElement)("button", { type: "button", className: "bt-update-go", onClick: () => void actions.openAppUpdates(dsh.verified).catch(() => { + }) }, t("Open updates")) : null : !dsh.available && dshPhase === "idle" ? null : dshPhase === "installed" ? null : (0, import_react18.createElement)("button", { + type: "button", + className: "bt-update-go", + disabled: dshPhase === "installing", + onClick: startDsh + }, dshPhase === "installing" ? t("Updating…") : dshPhase === "failed" ? t("Try again") : t("Update")) + ) + ) : null + ), document.body) : null + ); +} +function ConnectApps({ wide, actions }) { + if (!wide) return null; + return (0, import_react18.createElement)( + "span", + { className: "bt-connect-wrap" }, + (0, import_react18.createElement)( + "button", + { type: "button", className: "bt-connect bt-connect-foot", onClick: () => actions.openConnectors() }, + (0, import_react18.createElement)(PlugIcon), + t("Connect apps") + ) ); } -function ProjectQueueGroups({ view, onOpen }) { - return view.queues.map((entry) => (0, import_react19.createElement)( - "section", - { key: `queue:${entry.group}`, className: "bt-project-group", "data-queue": entry.group }, - (0, import_react19.createElement)("h4", null, t(entry.label)), - entry.items.map((row) => { - const open = row.board === null ? void 0 : () => onOpen?.(row.board); - const note = entry.group === "waiting" ? (0, import_react19.createElement)("span", { className: "bt-project-wait" }, [row.role === null ? null : (0, import_react19.createElement)("span", { key: "role", className: "bt-project-role" }, row.role), waitedLabel(row.waitedMs) === null ? null : ` · ${waitedLabel(row.waitedMs)}`]) : entry.group === "inflight" ? (0, import_react19.createElement)("span", { className: "bt-project-state" }, row.holder === null ? t(row.state) : `${row.holder} · ${t(row.state)}`) : (0, import_react19.createElement)("span", { className: "bt-project-reason" }, row.reason === null ? null : waitedLabel(row.waitedMs) === null ? row.reason : `${row.reason} · ${waitedLabel(row.waitedMs)}`); - return (0, import_react19.createElement)( - "div", - { - key: row.key, - className: "bt-project-row", - "data-task-id": row.id, - "data-state": row.state ?? void 0, - role: open === void 0 ? void 0 : "button", - tabIndex: open === void 0 ? void 0 : 0, - onClick: open, - onKeyDown: open === void 0 ? void 0 : (event) => { - if (event.key === "Enter" || event.key === " ") open(); - } - }, - (0, import_react19.createElement)( - "span", - { className: "bt-project-title-wrap" }, - (0, import_react19.createElement)("span", { className: "bt-project-title" }, row.title ?? `#${row.id}`) - ), - note - ); - }) - )); +function YouAvatar() { + return (0, import_react18.createElement)( + "span", + { className: "bt-you-wrap" }, + (0, import_react18.createElement)("span", { className: "bt-you", "aria-hidden": true }, (0, import_react18.createElement)(BotMark, { look: MAIN_LOOK, size: 24 })), + (0, import_react18.createElement)("span", { style: { position: "absolute", width: 1, height: 1, overflow: "hidden", clip: "rect(0 0 0 0)" } }, t("Settings")) + ); } -var countText = (value) => value === null ? t("unknown") : String(value); -function ProjectBoardRecord({ record, projectId, call: call4 }) { - const { decisions, meetings } = record; - const [open, setOpen] = (0, import_react19.useState)(null); - const toggle = (path) => { - if (open !== null && open.path === path) { - setOpen(null); +var openShellSettings = null; +var openShellOnboarding = null; +var shellShortcuts = null; +function holdShellShortcuts(shortcuts) { + shellShortcuts = shortcuts; + return () => { + if (shellShortcuts === shortcuts) shellShortcuts = null; + }; +} +function openHarnessSettings() { + requestAnimationFrame(() => { + if (openShellSettings) { + openShellSettings(); return; } - if (typeof call4 !== "function") return; - setOpen({ path, answer: null }); - const settle = (answer) => setOpen((current) => current !== null && current.path === path ? { path, answer } : current); - call4("project-decision", { id: projectId, path }).then(settle, (error) => settle({ ok: false, reason: String(error?.message ?? error) })); - }; - const decisionBody = (file) => { - if (open === null || open.path !== file.path) return null; - const answer = open.answer; - if (answer === null) return (0, import_react19.createElement)("div", { className: "bt-project-decision-text", "data-open-decision": file.path }, t("Reading the decision…")); - if (answer.ok !== true) return (0, import_react19.createElement)("div", { className: "bt-project-decision-text bt-project-decision-refusal", "data-open-decision": file.path, "data-refused": "true" }, text(answer.reason) || t("not listed")); - return (0, import_react19.createElement)("pre", { className: "bt-project-decision-text", "data-open-decision": file.path }, typeof answer.text === "string" ? answer.text : ""); - }; - const decisionRows = decisions.files.map((file) => (0, import_react19.createElement)( - "div", - { key: file.key, className: "bt-project-decision-wrap" }, - (0, import_react19.createElement)( - "button", - { - type: "button", - className: "bt-project-decision", - "data-path": file.path, - "data-sha256": file.sha256, - "data-open": open !== null && open.path === file.path ? "true" : void 0, - "aria-expanded": open !== null && open.path === file.path ? "true" : "false", - onClick: () => toggle(file.path) - }, - (0, import_react19.createElement)("span", { className: "bt-project-date" }, file.date), - (0, import_react19.createElement)("span", { className: "bt-project-title" }, file.title === "" ? file.name : file.title), - (0, import_react19.createElement)("span", { className: "bt-project-hash", title: file.sha256 }, file.shortHash) - ), - decisionBody(file) - )); - const recipientText = (list) => list.length === 0 ? null : list.map((name) => `@${name}`).join(", "); - const proxyText = (meeting) => meeting.participants.filter((participant) => participant.speaksFor !== null).map((participant) => `${participant.name} ${t("for {user}", { user: participant.speaksFor })}`).join(", "); - const proxyRow = (meeting) => proxyText(meeting) === "" ? null : (0, import_react19.createElement)("span", { className: "bt-project-speaks-for", "data-speaks-for": meeting.participants.filter((p) => p.speaksFor !== null).map((p) => p.speaksFor).join(", ") }, proxyText(meeting)); - const levelOf = (meeting) => (0, import_react19.createElement)("span", { className: "bt-project-level", "data-level": meeting.level }, t(meeting.level)); - const decidedRows = meetings.decided.map((meeting) => (0, import_react19.createElement)( - "div", - { key: meeting.key, className: "bt-project-meeting", "data-status": "decided" }, - (0, import_react19.createElement)("span", { className: "bt-project-date" }, meeting.date), - (0, import_react19.createElement)("span", { className: "bt-project-title" }, meeting.question), - meeting.owner === null ? null : (0, import_react19.createElement)("span", { className: "bt-project-owner" }, `@${meeting.owner}`), - proxyRow(meeting), - // A decided meeting whose file is gone still reads as decided; the missing path says so - // instead of the row disappearing with the file. - meeting.path === "" ? (0, import_react19.createElement)("span", { className: "bt-project-missing" }, "decision file gone") : (0, import_react19.createElement)("span", { className: "bt-project-path" }, meeting.path) - )); - const pendingRows = meetings.pending.map((meeting) => (0, import_react19.createElement)( - "div", - { key: meeting.key, className: "bt-project-meeting", "data-status": meeting.status, "data-level": meeting.level }, - (0, import_react19.createElement)("span", { className: "bt-project-date" }, meeting.date), - (0, import_react19.createElement)("span", { className: "bt-project-title" }, meeting.blockingQuestion === null ? meeting.question : meeting.blockingQuestion), - levelOf(meeting), - recipientText(meeting.recipients) === null ? null : (0, import_react19.createElement)("span", { className: "bt-project-recipients" }, recipientText(meeting.recipients)), - meeting.advance.allowed ? null : (0, import_react19.createElement)("span", { className: "bt-project-paused" }, meeting.advance.reason), - proxyRow(meeting), - (0, import_react19.createElement)("span", { className: "bt-project-status" }, meeting.status) - )); - const moreLine = (hidden, key) => hidden > 0 ? (0, import_react19.createElement)("div", { key, className: "bt-project-more" }, t("{count} more not shown", { count: hidden })) : null; - return (0, import_react19.createElement)( - "section", - { - className: "bt-project-record", - // `data-decisions` stays "rows drawn"; the total rides beside it so the view can be asserted. - "data-decisions": String(decisions.count), - ...decisions.total === null ? {} : { "data-decisions-total": String(decisions.total) }, - "data-meetings": String(meetings.count) - }, - (0, import_react19.createElement)("h4", null, t("Decisions")), - decisions.empty ? (0, import_react19.createElement)("div", { className: "bt-project-empty bt-project-record-empty" }, t(decisions.emptyText)) : decisionRows, - moreLine(decisions.hidden, "decisions-more"), - (0, import_react19.createElement)("h4", null, t("Meetings")), - meetings.empty ? (0, import_react19.createElement)("div", { className: "bt-project-empty bt-project-record-empty" }, t(meetings.emptyText)) : decidedRows, - moreLine(meetings.decidedHidden, "decided-more"), - meetings.pending.length === 0 ? null : (0, import_react19.createElement)("h5", null, t("Not decided")), - meetings.pending.length === 0 ? null : pendingRows, - moreLine(meetings.pendingHidden, "pending-more") - ); + const desktop = document.querySelector('button[aria-haspopup="menu"][data-signed-out]'); + if (desktop) openThroughMenu(desktop); + else pressSettingsShortcut(); + }); } -var botIdOf = (entry) => entry !== null && typeof entry === "object" ? text(entry.botId) : ""; -var idList = (value) => Array.isArray(value) ? value.map((id) => text(id)).filter((id) => id !== "") : []; -function projectTeamView(project) { - const stored = project?.roles !== null && typeof project?.roles === "object" ? project.roles : {}; - const owner = []; - const addOwner = (id) => { - const clean = text(id); - if (clean !== "" && !owner.includes(clean)) owner.push(clean); - }; - addOwner(project?.ownerUserId); - for (const id of idList(project?.ownerUserIds)) addOwner(id); - const maintainers = idList(stored.maintainers).filter((id) => !owner.includes(id)); - const members = idList(stored.members).filter((id) => !owner.includes(id) && !maintainers.includes(id)); - return { - chief: botIdOf(project?.chief), - agent: botIdOf(project?.assistant), - roles: { owner, maintainers, members }, - empty: owner.length === 0 && maintainers.length === 0 && members.length === 0, - // Both come from the `projects` listing (#1828), which the overlay keeps on the record: the - // `project-read` record answers no rank, and the panel never re-derives the host's policy. - role: text(project?.role), - choices: idList(project?.choices) +function openHarnessOnboarding(id) { + requestAnimationFrame(() => { + if (openShellOnboarding) openShellOnboarding(id); + else openHarnessSettings(); + }); +} +var MODELS_STEP_ID = "flybot-models"; +var MODELS_STEP_ORDER = 1e3; +function ModelsSettingsStep({ explicit, complete, openSection }) { + (0, import_react18.useLayoutEffect)(() => { + if (explicit) openSection("models"); + complete(); + }, []); + return null; +} +function openHarnessModels() { + openHarnessOnboarding(MODELS_STEP_ID); +} +function openThroughMenu(trigger) { + const before = new Set(document.querySelectorAll('[role="menu"]')); + trigger.click(); + let frames = 0; + const look = () => { + const menu = [...document.querySelectorAll('[role="menu"]')].find((node) => !before.has(node)); + const item = menu?.querySelector('button[role="menuitem"]'); + if (item) item.click(); + else if (++frames < 30) requestAnimationFrame(look); + else pressSettingsShortcut(); }; + requestAnimationFrame(look); } -var teamRow = (role, label, botId, onOpenBot, onOpenDossier) => (0, import_react19.createElement)( - "div", - { - key: role, - className: "bt-project-team-row", - "data-team-role": role, - // A row with no Bot is the fallback the slice specifies: still drawn, with nothing to open. - ...botId === "" ? { "data-team-state": "not-set" } : { - "data-open-bot": botId, - role: "button", - tabIndex: 0, - "aria-label": t("Open {name}'s chat", { name: botId }), - onClick: () => onOpenBot?.(botId), - onKeyDown: (event) => { - if (event.key === "Enter" || event.key === " ") { - event.preventDefault(); - onOpenBot?.(botId); - } - } - } - }, - (0, import_react19.createElement)("span", { key: "label", className: "bt-project-team-label" }, label), - botId === "" ? (0, import_react19.createElement)("span", { key: "value", className: "bt-project-missing" }, t("Not set")) : (0, import_react19.createElement)("span", { key: "value", className: "bt-project-team-bot" }, `@${botId}`), - // #1864: the dossier control sits inside the row, so the row still opens the chat (#1860) and a - // reader can look a Bot up first. A row with no Bot keeps the control: its panel reads "not set". - (0, import_react19.createElement)("button", { - key: "dossier", - type: "button", - className: "bt-project-team-dossier", - "data-dossier-role": role, - ...botId === "" ? {} : { "data-dossier-bot": botId }, - "aria-label": t("Open {name}'s dossier", { name: botId === "" ? label : botId }), - onClick: (event) => { - event?.stopPropagation(); - onOpenDossier?.({ role, label, botId }); - }, - onKeyDown: (event) => { - if (event.key === "Enter" || event.key === " ") { - event?.stopPropagation(); - onOpenDossier?.({ role, label, botId }); - } - } - }, t("Dossier")) -); -var POOL_RANKS = [["owner", "owner"], ["maintainer", "maintainers"], ["member", "members"]]; -function ProjectTeam({ project, onOpenBot, onOpenDossier }) { - const view = projectTeamView(project); - const poolId = (id) => (0, import_react19.createElement)("span", { key: id }, `@${id}`); - const ranks = view.empty ? (0, import_react19.createElement)("span", { className: "bt-project-missing" }, t("No one in this pool yet.")) : (0, import_react19.createElement)( - "span", - { className: "bt-project-team-roles" }, - POOL_RANKS.map(([role, key]) => view.roles[key].length === 0 ? null : (0, import_react19.createElement)( - "span", - { key: role, className: "bt-project-team-role", "data-pool-role": role }, - `${roleText(role)}: `, - view.roles[key].map((id) => poolId(id)) - )) - ); - return (0, import_react19.createElement)( - "section", - { className: "bt-project-team", "data-team": "chief,agent,pool" }, - (0, import_react19.createElement)("h4", null, t("Project team")), - teamRow("chief", t("Project Chief"), view.chief, onOpenBot, onOpenDossier), - teamRow("agent", t("My agent"), view.agent, onOpenBot, onOpenDossier), - (0, import_react19.createElement)( +function pressSettingsShortcut() { + const binding = shellShortcuts?.catalog.getSnapshot().find((row) => row.id === "settings.open")?.binding; + if (!binding) return; + const held = new Set(binding.modifiers); + window.dispatchEvent(new KeyboardEvent("keydown", { + code: binding.code, + key: binding.code === "Comma" ? "," : "", + bubbles: true, + cancelable: true, + ctrlKey: held.has("control"), + altKey: held.has("alt"), + shiftKey: held.has("shift"), + metaKey: held.has("meta") + })); +} +function AccountLauncher({ seat = true, settingsOpen, openSettings, openOnboarding, actions }) { + const [open, setOpen] = (0, import_react18.useState)(false); + (0, import_react18.useLayoutEffect)(() => { + if (!seat) return void 0; + actions.seatAccount(true); + return () => actions.seatAccount(false); + }, [seat]); + const [box, setBox] = (0, import_react18.useState)(null); + const button = (0, import_react18.useRef)(null); + (0, import_react18.useEffect)(() => { + if (settingsOpen) setOpen(false); + }, [settingsOpen]); + (0, import_react18.useEffect)(() => { + if (!openSettings) return void 0; + openShellSettings = openSettings; + return () => { + if (openShellSettings === openSettings) openShellSettings = null; + }; + }, [openSettings]); + (0, import_react18.useEffect)(() => { + if (!openOnboarding) return void 0; + openShellOnboarding = openOnboarding; + return () => { + if (openShellOnboarding === openOnboarding) openShellOnboarding = null; + }; + }, [openOnboarding]); + (0, import_react18.useEffect)(() => { + if (!open) return void 0; + const onDown = (event) => { + if (event.target instanceof Element && (event.target.closest(".bt-account-menu") || button.current?.contains(event.target))) return; + setOpen(false); + }; + const onKey = (event) => { + if (event.key === "Escape") { + event.stopPropagation(); + setOpen(false); + button.current?.focus(); + } + }; + window.addEventListener("mousedown", onDown); + window.addEventListener("keydown", onKey, true); + return () => { + window.removeEventListener("mousedown", onDown); + window.removeEventListener("keydown", onKey, true); + }; + }, [open]); + const toggle = () => { + const rect = button.current?.getBoundingClientRect(); + if (rect) setBox({ left: rect.left, bottom: window.innerHeight - rect.top + 5 }); + setOpen((value) => !value); + }; + const groups = [ + [ + [t("Usage"), GaugeIcon, () => actions.openSettings("usage")], + [t("Connect apps"), ConnectorIcon, () => actions.openConnectors()], + [t("Plugins"), PlugIcon, () => actions.selectPanel("plugins")], + [t("Scheduled tasks"), ClockIcon, () => actions.openSettings("schedules")], + [t("Settings"), GearIcon, () => actions.openSettings("general")] + ], + [ + [t("Switch to classic Agent mode"), SwitchIcon, () => actions.setSurface("agent")] + ] + ]; + return (0, import_react18.createElement)( + import_react18.Fragment, + null, + (0, import_react18.createElement)( + "button", + { ref: button, type: "button", className: "bt-account", "aria-label": t("Account"), "aria-haspopup": "menu", "aria-expanded": open, onClick: toggle }, + (0, import_react18.createElement)("span", { className: "bt-you", "aria-hidden": true }, (0, import_react18.createElement)(BotMark, { look: MAIN_LOOK, size: 24 })) + ), + open && box ? (0, import_react_dom4.createPortal)((0, import_react18.createElement)( "div", - { key: "pool", className: "bt-project-team-row", "data-team-role": "pool" }, - (0, import_react19.createElement)("span", { key: "label", className: "bt-project-team-label" }, t("Role pool")), - ranks, - // The caller's own rank and what the host said they may switch to: the same answer the - // sidebar menu reads, printed where the pool is. - view.role === "" ? null : (0, import_react19.createElement)( - "span", - { key: "mine", className: "bt-project-team-myrole", "data-team-my-role": view.role }, - [ - t("My role"), - ": ", - roleText(view.role), - view.choices.length === 0 ? null : ` · ${t("Can switch to {roles}", { roles: view.choices.map(roleText).join(", ") })}` - ] - ) - ) + { className: "bt-menu bt-menu-up bt-account-menu", role: "menu", "aria-label": t("Account"), style: box }, + (0, import_react18.createElement)(FlyBotAccountHeader, { actions }), + groups.map((group, index) => (0, import_react18.createElement)( + import_react18.Fragment, + { key: index }, + index > 0 ? (0, import_react18.createElement)("div", { className: "bt-menu-sep", role: "separator" }) : null, + group.map(([label, Icon, run, hint]) => (0, import_react18.createElement)("button", { + key: label, + type: "button", + role: "menuitem", + onClick: () => { + setOpen(false); + void Promise.resolve(run()).catch((error) => actions.reportError(error, "menu")); + } + }, (0, import_react18.createElement)(Icon), (0, import_react18.createElement)("span", { className: "bt-menu-label" }, label), hint ? (0, import_react18.createElement)("span", { className: "bt-menu-hint" }, hint) : null)) + )) + ), document.body) : null ); } -function dossierRowOf(team, botId) { - const id = text(botId); - if (id === "") return null; - const rows = Array.isArray(team?.rows) ? team.rows : []; - return rows.find((row) => row !== null && typeof row === "object" && text(row.botId) === id) ?? null; +function useLandOnMainBot(roster, useSessions, actions) { + const mainView = useSessions((list) => { + const row = Object.values(list.byId).find((session) => (session?.retainedBy?.mainView ?? 0) > 0); + return row ? `${row.id}|${row.blank ? 1 : 0}` : ""; + }); + const lastBot = (0, import_react18.useRef)(null); + (0, import_react18.useEffect)(() => { + if (!roster.ready || !roster.mainBotId) return void 0; + const [id, blank3] = mainView.split("|"); + const bot = id === "" ? void 0 : roster.byId[id]; + if (bot !== void 0) { + lastBot.current = { botId: bot.id, sessionId: id }; + if (currentPart(bot) !== id) actions.landOn(bot.id); + return void 0; + } + if (id !== "" && (roster.roomsById[id] || (actions.isAgentSession?.(id) ?? roster.agentOf[id] !== void 0) || blank3 !== "1")) { + lastBot.current = null; + return void 0; + } + const last = lastBot.current; + const timer = setTimeout(() => actions.landOn(roster.mainBotId, last, { stay: id === "", view: id }), last ? 0 : 400); + return () => clearTimeout(timer); + }, [mainView, roster]); } -var dossierSection = (title, children) => (0, import_react19.createElement)( - "div", - { key: title, className: "bt-project-dossier-section" }, - (0, import_react19.createElement)("h4", null, title), - children -); -function ProjectDossierPanel({ row, label, onClose, onOpenBot } = {}) { - const known = row !== null && typeof row === "object"; - const botId = known ? text(row.botId) : ""; - const topics = known && Array.isArray(row.memory?.topics) ? row.memory.topics.map((name) => text(name)).filter((name) => name !== "") : []; - const skills = known && Array.isArray(row.skills) ? row.skills.map((name) => text(name)).filter((name) => name !== "") : []; - const meta = known ? [text(row.role), text(row.model)].filter((value) => value !== "") : []; - return (0, import_react19.createElement)( - "div", - { className: "bt-project-panel bt-project-dossier", "data-panel": "project-dossier", "data-dossier-bot": botId === "" ? void 0 : botId }, - (0, import_react19.createElement)("h3", null, label === "" || label === void 0 ? t("Dossier") : `${label} · ${t("Dossier")}`), - known ? [ - dossierSection(t("Persona"), [ - meta.length === 0 ? null : (0, import_react19.createElement)("div", { key: "meta", className: "bt-project-dossier-meta" }, meta.map((value) => (0, import_react19.createElement)("span", { key: value }, value))), - (0, import_react19.createElement)("p", { key: "persona", className: "bt-project-dossier-text" }, text(row.persona) || t("Not set")) - ]), - dossierSection(t("Memory"), [ - (0, import_react19.createElement)("p", { key: "head", className: "bt-project-dossier-text" }, text(row.memory?.head) || t("No memory yet.")), - topics.length === 0 ? null : (0, import_react19.createElement)("div", { key: "topics", className: "bt-project-dossier-topics" }, topics.map((topic) => (0, import_react19.createElement)("span", { key: topic, className: "bt-project-dossier-topic" }, topic))) - ]), - dossierSection(t("Skills"), (0, import_react19.createElement)("p", { key: "skills", className: "bt-project-dossier-text" }, skills.length === 0 ? t("No skills attached.") : skills.join(", "))) - ] : (0, import_react19.createElement)("p", { className: "bt-project-missing" }, t("Not set")), - botId === "" || !known ? null : (0, import_react19.createElement)( - "div", - { key: "actions", className: "bt-project-row-actions" }, - (0, import_react19.createElement)("button", { - type: "button", - className: "bt-soft bt-soft-sm", - "data-open-bot": botId, - onClick: () => onOpenBot?.(botId) - }, t("Open chat")) - ), - (0, import_react19.createElement)( - "div", - { className: "bt-project-row-actions" }, - (0, import_react19.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: () => onClose?.() }, t("Back to board")) - ) +function FooterAccount(props) { + const seated = props.useUi((value) => value.accountSeated === true); + if (seated) return null; + return (0, import_react18.createElement)("span", { className: "bt-foot-account" }, (0, import_react18.createElement)(AccountLauncher, { ...props, seat: false })); +} +function BotReturn({ wide, useSurface, actions }) { + const mode = useSurface((value) => value.mode); + if (mode !== "agent") return null; + return (0, import_react18.createElement)( + "button", + { type: "button", className: "bt-return", "aria-label": t("FlyBot"), title: t("FlyBot"), onClick: () => actions.setSurface("bot") }, + (0, import_react18.createElement)(FlyBotMark, { size: 20 }), + wide ? (0, import_react18.createElement)("span", { className: "bt-return-name" }, t("FlyBot")) : null ); } -function projectAgentFor(bot, selfId) { - if (bot === null || typeof bot !== "object" || bot.remote !== void 0) return null; - const projectId = text(bot.projectId); - if (projectId === "") return null; - const owner = text(bot.ownerId); - const self = text(selfId); - if (self !== "" && owner !== "" && owner !== self) return null; - return { projectId }; +var RETURN_CSS = ` +.bt-return{display:inline-flex;align-items:center;justify-content:center;gap:8px;min-height:36px;padding:0 14px;border:0;border-radius:999px;background:none;color:inherit;font:inherit;font-size:14px;line-height:20px;cursor:pointer;transition:background-color .12s ease} +.bt-return:hover{background:rgba(127,127,127,.14)} +.bt-return:active{transform:scale(.96)} +.bt-return .bt-mark,.bt-return .bt-flybot-mark{display:inline-flex;flex:none;line-height:0} +.bt-return .bt-mark svg,.bt-return .bt-flybot-mark{width:20px;height:20px;overflow:visible} +/* Shell styles carry --bt-ink-deepseek and Agent mode withdraws them; pin it here. */ +.bt-return .bt-mark{--bt-ink-deepseek:#4D6BFE} +.bt-return-name{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} +`; +function BrandMark({ size = 24 }) { + return (0, import_react18.createElement)("span", { className: "bt-brand-mark" }, (0, import_react18.createElement)(FlyBotMark, { size })); } -function ProjectAgentEntry({ projectId, read, onOpen } = {}) { - const [agentId, setAgentId] = (0, import_react19.useState)(""); - const id = text(projectId); - const open = async () => { - let target = agentId; - if (target === "") { - try { - const record = await (read ?? call)("project-read", { id }); - target = botIdOf(record?.project?.assistant); - } catch { - target = ""; - } - if (target !== "") setAgentId(target); - } - if (target !== "") onOpen?.(target); - }; - if (id === "") return null; - return (0, import_react19.createElement)("button", { - type: "button", - className: "bt-project-agent-entry", - "data-project-agent": id, - "data-open-bot": agentId === "" ? void 0 : agentId, - title: t("Open my agent"), - "aria-label": t("Open my agent"), - onClick: open - }, t("My agent")); +function BrandName() { + return (0, import_react18.createElement)("span", { style: { fontWeight: 600, fontSize: 14 } }, t("FlyBot")); } -function ProjectBoardPane({ project, rows, done, canManage, tasks, decisions, meetings, queues, metrics, onOpen, onSettings, onMembers, onOpenBot, onOpenDossier, call: call4 }) { - const view = projectBoardView({ project, rows, done, canManage, tasks, decisions, meetings, queues, metrics }); - return (0, import_react19.createElement)( - "div", - { className: "bt-project-board", "data-can-manage": view.canManage ? "true" : "false" }, - (0, import_react19.createElement)("h3", null, view.title), - (0, import_react19.createElement)(ProjectTeam, { project, onOpenBot, onOpenDossier }), - // Header counts (#1768): four numbers read from the host, an unreadable one shown as unknown. - view.counts === null ? null : (0, import_react19.createElement)( - "div", - { className: "bt-project-counts", "data-counts": "waiting,inflight,blocked,done" }, - (0, import_react19.createElement)("span", null, [t("Waiting"), ": ", (0, import_react19.createElement)("b", { key: "w" }, countText(view.counts.waiting))]), - (0, import_react19.createElement)("span", null, [t("In flight"), ": ", (0, import_react19.createElement)("b", { key: "f" }, countText(view.counts.inflight))]), - (0, import_react19.createElement)("span", null, [t("Blocked"), ": ", (0, import_react19.createElement)("b", { key: "b" }, countText(view.counts.blocked))]), - (0, import_react19.createElement)("span", null, [t("Done"), ": ", (0, import_react19.createElement)("b", { key: "d" }, countText(view.counts.done))]) - ), - // #1749: both buttons open a panel and neither is hidden from a non-owner. The host refuses - // every write a non-owner attempts, so the refusal is an answer the reader sees, not a - // control that silently disappeared. - (0, import_react19.createElement)( - "div", - { className: "bt-project-actions" }, - (0, import_react19.createElement)("button", { type: "button", "data-action": "project-settings", onClick: () => onSettings?.() }, t("Project settings")), - (0, import_react19.createElement)("button", { type: "button", "data-action": "project-members", onClick: () => onMembers?.() }, t("Members")) - ), - (0, import_react19.createElement)(ProjectMetrics, { view: view.metrics }), - (0, import_react19.createElement)(ProjectRunningTable, { view: view.metrics, onOpen }), - (0, import_react19.createElement)(ProjectBoardRows, { view, onOpen }), - (0, import_react19.createElement)(ProjectQueueGroups, { view, onOpen }), - (0, import_react19.createElement)(ProjectDoneGroup, { view, onOpen }), - (0, import_react19.createElement)(ProjectBoardRecord, { record: view.record, projectId: view.projectId, call: call4 }) - ); +var SIDEBAR_CSS = ` +.bt-side-top .bt-brand{cursor:default;padding-left:6px} +.bt-side-top .bt-brand:hover{background:none} +.bt-brand .bt-mark{flex:none} +.bt-pin{flex:none;display:inline-flex;color:var(--bt-ink-3)} +.bt-rail .bt-row{position:relative} +.bt-rail .bt-pin{position:absolute;top:3px;left:calc(50% + 12px)} +.bt-side .bt-read-only{margin:4px 8px 6px;padding:8px 10px;border-radius:10px;background:var(--bt-hover);font-size:12px;line-height:1.45;text-align:left} +.bt-rail .bt-read-only{width:100%;margin:4px 0 6px;padding:4px 2px;font-size:10px;line-height:1.3;text-align:center;white-space:nowrap;overflow:hidden;text-overflow:ellipsis} +[class*="_footArea"]:has(.bt-foot-account){display:flex!important;flex-direction:row!important;align-items:center;gap:6px} +[class*="_footArea"]:has(.bt-foot-account)>[class*="_footerActions"],[class*="_footArea"]:has(.bt-foot-account)>[class*="_footerActions"]>div{display:contents!important} +.bt-foot-account{order:-1;flex:none;display:inline-flex} +[class*="_footArea"]:has(.bt-foot-account) .bt-connect-foot{order:0;flex:1 1 auto;min-width:0;width:auto} +[class*="_footArea"]:has(.bt-foot-account)>[class*="_settingsArea"]{order:1;flex:none} +[class*="_footArea"]:has(.bt-foot-account)>[class*="_settingsArea"] button[class*="_trigger"] [class*="_label"]{display:none!important} + +/* The update cloud springs in when a newer DS Bot shows up; its arrow drops in a + loop while the install runs. */ +.bt-round.bt-update{position:relative;color:var(--bt-accent,#4D6BFE);animation:bt-update-in ${SPRING_SHAPE.duration}ms ${SPRING_SHAPE.easing}} +.bt-round.bt-update svg{width:17px;height:17px} +.bt-round.bt-update[data-phase=installed]{color:var(--bt-ink-2)} +@keyframes bt-update-in{from{opacity:0;transform:scale(.6);filter:blur(4px)}} +.bt-update[data-phase=installing] .bt-cloud-arrow{animation:bt-cloud-drop 1.1s cubic-bezier(.45,0,.55,1) infinite} +@keyframes bt-cloud-drop{0%{transform:translateY(-3px);opacity:0}35%{opacity:1}70%{transform:translateY(1px);opacity:1}100%{transform:translateY(2px);opacity:0}} +.bt-update-dot{position:absolute;top:6px;right:6px;width:7px;height:7px;border-radius:50%;background:#E5484D;box-shadow:0 0 0 1.5px var(--bt-sidebar)} +.bt-update-pop{position:fixed;z-index:130;width:280px;padding:14px 14px 12px;border-radius:14px;background:var(--bt-main);border:.5px solid var(--bt-line-2);box-shadow:0 10px 20px -3px rgba(0,0,0,.1),0 4px 6px -4px rgba(0,0,0,.1);color:var(--bt-ink);box-sizing:border-box;transform-origin:top left;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",system-ui,sans-serif;animation:bt-update-pop ${SPRING_SHAPE.duration}ms ${SPRING_SHAPE.easing}} +@keyframes bt-update-pop{from{opacity:0;transform:scale(.96);filter:blur(4px)}} +.bt-update-title{font-size:14px;line-height:20px;font-weight:600} +.bt-update-text{margin-top:2px;font-size:12.5px;line-height:18px;color:var(--bt-ink-2)} +.bt-update-error{margin-top:8px;font-size:12px;line-height:17px;color:#c21d2e;overflow-wrap:anywhere;max-height:90px;overflow:auto} +.bt-update-actions{display:flex;align-items:center;justify-content:flex-end;gap:10px;margin-top:12px} +.bt-update-notes{margin-right:auto;font-size:12.5px;color:var(--bt-ink-2);text-decoration:none} +.bt-update-notes:hover{color:var(--bt-ink);text-decoration:underline} +.bt-update-go{height:30px;padding:0 14px;border:0;border-radius:999px;background:var(--bt-accent,#4D6BFE);color:var(--bt-accent-ink,#fff);font:inherit;font-size:13px;font-weight:500;cursor:pointer;transition:opacity .12s ease,transform .12s ease} +.bt-update-go:hover:not(:disabled){opacity:.9} +.bt-update-go:active:not(:disabled){transform:scale(.96)} +.bt-update-go:disabled{opacity:.6;cursor:default} +.bt-update-dsh:not(:first-child){margin-top:12px;padding-top:12px;border-top:.5px solid var(--bt-line-2)} +@media (prefers-reduced-motion:reduce){.bt-round.bt-update,.bt-update-pop,.bt-update .bt-cloud-arrow{animation:none}} + +/* The DSH Agent row and its expanding Session list. The black whale is the + harness's own mark, so the row reads as "plain dsh" next to the Bot marks. */ +.bt-agent-ava{flex:none;width:36px;height:36px;border-radius:50%;display:inline-flex;align-items:center;justify-content:center;background:var(--bt-hover);color:var(--bt-ink)} +.bt-agent-ava.bt-run{animation:bt-agent-pulse 1.4s ease-in-out infinite} +@keyframes bt-agent-pulse{0%,100%{opacity:1}50%{opacity:.55}} +.bt-agent-sub{display:grid;grid-template-rows:0fr;transition:grid-template-rows ${SPRING_SHAPE.duration}ms ${SPRING_SHAPE.easing}} +.bt-agent-sub[data-open]{grid-template-rows:1fr} +.bt-agent-sub-in{overflow:hidden;min-height:0;margin-left:44px} +.bt-agent-sub-row{display:flex;align-items:center;gap:8px;width:100%;padding:5px 10px 5px 8px;border:0;border-radius:8px;background:none;color:var(--bt-ink-2);font:inherit;font-size:13px;line-height:20px;text-align:left;cursor:pointer} +.bt-agent-sub-row:hover{background:var(--bt-hover);color:var(--bt-ink)} +.bt-agent-sub-row[aria-current=page]{background:var(--bt-hover);color:var(--bt-ink);font-weight:500} +.bt-agent-sub-title{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} +.bt-agent-sub-time{flex:none;font-size:11px;color:var(--bt-ink-3)} +.bt-agent-new{color:var(--bt-ink-3)} +.bt-agent-new svg{width:14px;height:14px;flex:none} +.bt-agent-edit{padding:3px 10px 3px 4px} +.bt-agent-edit input{width:100%;border:0;border-radius:6px;background:var(--bt-hover);color:var(--bt-ink);font:inherit;font-size:13px;line-height:20px;padding:2px 8px;outline:none;box-shadow:inset 0 0 0 .5px var(--bt-line-2)} +.bt-agent-sub[data-open] .bt-agent-sub-row{animation:bt-agent-row-in .24s ease both;animation-delay:calc(var(--i, 0)*30ms)} +@keyframes bt-agent-row-in{from{opacity:0;filter:blur(4px)}to{opacity:1;filter:blur(0)}} +@media (prefers-reduced-motion:reduce){.bt-agent-sub{transition:none}.bt-agent-sub .bt-agent-sub-row{animation:none}} + +.bt-row-wrap{position:relative;display:flex;align-items:center;width:100%;min-width:0} +.bt-row-wrap>.bt-row{flex:1 1 auto;min-width:0;width:100%} +.bt-tree-item>.bt-row-wrap{flex:1 1 auto;width:auto;min-width:0} +/* Gear sits over the row so hover/selected fill the same full-width band as a plain row (#384). */ +.bt-row-settings{position:absolute;right:6px;top:50%;transform:translateY(-50%);z-index:1;flex:none;display:inline-flex;align-items:center;justify-content:center;width:28px;height:28px;padding:0;border:0;border-radius:8px;background:transparent;color:var(--bt-ink-3);cursor:pointer;opacity:0;pointer-events:none;transition:background-color .12s ease,color .12s ease,opacity .12s ease} +.bt-row-wrap:hover>.bt-row,.bt-row-wrap:focus-within>.bt-row{padding-right:36px} +.bt-row-wrap:hover .bt-row-settings,.bt-row-wrap:focus-within .bt-row-settings{opacity:1;pointer-events:auto;color:var(--bt-ink-2);background:var(--bt-hover)} +.bt-row-settings:hover{opacity:1;color:var(--bt-ink-2);background:var(--bt-hover)} +.bt-row-settings svg{width:15px;height:15px} +`; + +// src/client/busy-ack-logic.js +function busyAckText(hostAck) { + return typeof hostAck?.text === "string" ? hostAck.text.trim() : ""; } -function ProjectSettingsPanel({ project, assistantMine, call: request, onClose, onSaved } = {}) { - const save = request ?? call; - const [kind, setKind] = (0, import_react19.useState)("bot"); - const [memberId, setMemberId] = (0, import_react19.useState)(""); - const [note, setNote] = (0, import_react19.useState)(""); - const noteLength = memoryLength(note); - const noteOver = noteLength > MEMORY_TEXT_MAX; - const noteSaveDisabled = text(note) === "" || noteOver; - const [assistantOff, setAssistantOff] = (0, import_react19.useState)(assistantMine === void 0 ? project?.assistant?.off === true : assistantMine?.off === true); - const [status, setStatus] = (0, import_react19.useState)(""); - const id = text(project?.id); - const run = async (endpoint, payload, done) => { - try { - const result = await save(endpoint, payload); - setStatus(done); - onSaved?.(result); - return result; - } catch (error) { - setStatus(hostText(error?.message ?? String(error))); - return null; - } - }; - return (0, import_react19.createElement)( - "div", - { className: "bt-project-panel", "data-panel": "project-settings" }, - (0, import_react19.createElement)("h3", null, t("Project settings")), - (0, import_react19.createElement)( - "div", - { className: "bt-project-field" }, - (0, import_react19.createElement)("label", { htmlFor: "bt-project-member-id" }, t("Bind a member to this Project")), - (0, import_react19.createElement)( - "div", - { className: "bt-project-row-actions" }, - (0, import_react19.createElement)( - "select", - { "aria-label": t("Member kind"), value: kind, onChange: (event) => setKind(event.target.value) }, - (0, import_react19.createElement)("option", { value: "bot" }, t("Bot")), - (0, import_react19.createElement)("option", { value: "group" }, t("Group")) - ), - (0, import_react19.createElement)("input", { id: "bt-project-member-id", value: memberId, placeholder: t("Member id"), onChange: (event) => setMemberId(event.target.value) }), - (0, import_react19.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", "data-action": "project-bind", onClick: () => run("project-bind", { kind, id: text(memberId), projectId: id }, t("Member bound.")) }, t("Bind")), - (0, import_react19.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", "data-action": "project-unbind", onClick: () => run("project-bind", { kind, id: text(memberId), projectId: null }, t("Member unbound.")) }, t("Unbind")) - ) - ), - (0, import_react19.createElement)( - "div", - { className: "bt-project-field" }, - (0, import_react19.createElement)("label", null, t("Project Assistant")), - (0, import_react19.createElement)( - "div", - { className: "bt-project-row-actions" }, - (0, import_react19.createElement)("label", null, (0, import_react19.createElement)("input", { type: "checkbox", "data-action": "assistant-off", checked: assistantOff, onChange: (event) => setAssistantOff(event.target.checked) }), " ", t("Pause the daily digest")), - (0, import_react19.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", "data-action": "project-assistant-save", onClick: () => run("project-assistant-owner", { id, settings: { off: assistantOff === true } }, t("Assistant settings saved.")) }, t("Save")) - ) - ), - (0, import_react19.createElement)( - "div", - { className: "bt-project-field" }, - // The host knows two memory kinds; the free text the panel takes is recorded as a decision, - // never as a third kind the host would refuse (#1820). - (0, import_react19.createElement)("label", { htmlFor: "bt-project-note" }, t("Add to the assistant memory")), - (0, import_react19.createElement)("textarea", { id: "bt-project-note", value: note, onChange: (event) => setNote(event.target.value) }), - (0, import_react19.createElement)("div", { className: "bt-project-count", "data-role": "project-memory-count", "data-over": noteOver ? "true" : "false" }, `${noteLength}/${MEMORY_TEXT_MAX}`), - (0, import_react19.createElement)( - "div", - { className: "bt-project-row-actions" }, - (0, import_react19.createElement)("button", { - type: "button", - className: "bt-soft bt-soft-sm", - "data-action": "project-memory-save", - disabled: noteSaveDisabled, - // A disabled button draws no click, but the guard is what a keyboard or a stale tree - // could still reach: neither state sends a request or writes anything. - onClick: () => { - if (noteSaveDisabled) return; - run("project-memory-write", { id, kind: "decision", text: text(note) }, t("Memory saved.")); - } - }, t("Save memory")) - ) - ), - (0, import_react19.createElement)("div", { className: "bt-project-status-line", role: "status" }, status), - (0, import_react19.createElement)( - "div", - { className: "bt-project-row-actions" }, - (0, import_react19.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: () => onClose?.() }, t("Back to board")) - ) - ); +function busyAckPending(hostAck) { + return busyAckText(hostAck) === ""; } -function ProjectMembersPanel({ project, rows, onClose } = {}) { - const owners = []; - const add = (id) => { - const clean = text(id); - if (clean !== "" && !owners.includes(clean)) owners.push(clean); - }; - add(project?.ownerUserId); - for (const id of Array.isArray(project?.ownerUserIds) ? project.ownerUserIds : []) add(id); - const boardMembers = []; - for (const row of Array.isArray(rows) ? rows : []) { - const name = text(row?.assignee); - if (name !== "" && !boardMembers.includes(name)) boardMembers.push(name); - } - const chief = text(project?.chief?.botId); - return (0, import_react19.createElement)( - "div", - { className: "bt-project-panel", "data-panel": "project-members" }, - (0, import_react19.createElement)("h3", null, t("Members")), - (0, import_react19.createElement)( - "ul", - { className: "bt-project-members" }, - owners.map((owner) => (0, import_react19.createElement)( - "li", - { key: `owner:${owner}`, className: "bt-project-member", "data-member-role": "owner", "data-member-id": owner }, - (0, import_react19.createElement)("span", null, `@${owner}`), - (0, import_react19.createElement)("span", { className: "bt-project-member-kind" }, t("Owner")) - )), - chief === "" ? null : (0, import_react19.createElement)( - "li", - { key: `chief:${chief}`, className: "bt-project-member", "data-member-role": "chief", "data-member-id": chief }, - (0, import_react19.createElement)("span", null, t("Project Chief")), - (0, import_react19.createElement)("span", { className: "bt-project-member-kind" }, t("Chief of this Project")) - ), - boardMembers.map((name) => (0, import_react19.createElement)( - "li", - { key: `board:${name}`, className: "bt-project-member", "data-member-role": "board", "data-member-id": name }, - (0, import_react19.createElement)("span", null, `@${name}`), - (0, import_react19.createElement)("span", { className: "bt-project-member-kind" }, t("On the board")) - )) - ), - owners.length === 0 && boardMembers.length === 0 && chief === "" ? (0, import_react19.createElement)("div", { className: "bt-project-empty" }, t("No member is named yet.")) : null, - (0, import_react19.createElement)("p", { className: "bt-project-note" }, t("Member management is not part of this slice; owners and the members their cards name are listed here.")), - (0, import_react19.createElement)( - "div", - { className: "bt-project-row-actions" }, - (0, import_react19.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: () => onClose?.() }, t("Back to board")) - ) - ); +function pendingBusyPlacement(submissions) { + if (!Array.isArray(submissions)) return false; + return submissions.some((item) => item?.placement === "queued" || item?.placement === "steering"); } -async function readProjectBoard(read, wanted, knownTeam) { - const listing = await read("projects"); - const projects = Array.isArray(listing?.projects) ? listing.projects : []; - const project = wanted === "" ? projects[0] : projects.find((item) => item.id === wanted); - if (project === void 0) return { project: null, rows: [], done: [], canManage: false, tasks: [], team: { rows: [] }, teamFresh: true }; - const zone = typeof Intl === "undefined" || typeof Intl.DateTimeFormat !== "function" ? void 0 : Intl.DateTimeFormat().resolvedOptions().timeZone; - const [record, board, taskList, dossier] = await Promise.all([ - read("project-read", { id: project.id }), - read("project-board", { id: project.id, timeZone: zone }), - read("tasks", {}), - // #1910: #1902 turned this failure into an ordinary empty team, which the poll then served for - // the rest of the open. Keep the two answers apart: a refusal is not fresh, so nothing is cached - // and the next tick asks again, while a real `{ rows: [] }` answer is fresh and frozen as usual. - knownTeam !== void 0 ? { answer: knownTeam, fresh: true } : read("project-team", { id: project.id }).then( - (answer) => ({ answer, fresh: answer !== null && typeof answer === "object" }), - () => ({ answer: null, fresh: false }) - ) - ]); - const tasks = Array.isArray(taskList) ? taskList : taskList?.tasks ?? []; - const queues = { waiting: board?.waiting, inflight: board?.inflight, blocked: board?.blocked, counts: board?.counts ?? null }; - const base = record?.project ?? project; - const withRole = { ...base, role: project.role ?? null, choices: Array.isArray(project.choices) ? project.choices : [] }; - return { - project: withRole, - assistantMine: record?.assistantMine ?? null, - rows: board?.tasks ?? [], - done: board?.done ?? [], - canManage: record?.canManage === true, - tasks, - decisions: board?.decisions, - meetings: board?.meetings, - queues, - metrics: board?.metrics ?? null, - team: dossier.answer !== null && typeof dossier.answer === "object" ? dossier.answer : { rows: [] }, - teamFresh: dossier.fresh - }; +function busyAckVisible({ hostAck, pendingBusy, streamingText }) { + if (streamingText) return false; + return busyAckText(hostAck) !== "" || pendingBusy === true; } -var PROJECT_BOARD_POLL_MS = 5e3; -var boardPollPaused = () => typeof document !== "undefined" && document.visibilityState === "hidden" || typeof window !== "undefined" && !window.location.hash.startsWith("#project"); -function ProjectBoardOverlay({ actions, call: request } = {}) { - const read = request ?? call; - const [state, setState] = (0, import_react19.useState)(null); - const [panel, setPanel] = (0, import_react19.useState)(null); - const [dossier, setDossier] = (0, import_react19.useState)(null); - const team = (0, import_react19.useRef)(void 0); - const teamGen = (0, import_react19.useRef)(0); - const [hash, setHash] = (0, import_react19.useState)(() => typeof window === "undefined" ? "" : window.location.hash); - const close = (0, import_react19.useCallback)(() => { - if (typeof window !== "undefined") window.location.hash = ""; - setPanel(null); - setState(null); - }, []); - (0, import_react19.useEffect)(() => { - if (typeof window === "undefined") return void 0; - const onKey = (event) => { - if (event.key !== "Escape") return; - if (panel !== null) setPanel(null); - else close(); - }; - window.addEventListener("keydown", onKey); - return () => window.removeEventListener("keydown", onKey); - }, [panel, close]); - (0, import_react19.useEffect)(() => { - if (typeof window === "undefined") return void 0; - const sync = () => setHash(window.location.hash); - window.addEventListener("hashchange", sync); - sync(); - return () => window.removeEventListener("hashchange", sync); - }, []); +function roomBusyAck(room2) { + const entries2 = Object.entries(room2?.busyAcks ?? {}).filter(([, ack2]) => ack2 !== void 0 && ack2 !== null); + if (entries2.length === 0) return null; + const [botId, ack] = entries2.sort((a, b) => (b[1].at ?? 0) - (a[1].at ?? 0))[0]; + return { botId, text: busyAckText(ack), at: ack.at ?? 0 }; +} +function roomBusyAckVisible({ ack, pendingBusy }) { + if (ack === null || ack === void 0) return false; + return ack.text !== "" || pendingBusy === true; +} + +// src/client/busy-ack.js +var import_react19 = require("react"); +var import_react_dom5 = require("react-dom"); +function useRunningHost2(active) { + const [host, setHost] = (0, import_react19.useState)(null); (0, import_react19.useEffect)(() => { - if (typeof window === "undefined") return void 0; - if (!hash.startsWith("#project")) { - setState(null); + if (!active) { + setHost(null); return void 0; } - const wanted = hash.slice("#project".length).replace(/^=/, ""); - let gone = false; - team.current = void 0; - teamGen.current = 0; - const loadBoard = () => readProjectBoard(read, wanted, team.current); - const refresh = async (first) => { - const gen = teamGen.current; - try { - const next = await loadBoard(); - if (!gone) { - const newer = teamGen.current !== gen; - if (next.teamFresh !== false && !newer) team.current = next.team; - setState(newer && team.current !== void 0 ? { ...next, team: team.current } : next); - } - } catch { - if (!gone && first) setState({ project: null, rows: [], canManage: false, tasks: [] }); - } + let frame = 0; + const find = () => { + frame = 0; + const node = document.querySelector(RUNNING_HOST); + setHost((prev) => prev === node ? prev : node); }; - void refresh(true); - const timer = setInterval(() => { - if (!boardPollPaused()) void refresh(false); - }, PROJECT_BOARD_POLL_MS); + const observer = new MutationObserver(() => { + if (!frame) frame = requestAnimationFrame(find); + }); + observer.observe(document.querySelector("[data-conversation-scroll]") ?? document.body, { childList: true, subtree: true }); + find(); return () => { - gone = true; - clearInterval(timer); + cancelAnimationFrame(frame); + observer.disconnect(); }; - }, [hash]); - const openDossier = async (value) => { - setDossier(value); - setPanel("dossier"); - const id = state?.project?.id; - if (typeof id !== "string" || id === "") return; - const gen = teamGen.current + 1; - teamGen.current = gen; - try { - const answer = await read("project-team", { id }); - if (teamGen.current !== gen || answer === null || typeof answer !== "object") return; - team.current = answer; - setState((previous) => previous === null ? previous : { ...previous, team: answer }); - } catch { - } - }; - if (state === null) return null; - return (0, import_react19.createElement)( + }, [active]); + return host; +} +function BusyAckBubble({ sessionId, useRoster, useBusyAcks, useSessionStatus, useSession, useLive }) { + const roster = useRoster((value) => value); + const room2 = roster.roomsById?.[sessionId]; + const memberAck = room2 ? roomBusyAck(room2) : null; + const hostAck = useBusyAcks((map) => map?.[sessionId] ?? null); + const running = useSessionStatus((map) => map.get(sessionId)?.running === true); + const pendingBusy = useSession((snapshot) => pendingBusyPlacement(snapshot?.pendingSubmissions)); + const streamingText = useLive((state) => Object.values(state[sessionId] ?? {}).some((entry) => entry?.text)); + const visible = room2 ? roomBusyAckVisible({ ack: memberAck, pendingBusy }) : running && busyAckVisible({ hostAck, pendingBusy, streamingText }); + const bot = room2 ? roster.byId[memberAck?.botId] : roster.byId[sessionId]; + const host = useRunningHost2(visible); + const shown = (0, import_react19.useRef)(null); + if (visible) shown.current = room2 ? { text: memberAck?.text ?? "", at: memberAck?.at ?? Date.now() } : { text: busyAckText(hostAck), at: hostAck?.at ?? Date.now() }; + const text2 = shown.current?.text ?? ""; + if (!visible || !host || !shown.current || !bot) return null; + return (0, import_react_dom5.createPortal)((0, import_react19.createElement)( "div", - { className: "bt-project-overlay" }, - (0, import_react19.createElement)("div", { className: "bt-scrim", onClick: close }), + { + className: "bt-busy-ack", + "data-flybot": "busy-ack", + role: "status", + "aria-live": "polite", + "aria-busy": text2 ? void 0 : "true", + "data-pending": text2 ? void 0 : "" + }, + (0, import_react19.createElement)(BotMark, { bot, size: 28, state: "thinking", live: true }), (0, import_react19.createElement)( "div", - { className: "bt-mem bt-project-dialog", role: "dialog", "aria-modal": true, "aria-label": t("Project board") }, - (0, import_react19.createElement)( - "div", - { className: "bt-mem-head" }, - (0, import_react19.createElement)("h2", {}, t("Project board")), - (0, import_react19.createElement)("span", { className: "bt-mem-head-gap" }), - (0, import_react19.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm bt-project-close", autoFocus: true, onClick: close }, t("Close")) - ), + { className: "bt-astack" }, (0, import_react19.createElement)( "div", - { className: "bt-project-body" }, - state.project === null ? (0, import_react19.createElement)( + { className: "bt-msg" }, + (0, import_react19.createElement)( "div", - { className: "bt-project-empty" }, - (0, import_react19.createElement)("p", null, t("No Project yet — create one.")), - (0, import_react19.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: () => { - close(); - actions?.openSettings?.("projects"); - } }, t("Open Projects in Settings")) - ) : panel === "dossier" ? (0, import_react19.createElement)(ProjectDossierPanel, { - row: dossierRowOf(state.team, dossier?.botId), - label: dossier?.label ?? "", - onClose: () => setPanel(null), - onOpenBot: (id) => { - close(); - actions?.openSession?.(id); - } - }) : panel === "settings" ? (0, import_react19.createElement)(ProjectSettingsPanel, { project: state.project, assistantMine: state.assistantMine, call: read, onClose: () => setPanel(null) }) : panel === "members" ? (0, import_react19.createElement)(ProjectMembersPanel, { project: state.project, rows: state.rows, onClose: () => setPanel(null) }) : (0, import_react19.createElement)(ProjectBoardPane, { - project: state.project, - rows: state.rows, - done: state.done, - canManage: state.canManage, - tasks: state.tasks, - decisions: state.decisions, - meetings: state.meetings, - queues: state.queues, - metrics: state.metrics, - team: state.team, - // #2041: a decision row opens through the same channel read the page polls with. - call: read, - onSettings: () => setPanel("settings"), - onMembers: () => setPanel("members"), - // #1864: a team row's Dossier control opens the read-only panel, fed by the same read. - onOpenDossier: (value) => { - void openDossier(value); - }, - // #1860: a team row opens that Bot's chat. The board is a modal over the conversation, - // so it closes first — otherwise the chat opens behind it. - onOpenBot: (id) => { - close(); - actions?.openSession?.(id); - }, - // The task's own board is where a row leads: the Tasks dialog reads `#task=` on mount and - // lands on that Bot or group. Nothing here re-decides which board that is. - onOpen: (board) => { - if (typeof window !== "undefined") window.location.hash = `#task=${board.kind}:${board.id}`; - actions?.openBoard?.(); - setState(null); - } - }) + { className: "bt-msg-line" }, + text2 ? (0, import_react19.createElement)("div", { className: "bt-bubble" }, text2) : (0, import_react19.createElement)( + "div", + { className: "bt-bubble bt-busy-dots", "aria-hidden": "true" }, + (0, import_react19.createElement)("span", null), + (0, import_react19.createElement)("span", null), + (0, import_react19.createElement)("span", null) + ) + ) ) ) - ); + ), host); } +var BUSY_ACK_CSS = ` +.bt-busy-ack{display:flex;align-items:flex-end;gap:8px;width:100%;margin-top:var(--dsh-chat-flow-gap,12px);margin-bottom:4px} +.bt-busy-ack .bt-astack{align-items:flex-start} +.bt-busy-ack .bt-bubble{opacity:.92} +.bt-busy-dots{display:inline-flex;align-items:center;gap:4px;height:22px} +.bt-busy-dots>span{width:5px;height:5px;border-radius:50%;background:var(--bt-ink-3);animation:bt-busy-blink 1.2s ease-in-out infinite} +.bt-busy-dots>span:nth-child(2){animation-delay:.2s}.bt-busy-dots>span:nth-child(3){animation-delay:.4s} +@keyframes bt-busy-blink{0%,80%,100%{opacity:.25}40%{opacity:1}} +@media (prefers-reduced-motion:reduce){.bt-busy-dots>span{animation:none;opacity:.6}} +`; -// src/client/header.js +// src/client/project-board.js var import_react20 = require("react"); -var import_react_dom6 = require("react-dom"); -var GoArrow = () => (0, import_react20.createElement)("svg", { width: 14, height: 14, viewBox: "-12 -12 24 24", fill: "none", stroke: "currentColor", strokeWidth: 2.2, strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": true }, (0, import_react20.createElement)("path", { d: "M-7 0H7M2 -5L7 0L2 5" })); -var MEMORY_NOTICE_MS = 3200; -var noNews = (select) => select({}); -function useMemoryNotice(ids, useMemoryNews) { - const seen = useMemoryNews((map) => Math.max(0, ...ids.map((id) => map?.[id]?.seen ?? 0))); - const [shown, setShown] = (0, import_react20.useState)(false); - (0, import_react20.useEffect)(() => { - const left = seen + MEMORY_NOTICE_MS - Date.now(); - setShown(seen > 0 && left > 0); - if (seen === 0 || left <= 0) return void 0; - const timer = setTimeout(() => setShown(false), left); - return () => clearTimeout(timer); - }, [seen]); - return shown; -} -function MemoryNotice({ shown }) { - const inner = (0, import_react20.useRef)(null); - const [width, setWidth] = (0, import_react20.useState)(0); - const label = t("Memory updated"); - (0, import_react20.useLayoutEffect)(() => { - if (shown) setWidth(inner.current?.scrollWidth ?? 0); - }, [label, shown]); - return (0, import_react20.createElement)( - "span", - { className: "bt-pill-mem", "aria-hidden": true, style: { "--bt-mem-w": `${width}px` } }, - (0, import_react20.createElement)("span", { className: "bt-pill-mem-in", ref: inner }, (0, import_react20.createElement)("span", { className: "bt-pill-mem-ico" }, (0, import_react20.createElement)(MemoryIcon)), label) - ); -} -function HeaderPill({ sessionId, useRoster, useSessions, useSessionStatus, useSession, useActivity, useBusyAcks, useMemoryNews, useTurnClock, useUi, useLive, actions }) { - const roster = useRoster((value) => value); - const title = useSessions((list) => list.byId[sessionId]?.displayTitle ?? list.byId[sessionId]?.title ?? ""); - const firstTurns = useSessions((list) => { - const outline = list.projectionsBySession?.[sessionId]?.values?.turnOutline ?? list.byId[sessionId]?.projectionValues?.turnOutline; - return Array.isArray(outline) ? outline.slice(0, 8).map((entry) => entry.turn).join(",") : ""; - }); - const hiddenTurns = roster.exchangeTurns?.[sessionId]; - const visit = useUi((state) => state.visits?.[sessionId]); - const opening = useTurnClock((state) => { - const first = openingTurn(state[sessionId], firstTurns, hiddenTurns); - return first && !unseenSince(visit, first.start) ? clockLabel(first.start) : ""; - }); - (0, import_react20.useEffect)(() => { - if (opening) document.body.style.setProperty("--bt-first-sep", JSON.stringify(opening)); - else document.body.style.removeProperty("--bt-first-sep"); - return () => document.body.style.removeProperty("--bt-first-sep"); - }, [opening]); - const running = useSessionStatus((map) => map.get(sessionId)?.running === true); - const owner = roster.byId[sessionId]?.id ?? sessionId; - const doing = useActivity((map) => map[owner]); - const bot = roster.byId[sessionId]; - const projectAgent = projectAgentFor(bot, roster.self?.memberId); - const room2 = roster.roomsById[sessionId]; - const detailsOpen = useUi((state) => state.details === sessionId); - const full2 = bot?.remote ? friendBotTitle(bot) : roomTitle(room2) || bot?.name || ""; - const face = room2?.origin === "face-to-face"; - const name = face ? faceRowTitle(full2) : full2; - const useNews = useMemoryNews ?? noNews; - const memoryIds = bot ? [bot.id] : room2?.members ?? []; - const notice = useMemoryNotice(memoryIds, useNews); - const changed2 = useNews((map) => latestMemoryChange(map, memoryIds)); - const memoryNotice = notice && changed2 !== null && roster.memory !== false; - return (0, import_react20.createElement)( - "div", - { className: "bt-head" }, - (0, import_react20.createElement)( - "button", - { - type: "button", - className: "bt-pill", - "aria-expanded": detailsOpen, - "data-memory": memoryNotice ? "updated" : void 0, - "aria-label": memoryNotice ? t("Memory updated. Open {name}'s memory", { name: roster.byId[changed2]?.name ?? full2 }) : t("View conversation details"), - onClick: () => memoryNotice ? actions.openMemory(changed2) : actions.toggleDetails(sessionId) - }, - bot ? (0, import_react20.createElement)(BotAvatar, { bot, size: 24, state: botState(running, doing, roster.questions?.[currentPart(bot)] !== void 0), badge: false }) : room2 ? (0, import_react20.createElement)(RoomAvatar, { room: room2, roster, size: 24 }) : null, - (0, import_react20.createElement)("span", { className: face ? "bt-pill-name bt-pill-name-face" : "bt-pill-name", title: face ? full2 : void 0 }, name || title || t("New chat")), - (0, import_react20.createElement)(MemoryNotice, { shown: memoryNotice }), - (0, import_react20.createElement)("span", { className: "bt-pill-go", "aria-hidden": true }, (0, import_react20.createElement)(GoArrow)) - ), - (0, import_react20.createElement)("span", { className: "bt-sr", role: "status" }, memoryNotice ? t("Memory updated") : ""), - projectAgent === null ? null : (0, import_react20.createElement)(ProjectAgentEntry, { - projectId: projectAgent.projectId, - read: call, - onOpen: (id) => actions?.openSession?.(id) - }), - (0, import_react20.createElement)(NewMessagePills, { key: sessionId, sessionId, hiddenTurns, useSessions, useUi, useTurnClock, actions }), - (0, import_react20.createElement)(ActivityIndicator, { key: `activity:${sessionId}`, sessionId, useRoster, useSessionStatus, useSession, useLive, actions }), - (0, import_react20.createElement)(BusyAckBubble, { key: `busy-ack:${sessionId}`, sessionId, useRoster, useBusyAcks, useSessionStatus, useSession, useLive }) - ); +var PROJECT_BOARD_CSS = ` +/* #1750: the board was a short dialog in the page flow — no stylesheet defines 'bt-overlay', so the + bot tree and workflows stayed legible under its rows. It is a modal now, on the same tier as the + Board and Settings layers, with kanban's own 'bt-scrim' sibling and 'bt-mem' surface. */ +.bt-project-overlay{position:fixed;inset:0;z-index:121;isolation:isolate;display:flex;align-items:center;justify-content:center;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",system-ui,sans-serif} +.bt-mem.bt-project-dialog{width:min(1080px,calc(100vw - 32px));height:min(760px,calc(100vh - 48px))} +.bt-project-body{flex:1 1 auto;min-height:0;overflow:auto} +.bt-project-board{padding:16px 18px} +.bt-project-group{margin:0 0 14px} +.bt-project-group h4{margin:0 0 6px;font-size:12px;letter-spacing:.04em;text-transform:uppercase;opacity:.7} +.bt-project-row{display:flex;gap:10px;align-items:center;padding:8px 10px;border-radius:8px;background:var(--bt-card,rgba(127,127,127,.08));margin:0 0 6px;cursor:default} +.bt-project-row[data-column="done"]{opacity:.7} +.bt-project-title-wrap{display:flex;flex-direction:column;gap:2px;min-width:0} +.bt-project-audit{font-size:11px;line-height:1.45;white-space:pre-wrap;opacity:.65;font-family:var(--bt-mono,ui-monospace,monospace)} +.bt-project-row .bt-project-ref{margin-left:auto;font-size:11px;opacity:.6} +.bt-project-empty{padding:22px 4px;opacity:.7} +/* #1767: the record beside the cards. The same surface as a row, one step quieter, and no new + component language: a heading, a list, and a monospace hash the reader can compare. */ +.bt-project-record{margin:18px 0 0;border-top:1px solid var(--bt-line,rgba(127,127,127,.22));padding:12px 0 0} +.bt-project-record h4{margin:0 0 6px;font-size:12px;letter-spacing:.04em;text-transform:uppercase;opacity:.7} +.bt-project-record h5{margin:10px 0 4px;font-size:11px;letter-spacing:.04em;text-transform:uppercase;opacity:.55} +.bt-project-decision,.bt-project-meeting{display:flex;gap:10px;align-items:baseline;padding:6px 10px;border-radius:8px;background:var(--bt-card,rgba(127,127,127,.08));margin:0 0 6px} +.bt-project-decision .bt-project-date,.bt-project-meeting .bt-project-date{font-size:11px;opacity:.6;min-width:76px;font-variant-numeric:tabular-nums} +.bt-project-decision .bt-project-hash{margin-left:auto;font-size:11px;opacity:.55;font-family:var(--bt-mono,ui-monospace,monospace)} +.bt-project-decision .bt-project-title,.bt-project-meeting .bt-project-title{min-width:0} +/* #2041: the decision row became a button, so it keeps the row's look and drops the control's. */ +button.bt-project-decision{width:100%;font:inherit;color:inherit;text-align:left;border:0;cursor:pointer} +button.bt-project-decision:hover{background:var(--bt-card-hover,rgba(127,127,127,.16))} +button.bt-project-decision:focus-visible{outline:2px solid var(--bt-accent,#4c8dff);outline-offset:1px} +button.bt-project-decision[data-open="true"]{outline:1px solid var(--bt-accent,#4c8dff)} +.bt-project-decision-wrap{margin:0} +.bt-project-decision-text{margin:0 0 8px;padding:8px 10px;border-radius:8px;background:var(--bt-card,rgba(127,127,127,.08));white-space:pre-wrap;overflow-wrap:anywhere;max-height:320px;overflow:auto;font-size:12px;font-family:var(--bt-mono,ui-monospace,monospace)} +.bt-project-decision-refusal{opacity:.75} +.bt-project-path{font-size:11px;opacity:.6;font-family:var(--bt-mono,ui-monospace,monospace)} +.bt-project-missing{font-size:11px;opacity:.6} +.bt-project-status{font-size:11px;text-transform:uppercase;letter-spacing:.04em;opacity:.6;margin-left:auto} +/* #1831: the card's level, the people it is addressed to, and the identity a row or a comment + carries. A severe card is the only one painted louder, because it is the only one that waits. */ +.bt-project-level{font-size:10px;text-transform:uppercase;letter-spacing:.06em;padding:1px 6px;border-radius:999px;border:1px solid var(--bt-line,rgba(127,127,127,.32));opacity:.75} +.bt-project-level[data-level="severe"]{opacity:1;border-color:#c2410c;color:#c2410c} +.bt-project-recipients{font-size:11px;opacity:.65} +.bt-project-paused{font-size:11px;opacity:.75;color:#c2410c} +.bt-project-holder{font-size:11px;opacity:.7} +.bt-project-speaks-for{font-size:11px;opacity:.7;font-style:italic} +.bt-project-actions{display:flex;gap:8px;margin:0 0 14px} +.bt-project-metrics{margin:0 0 14px} +.bt-project-metric-cards{display:grid;grid-template-columns:repeat(auto-fit,minmax(132px,1fr));gap:8px} +.bt-project-metric{display:flex;flex-direction:column;gap:2px;padding:8px 10px;border:1px solid rgba(127,127,127,.24);border-radius:8px} +.bt-project-metric-value{font-size:18px;font-weight:600} +.bt-project-metric-label{font-size:12px;font-weight:600} +.bt-project-metric-caption{font-size:10px;opacity:.6;line-height:1.3} +.bt-project-metrics-note{margin:0 0 6px;font-size:11px;opacity:.7} +.bt-project-running{margin:0 0 14px} +.bt-project-running-head,.bt-project-running-row{display:grid;grid-template-columns:2fr 1fr 1fr .8fr .8fr 1.2fr;gap:8px;font-size:12px;padding:3px 0} +.bt-project-running-head{opacity:.6;font-size:10px;letter-spacing:.03em} +.bt-project-running-row[role="button"]{cursor:pointer} +.bt-project-running-row[data-overdue="true"]{color:#c2410c} +.bt-project-counts{display:flex;gap:14px;align-items:center;margin:0 0 12px;font-size:12px;letter-spacing:.03em;opacity:.8} +.bt-project-counts b{font-weight:600} +.bt-project-group[data-queue="waiting"] .bt-project-row{cursor:default} +.bt-project-role{font-size:11px;opacity:.65;font-family:var(--bt-mono,ui-monospace,monospace)} +.bt-project-wait{font-size:11px;opacity:.65;margin-left:auto} +.bt-project-state{font-size:11px;margin-left:auto} +.bt-project-row[data-state="stalled"]{border-left:2px solid #d9822b} +.bt-project-reason{font-size:11px;opacity:.7} + +/* #1855: the finished cards are one group of their own, closed until the reader opens it — a + Project with months of history reads as the work in flight, not as its archive. */ +.bt-project-done{margin:18px 0 0;border-top:1px solid var(--bt-line,rgba(127,127,127,.22));padding:12px 0 0} +.bt-project-done>summary{cursor:pointer;font-size:12px;letter-spacing:.04em;text-transform:uppercase;opacity:.7;list-style:none} +.bt-project-done>summary::-webkit-details-marker{display:none} +.bt-project-done>summary::before{content:'\\25B8';display:inline-block;width:12px;opacity:.6} +.bt-project-done[open]>summary::before{content:'\\25BE'} +.bt-project-done[open]>summary{margin:0 0 8px} + +/* #1749: management panels. Same surface as the board, one form row per control, and a status + line that quotes the host's own answer — a refused save reads where the reader pressed it. */ +.bt-project-panel{padding:16px 18px;display:flex;flex-direction:column;gap:12px} +.bt-project-panel h3{margin:0} +.bt-project-field{display:flex;flex-direction:column;gap:4px} +.bt-project-field label{font-size:12px;letter-spacing:.04em;text-transform:uppercase;opacity:.7} +.bt-project-field input,.bt-project-field select,.bt-project-field textarea{font:inherit;padding:6px 8px;border-radius:8px;border:1px solid var(--bt-line,rgba(127,127,127,.32));background:var(--bt-card,rgba(127,127,127,.08));color:inherit} +.bt-project-field textarea{min-height:88px;resize:vertical} +/* #1887: the counter under the memory box. It stays quiet until the text passes the bound, and + past it turns the one colour that says so; the disabled Save is the state a reader acts on. */ +.bt-project-count{font-size:11px;opacity:.6;font-variant-numeric:tabular-nums} +.bt-project-count[data-over="true"]{opacity:1;color:#c2410c} +.bt-project-row-actions{display:flex;gap:8px;align-items:center} +.bt-project-members{list-style:none;margin:0;padding:0;display:flex;flex-direction:column;gap:6px} +.bt-project-member{display:flex;gap:10px;align-items:baseline;padding:8px 10px;border-radius:8px;background:var(--bt-card,rgba(127,127,127,.08))} +.bt-project-member .bt-project-member-kind{margin-left:auto;font-size:11px;opacity:.6} +.bt-project-status-line{font-size:12px;opacity:.75;min-height:16px} +.bt-project-note{font-size:12px;opacity:.65;margin:0} + +/* #1860: the Project team — who runs the Project, who speaks for the reader, and the role pool. + One quiet block above the cards, and a Bot row is a button because a reader is looking a + person up. The pool row keeps the rank it plays and the ranks the host said it may switch to. */ +.bt-project-team{margin:0 0 14px} +.bt-project-team h4{margin:0 0 6px;font-size:12px;letter-spacing:.04em;text-transform:uppercase;opacity:.7} +.bt-project-team-row{display:flex;gap:10px;align-items:baseline;flex-wrap:wrap;padding:8px 10px;border-radius:8px;background:var(--bt-card,rgba(127,127,127,.08));margin:0 0 6px} +.bt-project-team-row[data-open-bot]{cursor:pointer} +.bt-project-team-row[data-open-bot]:hover{background:var(--bt-hover,rgba(127,127,127,.14))} +.bt-project-team-label{font-size:12px;opacity:.7;min-width:96px} +.bt-project-team-bot{font-size:12px;font-family:var(--bt-mono,ui-monospace,monospace)} +.bt-project-team-roles{display:flex;gap:10px;flex-wrap:wrap;font-size:11px;opacity:.75} +.bt-project-team-role{font-family:var(--bt-mono,ui-monospace,monospace)} +.bt-project-team-myrole{margin-left:auto;font-size:11px;opacity:.65} + +/* #1864: the team dossier. A Dossier control on each team row opens one read-only panel; the + owner's own chat header carries the same entry to the Project assistant's chat. */ +.bt-project-team-dossier{margin-left:auto;font-size:11px;padding:1px 8px;border-radius:999px;border:1px solid var(--bt-line,rgba(127,127,127,.32));background:transparent;color:inherit;cursor:pointer} +.bt-project-team-dossier:hover{background:var(--bt-hover,rgba(127,127,127,.14))} +.bt-project-dossier{padding:16px 18px;display:flex;flex-direction:column;gap:12px} +.bt-project-dossier-section{display:flex;flex-direction:column;gap:4px} +.bt-project-dossier-section h4{margin:0;font-size:12px;letter-spacing:.04em;text-transform:uppercase;opacity:.7} +.bt-project-dossier-text{margin:0;font-size:13px;line-height:1.5;white-space:pre-wrap;overflow-wrap:anywhere} +.bt-project-dossier-meta{display:flex;gap:12px;flex-wrap:wrap;font-size:11px;opacity:.7} +.bt-project-dossier-topics{display:flex;gap:6px;flex-wrap:wrap} +.bt-project-dossier-topic{font-size:11px;padding:1px 6px;border-radius:999px;background:var(--bt-card,rgba(127,127,127,.08))} +.bt-project-agent-entry{display:inline-flex;align-items:center;gap:4px;font-size:11px;padding:1px 8px;border-radius:999px;border:1px solid var(--bt-line,rgba(127,127,127,.32));background:transparent;color:inherit;cursor:pointer} +.bt-project-agent-entry:hover{background:var(--bt-hover,rgba(127,127,127,.14))} +`; +var PROJECT_SOURCES = ["bot", "group", "workflow"]; +var SOURCE_LABELS = { bot: "Bots", group: "Groups", workflow: "Workflows" }; +var text = (value) => typeof value === "string" ? value.trim() : ""; +var MEMORY_TEXT_MAX = 600; +var memoryLength = (value) => [...text(value)].length; +var DONE_COLUMN = "done"; +var boardOfRow = (row, task) => { + const stated = row?.board; + if (stated !== null && typeof stated === "object") { + const id = text(stated.id); + if (id !== "" && (stated.kind === "group" || stated.kind === "bot")) return { kind: stated.kind, id }; + } + if (task === void 0) return null; + if (row.source === "group") { + const id = text(task.origin?.roomId); + return id === "" ? null : { kind: "group", id }; + } + if (row.source === "bot") { + const id = text(task.ownerBotId); + return id === "" ? null : { kind: "bot", id }; + } + return null; +}; +var decisionOf = (file) => { + if (file === null || typeof file !== "object") return null; + const path = text(file.path); + if (path === "") return null; + return { + key: `decision:${path}`, + path, + name: text(file.name) || path.slice(path.lastIndexOf("/") + 1), + date: text(file.date), + title: text(file.title), + sha256: text(file.sha256), + shortHash: text(file.sha256).slice(0, 12) + }; +}; +var meetingOf = (meeting, status) => { + if (meeting === null || typeof meeting !== "object") return null; + const id = text(meeting.id); + if (id === "") return null; + return { + key: `meeting:${id}`, + id, + date: text(meeting.date), + question: text(meeting.question) || text(meeting.title), + owner: text(meeting.owner) || null, + status, + path: text(meeting.path), + blockingQuestion: text(meeting.blockingQuestion) || null, + // #1831: the level and recipients the host answered, and the refusal to advance a severe card. + // Nothing is inferred here: a record written before the slice carries an ordinary card and no + // recipients, exactly as the host reads it. + level: text(meeting.level) || "ordinary", + recipients: Array.isArray(meeting.recipients) ? meeting.recipients.map((item) => text(item)).filter((name) => name !== "") : [], + advance: meeting.advance !== null && typeof meeting.advance === "object" ? { allowed: meeting.advance.allowed === true, reason: text(meeting.advance.reason) || null } : { allowed: true, reason: null }, + // #1861: the host's participants with who each position speaks for, so the row can say a + // Project Chief or an agent held it for someone rather than for themselves. + participants: Array.isArray(meeting.participants) ? meeting.participants.map((participant) => ({ name: text(participant?.name), speaksFor: text(participant?.speaksFor) || null })).filter((participant) => participant.name !== "") : [] + }; +}; +var listOf = (value) => Array.isArray(value) ? value.map((item) => decisionOf(item)).filter((item) => item !== null) : []; +function projectBoardRecord({ decisions, meetings } = {}) { + const dir = text(decisions?.dir) || null; + const files = listOf(decisions?.files); + const decided = Array.isArray(meetings?.decided) ? meetings.decided.map((item) => meetingOf(item, "decided")).filter((item) => item !== null) : []; + const pending = Array.isArray(meetings?.pending) ? meetings.pending.map((item) => meetingOf(item, item?.status ?? "deliberating")).filter((item) => item !== null) : []; + const totalOf = (value) => typeof value === "number" && Number.isFinite(value) ? value : null; + const hiddenOf = (total, rows) => total === null ? 0 : Math.max(0, total - rows); + const decisionsTotal = totalOf(decisions?.total); + const decidedTotal = totalOf(meetings?.decidedTotal); + const pendingTotal = totalOf(meetings?.pendingTotal); + return { + decisions: { + dir, + files, + count: files.length, + total: decisionsTotal, + hidden: hiddenOf(decisionsTotal, files.length), + empty: files.length === 0, + emptyText: text(decisions?.reason) || (dir === null ? "This Project names no working folder." : `No decision file in ${dir}/decisions.`) + }, + meetings: { + decided, + pending, + count: decided.length + pending.length, + decidedTotal, + pendingTotal, + decidedHidden: hiddenOf(decidedTotal, decided.length), + pendingHidden: hiddenOf(pendingTotal, pending.length), + empty: decided.length === 0 && pending.length === 0, + emptyText: text(meetings?.reason) || "No meeting of this Project is recorded." + } + }; } -function useTranscriptView() { - const [view, setView] = (0, import_react20.useState)(null); - (0, import_react20.useEffect)(() => { - let frame = 0; - let watched = null; - const observer = new MutationObserver(() => schedule()); - const measure = () => { - frame = 0; - const scroller = document.querySelector("[data-conversation-scroll]"); - if (scroller !== watched) { - observer.disconnect(); - watched = scroller; - if (scroller) observer.observe(scroller, { childList: true, subtree: true }); - } - if (!scroller) { - setView(null); - return; - } - const box = scroller.getBoundingClientRect(); - const headerBottom = document.querySelector('header[class*="_header"]')?.getBoundingClientRect().bottom ?? box.top + 50; - const sep = scroller.querySelector(".bt-new-sep"); - let divider = null; - if (sep) { - const at = sep.getBoundingClientRect(); - divider = at.bottom <= headerBottom ? "above" : at.top >= box.bottom ? "below" : "in-view"; - } - const composer = parseFloat(getComputedStyle(scroller).getPropertyValue("--dsh-composer-height")) || 152; - const next = { - unpinned: scroller.querySelector('[class*="_toBottomSlot"]>button') !== null, - divider, - center: Math.round(box.left + box.width / 2), - top: Math.round(headerBottom + 8), - bottom: Math.round(window.innerHeight - box.bottom + composer + 8) - }; - setView((prev) => prev && Object.keys(next).every((key) => prev[key] === next[key]) ? prev : next); - }; - const schedule = () => { - if (!frame) frame = requestAnimationFrame(measure); - }; - document.addEventListener("scroll", schedule, true); - window.addEventListener("resize", schedule); - schedule(); - return () => { - cancelAnimationFrame(frame); - observer.disconnect(); - document.removeEventListener("scroll", schedule, true); - window.removeEventListener("resize", schedule); +function projectBoardView({ project, rows, done, canManage, tasks, decisions, meetings, queues, metrics } = {}) { + const isRow = (row) => row !== null && typeof row === "object" && text(row.id) !== ""; + const byId = new Map((Array.isArray(tasks) ? tasks : []).filter((task) => task !== null && typeof task === "object" && text(task.id) !== "").map((task) => [text(task.id), task])); + const list = []; + const archived = []; + const seen = /* @__PURE__ */ new Set(); + for (const row of [...Array.isArray(done) ? done : [], ...Array.isArray(rows) ? rows : []]) { + if (!isRow(row)) continue; + const id = text(row.id); + if (seen.has(id)) continue; + seen.add(id); + const column = text(row.column) || "not-started"; + (column === DONE_COLUMN ? archived : list).push(row); + } + const projectRow = (row) => { + const task = byId.get(text(row.id)); + return { + id: text(row.id), + key: `task:${text(row.id)}`, + column: text(row.column) || "not-started", + assignee: text(row.assignee) || null, + runId: text(row.runId) || null, + nodeId: text(row.nodeId) || null, + title: text(row.title) || text(task?.title) || text(task?.note) || null, + priority: text(row.priority) || text(task?.priority) || null, + audit: text(row.audit) || null, + // #1831: who is doing the card, and who is speaking for whom. Both are host truth; + // a card carrying neither renders exactly as before. + holder: text(row.holder) || null, + speaksFor: text(row.speaksFor) || null, + board: boardOfRow(row, task) }; - }, []); - return view; -} -function NewMessagePills({ sessionId, hiddenTurns, useSessions, useUi, useTurnClock, actions }) { - const replies = useSessions((list) => { - const outline = list.projectionsBySession?.[sessionId]?.values?.turnOutline ?? list.byId[sessionId]?.projectionValues?.turnOutline; - return Array.isArray(outline) ? outline.reduce((count, entry) => count + (entry.response ? 1 : 0), 0) : 0; - }); - const visit = useUi((state) => state.visits?.[sessionId]); - const unread = useTurnClock((state) => { - if (typeof visit?.seen !== "number") return 0; - let count = 0; - for (const entry of Object.values(state[sessionId] ?? {})) { - if (entry.first !== void 0 && entry.first > visit.seen && entry.first <= visit.entered) count += 1; - } - return count; + }; + const groups = PROJECT_SOURCES.map((source) => ({ + source, + label: SOURCE_LABELS[source], + rows: list.filter((row) => row.source === source).map(projectRow) + })).filter((group) => group.rows.length > 0); + const countOf = (value) => value === void 0 || value === null ? null : value; + const queueItems = (items, annotate) => (Array.isArray(items) ? items : []).filter((item) => item !== null && typeof item === "object" && text(item.id) !== "").map((item) => { + const id = text(item.id); + const source = text([...list, ...archived].find((row) => text(row.id) === id)?.source); + const task = byId.get(id); + return { id, key: `task:${id}`, title: text(task?.title) || text(task?.note) || null, priority: text(task?.priority) || null, board: boardOfRow({ source }, task), ...annotate(item) }; }); - const view = useTranscriptView(); - const unpinned = view?.unpinned === true; - const [release, setRelease] = (0, import_react20.useState)({ base: null, dismissed: false }); - const [above, setAbove] = (0, import_react20.useState)({ seen: false, dismissed: false }); - (0, import_react20.useEffect)(() => { - setRelease({ base: unpinned ? replies : null, dismissed: false }); - }, [unpinned]); - (0, import_react20.useEffect)(() => { - if (view?.divider === "in-view") setAbove((state) => state.seen ? state : { ...state, seen: true }); - }, [view?.divider]); - const movedOn = useTurnClock((state) => movedOnSince(visit, state[sessionId], hiddenTurns)); - (0, import_react20.useEffect)(() => { - if (above.seen && movedOn) actions?.settleVisit?.(sessionId); - }, [above.seen, movedOn, sessionId]); - const fresh = release.base === null ? 0 : replies - release.base; - const showDown = unpinned && fresh > 0 && !release.dismissed; - const showUp = view?.divider === "above" && !above.seen && !above.dismissed; - (0, import_react20.useEffect)(() => { - const scroller = document.querySelector("[data-conversation-scroll]"); - if (!scroller) return void 0; - scroller.toggleAttribute("data-bt-news", showDown); - return () => scroller.removeAttribute("data-bt-news"); - }, [showDown]); - if (!view || !showDown && !showUp) return null; - const label = (count) => count === 1 ? t("1 new message") : count > 0 ? t("{count} new messages", { count }) : t("New messages"); - const pill = (direction, count, style, onJump, onDismiss) => (0, import_react20.createElement)( + const waiting = queueItems(queues?.waiting, (item) => ({ role: text(item.role) || null, waitedMs: Number.isFinite(item.waitedMs) ? item.waitedMs : null })); + const inflight = queueItems(queues?.inflight, (item) => ({ holder: text(item.holder) || null, state: item.stalled === true ? "stalled" : "running" })); + const blocked = queueItems(queues?.blocked, (item) => ({ reason: text(item.reason) || null, waitedMs: Number.isFinite(item.waitedMs) ? item.waitedMs : null })); + const queueGroups = [ + { group: "waiting", label: "Waiting", items: waiting }, + { group: "inflight", label: "In flight", items: inflight }, + { group: "blocked", label: "Blocked", items: blocked } + ].filter((entry) => entry.items.length > 0); + const rawCounts = queues?.counts ?? null; + return { + projectId: text(project?.id) || null, + title: text(project?.name) || "Project", + canManage: canManage === true, + groups, + // #1855: one group, closed until the reader opens it. Rows keep the row shape above, so a + // finished card is still a card — only its place on the board changed. + done: { group: "done", label: "Done cards", collapsed: true, rows: archived.map(projectRow) }, + queues: queueGroups, + counts: rawCounts === null ? null : { waiting: countOf(rawCounts.waiting), inflight: countOf(rawCounts.inflight), blocked: countOf(rawCounts.blocked), done: countOf(rawCounts.done) }, + count: list.length, + empty: list.length === 0 && queueGroups.length === 0 && archived.length === 0, + emptyText: "No cards yet.", + record: projectBoardRecord({ decisions, meetings }), + metrics: metricsView(metrics, { tasks }) + }; +} +function projectRowNode(row, onOpen) { + const open = row.board === null ? void 0 : () => onOpen?.(row.board); + return (0, import_react20.createElement)( "div", - { className: "bt-news", "data-direction": direction, style }, - (0, import_react20.createElement)( - "button", - { type: "button", className: "bt-news-jump", onClick: onJump }, - (0, import_react20.createElement)("span", { className: "bt-news-ico" }, (0, import_react20.createElement)(direction === "up" ? ArrowUpIcon : ArrowDownIcon)), - label(count) - ), - (0, import_react20.createElement)("button", { - type: "button", - className: "bt-news-x", - "aria-label": t("Dismiss new messages"), - onClick: (event) => { - event.stopPropagation(); - onDismiss(); + { + key: row.key, + className: "bt-project-row", + "data-task-id": row.id, + "data-column": row.column, + "data-priority": row.priority ?? void 0, + role: open === void 0 ? void 0 : "button", + tabIndex: open === void 0 ? void 0 : 0, + onClick: open, + onKeyDown: open === void 0 ? void 0 : (event) => { + if (event.key === "Enter" || event.key === " ") open(); } - }, (0, import_react20.createElement)(CloseIcon)) - ); - const toLatest = () => { - const scroller = document.querySelector("[data-conversation-scroll]"); - const back = scroller?.querySelector('[class*="_toBottomSlot"]>button'); - if (back) back.click(); - else scroller?.scrollTo({ top: scroller.scrollHeight, behavior: "smooth" }); - }; - const toDivider = () => document.querySelector("[data-conversation-scroll] .bt-new-sep")?.scrollIntoView({ block: "center", behavior: "smooth" }); - return (0, import_react_dom6.createPortal)( + }, + (0, import_react20.createElement)("span", { className: "bt-project-column" }, t(row.column)), (0, import_react20.createElement)( - import_react20.Fragment, - null, - showUp ? pill("up", unread, { left: view.center, top: view.top }, toDivider, () => setAbove((state) => ({ ...state, dismissed: true }))) : null, - showDown ? pill("down", fresh, { left: view.center, bottom: view.bottom }, toLatest, () => setRelease((state) => ({ ...state, dismissed: true }))) : null + "span", + { className: "bt-project-title-wrap" }, + (0, import_react20.createElement)("span", { className: "bt-project-title" }, row.title ?? (row.assignee ? `@${row.assignee}` : "—")), + // The audit text the row already carries (#1730): shown inside the card detail, never a + // second read. A row without one renders exactly as before. + row.audit === null ? null : (0, import_react20.createElement)("span", { className: "bt-project-audit" }, row.audit) ), - document.body + // "Who is doing" is the claim, never the assignee label; a card the Assistant speaks for + // says so, so the three identities (Bot, Assistant, user) never read as one. + row.holder === null ? null : (0, import_react20.createElement)("span", { className: "bt-project-holder", "data-holder": row.holder }, `@${row.holder}`), + row.speaksFor === null ? null : (0, import_react20.createElement)("span", { className: "bt-project-speaks-for", "data-speaks-for": row.speaksFor }, t("for {user}", { user: row.speaksFor })), + row.priority === null ? null : (0, import_react20.createElement)("span", { className: "bt-project-priority" }, row.priority), + row.runId === null ? null : (0, import_react20.createElement)("span", { className: "bt-project-ref" }, row.nodeId === null ? row.runId : `${row.runId} · ${row.nodeId}`) ); } - -// src/client/composer-label.js -var ATTRIBUTES = ["aria-label", "data-placeholder"]; -function holdComposerLabel(doc, label) { - doc.body.style.setProperty("--bt-placeholder", JSON.stringify(label)); - const shell = /* @__PURE__ */ new Map(); - const apply2 = () => { - for (const el of doc.querySelectorAll("[data-composer-input]")) { - for (const name of ATTRIBUTES) { - const current = el.getAttribute(name); - if (current === label) continue; - if (!shell.has(el)) shell.set(el, {}); - shell.get(el)[name] = current; - el.setAttribute(name, label); - } - } - }; - apply2(); - const Observer = doc.defaultView?.MutationObserver ?? globalThis.MutationObserver; - const watcher = typeof Observer === "function" ? new Observer(apply2) : null; - watcher?.observe(doc.body, { subtree: true, childList: true, attributes: true, attributeFilter: ATTRIBUTES }); - return () => { - watcher?.disconnect(); - for (const [el, values] of shell) { - if (!el.isConnected) continue; - for (const [name, value] of Object.entries(values)) { - if (value == null) el.removeAttribute(name); - else el.setAttribute(name, value); - } - } - }; +function ProjectBoardRows({ view, onOpen }) { + if (view.empty) return (0, import_react20.createElement)("div", { className: "bt-project-empty" }, t(view.emptyText)); + return view.groups.map((group) => (0, import_react20.createElement)( + "section", + { key: group.source, className: "bt-project-group", "data-source": group.source }, + (0, import_react20.createElement)("h4", null, t(group.label)), + group.rows.map((row) => projectRowNode(row, onOpen)) + )); } - -// src/client/header-effects.js -function installHeaderEffects({ sessions, roster, actions, doc = globalThis.document }) { - if (!doc) return () => { - }; - const slots = /* @__PURE__ */ new Map(); - const run = (id, dep, setup) => { - const held = slots.get(id); - if (held !== void 0 && held.dep === dep) return; - held?.off(); - slots.set(id, { dep, off: setup() ?? (() => { - }) }); - }; - let running = false; - let again = false; - const pass = () => { - const list = sessions.getSnapshot(); - const state = roster.getSnapshot(); - const row = Object.values(list?.byId ?? {}).find((entry) => (entry?.retainedBy?.mainView ?? 0) > 0); - const sessionId = row?.id; - const bot = sessionId === void 0 ? void 0 : state?.byId?.[sessionId]; - const room2 = sessionId === void 0 ? void 0 : state?.roomsById?.[sessionId]; - const name = bot?.remote ? friendBotTitle(bot) : roomTitle(room2) || bot?.name || ""; - run("friend-chat", `${sessionId}|${bot?.remote === true}`, () => { - if (!bot?.remote) return void 0; - doc.body.dataset.btFriendChat = ""; - return () => { - delete doc.body.dataset.btFriendChat; - }; - }); - run("remote-empty", `${sessionId}|${bot?.remote === true}|${row?.blank === true}`, () => { - if (!bot?.remote || row?.blank !== true) return void 0; - doc.body.dataset.btRemoteChat = "empty"; - doc.body.style.setProperty("--bt-remote-empty", JSON.stringify(t("Send a message. Your friend approves unless Auto-send is on."))); - return () => { - delete doc.body.dataset.btRemoteChat; - doc.body.style.removeProperty("--bt-remote-empty"); - }; - }); - run("composer-label", `${sessionId}|${name}`, () => holdComposerLabel(doc, name ? t("Message {name}", { name }) : t("Message"))); - run("visit", sessionId, () => { - if (sessionId === void 0) return void 0; - actions.beginVisit(sessionId); - return () => actions.endVisit(sessionId); - }); - run("face-at-menu", `${sessionId}|${room2?.origin === "face-to-face"}`, () => { - if (room2?.origin !== "face-to-face") return void 0; - const stop = actions.filterFaceAtMenu?.(sessionId); - return typeof stop === "function" ? stop : void 0; - }); - run("room-flag", `${sessionId}|${room2 !== void 0}`, () => { - if (room2 === void 0) return void 0; - doc.body.dataset.btRoom = ""; - return () => { - delete doc.body.dataset.btRoom; - }; - }); +function ProjectDoneGroup({ view, onOpen }) { + if (view.done.rows.length === 0) return null; + return (0, import_react20.createElement)( + "details", + { className: "bt-project-group bt-project-done", "data-group": "done" }, + (0, import_react20.createElement)("summary", { className: "bt-project-done-summary" }, `${t(view.done.label)} · ${view.done.rows.length}`), + view.done.rows.map((row) => projectRowNode(row, onOpen)) + ); +} +function waitedLabel(ms) { + if (!Number.isFinite(ms) || ms < 0) return null; + if (ms < 6e4) return `${ms === 0 ? 0 : Math.max(1, Math.floor(ms / 1e3))}s`; + const minutes = Math.floor(ms / 6e4); + if (minutes < 60) return `${minutes}m`; + const hours = Math.floor(minutes / 60); + if (hours < 24) return `${hours}h`; + return `${Math.floor(hours / 24)}d`; +} +var METRIC_CARDS = [ + { key: "prsOpenedToday", label: "PRs opened today", caption: "Natural day in your time zone, by created_at." }, + { key: "prsMergedToday", label: "PRs merged today", caption: "Natural day in your time zone, by merged_at." }, + { key: "issuesOpenedToday", label: "Issues opened today", caption: "Natural day in your time zone, by created_at." }, + { key: "issuesClosedToday", label: "Issues closed today", caption: "Natural day in your time zone, by closed_at." }, + { key: "prMergeMedianMs", label: "PR merge time", caption: "Median of the last 7 days, opened to merged." }, + { key: "issueCloseMedianMs", label: "Issue close time", caption: "Median of the last 7 days, opened to closed." }, + { key: "taskDoneMedianMs", label: "Task completion", caption: "Median of the last 7 days, created to done." } +]; +var METRIC_DASH = "—"; +var DURATION_KEYS = /* @__PURE__ */ new Set(["prMergeMedianMs", "issueCloseMedianMs", "taskDoneMedianMs"]); +var RUNNING_OVERDUE_MS = 2 * 60 * 60 * 1e3; +var metricText = (key, value) => { + if (!Number.isFinite(value)) return METRIC_DASH; + if (DURATION_KEYS.has(key)) return waitedLabel(value) ?? METRIC_DASH; + return String(value); +}; +function metricsView(metrics, { tasks } = {}) { + const source = metrics !== null && typeof metrics === "object" ? metrics : null; + const values = source?.metrics !== null && typeof source?.metrics === "object" ? source.metrics : {}; + const byId = new Map((Array.isArray(tasks) ? tasks : []).filter((task) => task !== null && typeof task === "object" && text(task.id) !== "").map((task) => [text(task.id), task])); + const running = (Array.isArray(source?.running) ? source.running : []).filter((row) => row !== null && typeof row === "object" && text(row.id) !== "").map((row) => { + const id = text(row.id); + const task = byId.get(id); + return { + id, + key: "task:" + id, + title: text(row.title) || text(task?.title) || text(task?.note) || id, + status: text(row.status), + who: text(row.who) || null, + sinceMs: Number.isFinite(row.sinceMs) ? row.sinceMs : null, + source: text(row.source) || null, + lastActivityAt: Number.isFinite(row.lastActivityAt) ? row.lastActivityAt : null, + overdue: Number.isFinite(row.sinceMs) && row.sinceMs > RUNNING_OVERDUE_MS, + board: boardOfRow({ source: text(row.source) }, task) + }; + }); + return { + available: source?.ok === true, + reason: text(source?.reason) || null, + cards: METRIC_CARDS.map((card) => ({ ...card, value: metricText(card.key, values[card.key]) })), + running, + empty: source === null, + emptyText: "No metrics read yet." }; - const apply2 = () => { - if (running) { - again = true; +} +function ProjectMetrics({ view }) { + return (0, import_react20.createElement)( + "section", + { className: "bt-project-metrics", "data-available": view.available ? "true" : "false" }, + view.available || view.reason === null ? null : (0, import_react20.createElement)("p", { className: "bt-project-metrics-note" }, t("Metrics unavailable") + ": " + view.reason), + (0, import_react20.createElement)( + "div", + { className: "bt-project-metric-cards" }, + view.cards.map((card) => (0, import_react20.createElement)( + "div", + { key: card.key, className: "bt-project-metric", "data-metric": card.key }, + (0, import_react20.createElement)("span", { className: "bt-project-metric-value" }, card.value), + (0, import_react20.createElement)("span", { className: "bt-project-metric-label" }, t(card.label)), + (0, import_react20.createElement)("span", { className: "bt-project-metric-caption" }, t(card.caption)) + )) + ) + ); +} +function ProjectRunningTable({ view, onOpen }) { + if (view.running.length === 0) return (0, import_react20.createElement)("section", { className: "bt-project-running", "data-running": "0" }, (0, import_react20.createElement)("h4", null, t("Running tasks")), (0, import_react20.createElement)("div", { className: "bt-project-empty" }, t("Nothing is running."))); + return (0, import_react20.createElement)( + "section", + { className: "bt-project-running", "data-running": String(view.running.length) }, + (0, import_react20.createElement)("h4", null, t("Running tasks")), + (0, import_react20.createElement)( + "div", + { className: "bt-project-running-head" }, + (0, import_react20.createElement)("span", null, t("Task")), + (0, import_react20.createElement)("span", null, t("Status")), + (0, import_react20.createElement)("span", null, t("Who")), + (0, import_react20.createElement)("span", null, t("Claimed for")), + (0, import_react20.createElement)("span", null, t("Source")), + (0, import_react20.createElement)("span", null, t("Last activity")) + ), + view.running.map((row) => { + const open = row.board === null ? void 0 : () => onOpen?.(row.board); + return (0, import_react20.createElement)( + "div", + { + key: row.key, + className: "bt-project-running-row", + "data-task-id": row.id, + "data-overdue": row.overdue ? "true" : "false", + role: open === void 0 ? void 0 : "button", + tabIndex: open === void 0 ? void 0 : 0, + onClick: open + }, + (0, import_react20.createElement)("span", { className: "bt-project-running-title" }, row.title), + (0, import_react20.createElement)("span", { className: "bt-project-running-status" }, t(row.status)), + (0, import_react20.createElement)("span", null, row.who ?? METRIC_DASH), + (0, import_react20.createElement)("span", null, metricText("since", row.sinceMs)), + (0, import_react20.createElement)("span", null, row.source === null ? METRIC_DASH : t(SOURCE_LABELS[row.source] ?? row.source)), + (0, import_react20.createElement)("span", null, row.lastActivityAt === null ? METRIC_DASH : new Date(row.lastActivityAt).toLocaleString(dateLocale())) + ); + }) + ); +} +function ProjectQueueGroups({ view, onOpen }) { + return view.queues.map((entry) => (0, import_react20.createElement)( + "section", + { key: `queue:${entry.group}`, className: "bt-project-group", "data-queue": entry.group }, + (0, import_react20.createElement)("h4", null, t(entry.label)), + entry.items.map((row) => { + const open = row.board === null ? void 0 : () => onOpen?.(row.board); + const note = entry.group === "waiting" ? (0, import_react20.createElement)("span", { className: "bt-project-wait" }, [row.role === null ? null : (0, import_react20.createElement)("span", { key: "role", className: "bt-project-role" }, row.role), waitedLabel(row.waitedMs) === null ? null : ` · ${waitedLabel(row.waitedMs)}`]) : entry.group === "inflight" ? (0, import_react20.createElement)("span", { className: "bt-project-state" }, row.holder === null ? t(row.state) : `${row.holder} · ${t(row.state)}`) : (0, import_react20.createElement)("span", { className: "bt-project-reason" }, row.reason === null ? null : waitedLabel(row.waitedMs) === null ? row.reason : `${row.reason} · ${waitedLabel(row.waitedMs)}`); + return (0, import_react20.createElement)( + "div", + { + key: row.key, + className: "bt-project-row", + "data-task-id": row.id, + "data-state": row.state ?? void 0, + role: open === void 0 ? void 0 : "button", + tabIndex: open === void 0 ? void 0 : 0, + onClick: open, + onKeyDown: open === void 0 ? void 0 : (event) => { + if (event.key === "Enter" || event.key === " ") open(); + } + }, + (0, import_react20.createElement)( + "span", + { className: "bt-project-title-wrap" }, + (0, import_react20.createElement)("span", { className: "bt-project-title" }, row.title ?? `#${row.id}`) + ), + note + ); + }) + )); +} +var countText = (value) => value === null ? t("unknown") : String(value); +function ProjectBoardRecord({ record, projectId, call: call4 }) { + const { decisions, meetings } = record; + const [open, setOpen] = (0, import_react20.useState)(null); + const toggle = (path) => { + if (open !== null && open.path === path) { + setOpen(null); return; } - running = true; - try { - pass(); - } finally { - running = false; - } - if (again) { - again = false; - apply2(); - } + if (typeof call4 !== "function") return; + setOpen({ path, answer: null }); + const settle = (answer) => setOpen((current) => current !== null && current.path === path ? { path, answer } : current); + call4("project-decision", { id: projectId, path }).then(settle, (error) => settle({ ok: false, reason: String(error?.message ?? error) })); }; - apply2(); - const offSessions = sessions.subscribe(apply2); - const offRoster = roster.subscribe(apply2); - return () => { - offSessions?.(); - offRoster?.(); - for (const held of slots.values()) held.off(); - slots.clear(); + const decisionBody = (file) => { + if (open === null || open.path !== file.path) return null; + const answer = open.answer; + if (answer === null) return (0, import_react20.createElement)("div", { className: "bt-project-decision-text", "data-open-decision": file.path }, t("Reading the decision…")); + if (answer.ok !== true) return (0, import_react20.createElement)("div", { className: "bt-project-decision-text bt-project-decision-refusal", "data-open-decision": file.path, "data-refused": "true" }, text(answer.reason) || t("not listed")); + return (0, import_react20.createElement)("pre", { className: "bt-project-decision-text", "data-open-decision": file.path }, typeof answer.text === "string" ? answer.text : ""); }; + const decisionRows = decisions.files.map((file) => (0, import_react20.createElement)( + "div", + { key: file.key, className: "bt-project-decision-wrap" }, + (0, import_react20.createElement)( + "button", + { + type: "button", + className: "bt-project-decision", + "data-path": file.path, + "data-sha256": file.sha256, + "data-open": open !== null && open.path === file.path ? "true" : void 0, + "aria-expanded": open !== null && open.path === file.path ? "true" : "false", + onClick: () => toggle(file.path) + }, + (0, import_react20.createElement)("span", { className: "bt-project-date" }, file.date), + (0, import_react20.createElement)("span", { className: "bt-project-title" }, file.title === "" ? file.name : file.title), + (0, import_react20.createElement)("span", { className: "bt-project-hash", title: file.sha256 }, file.shortHash) + ), + decisionBody(file) + )); + const recipientText = (list) => list.length === 0 ? null : list.map((name) => `@${name}`).join(", "); + const proxyText = (meeting) => meeting.participants.filter((participant) => participant.speaksFor !== null).map((participant) => `${participant.name} ${t("for {user}", { user: participant.speaksFor })}`).join(", "); + const proxyRow = (meeting) => proxyText(meeting) === "" ? null : (0, import_react20.createElement)("span", { className: "bt-project-speaks-for", "data-speaks-for": meeting.participants.filter((p) => p.speaksFor !== null).map((p) => p.speaksFor).join(", ") }, proxyText(meeting)); + const levelOf = (meeting) => (0, import_react20.createElement)("span", { className: "bt-project-level", "data-level": meeting.level }, t(meeting.level)); + const decidedRows = meetings.decided.map((meeting) => (0, import_react20.createElement)( + "div", + { key: meeting.key, className: "bt-project-meeting", "data-status": "decided" }, + (0, import_react20.createElement)("span", { className: "bt-project-date" }, meeting.date), + (0, import_react20.createElement)("span", { className: "bt-project-title" }, meeting.question), + meeting.owner === null ? null : (0, import_react20.createElement)("span", { className: "bt-project-owner" }, `@${meeting.owner}`), + proxyRow(meeting), + // A decided meeting whose file is gone still reads as decided; the missing path says so + // instead of the row disappearing with the file. + meeting.path === "" ? (0, import_react20.createElement)("span", { className: "bt-project-missing" }, "decision file gone") : (0, import_react20.createElement)("span", { className: "bt-project-path" }, meeting.path) + )); + const pendingRows = meetings.pending.map((meeting) => (0, import_react20.createElement)( + "div", + { key: meeting.key, className: "bt-project-meeting", "data-status": meeting.status, "data-level": meeting.level }, + (0, import_react20.createElement)("span", { className: "bt-project-date" }, meeting.date), + (0, import_react20.createElement)("span", { className: "bt-project-title" }, meeting.blockingQuestion === null ? meeting.question : meeting.blockingQuestion), + levelOf(meeting), + recipientText(meeting.recipients) === null ? null : (0, import_react20.createElement)("span", { className: "bt-project-recipients" }, recipientText(meeting.recipients)), + meeting.advance.allowed ? null : (0, import_react20.createElement)("span", { className: "bt-project-paused" }, meeting.advance.reason), + proxyRow(meeting), + (0, import_react20.createElement)("span", { className: "bt-project-status" }, meeting.status) + )); + const moreLine = (hidden, key) => hidden > 0 ? (0, import_react20.createElement)("div", { key, className: "bt-project-more" }, t("{count} more not shown", { count: hidden })) : null; + return (0, import_react20.createElement)( + "section", + { + className: "bt-project-record", + // `data-decisions` stays "rows drawn"; the total rides beside it so the view can be asserted. + "data-decisions": String(decisions.count), + ...decisions.total === null ? {} : { "data-decisions-total": String(decisions.total) }, + "data-meetings": String(meetings.count) + }, + (0, import_react20.createElement)("h4", null, t("Decisions")), + decisions.empty ? (0, import_react20.createElement)("div", { className: "bt-project-empty bt-project-record-empty" }, t(decisions.emptyText)) : decisionRows, + moreLine(decisions.hidden, "decisions-more"), + (0, import_react20.createElement)("h4", null, t("Meetings")), + meetings.empty ? (0, import_react20.createElement)("div", { className: "bt-project-empty bt-project-record-empty" }, t(meetings.emptyText)) : decidedRows, + moreLine(meetings.decidedHidden, "decided-more"), + meetings.pending.length === 0 ? null : (0, import_react20.createElement)("h5", null, t("Not decided")), + meetings.pending.length === 0 ? null : pendingRows, + moreLine(meetings.pendingHidden, "pending-more") + ); } - -// src/client/schedules.js -var import_react21 = require("react"); -var POLL_MS4 = 3e4; -var shared = null; -var listeners = /* @__PURE__ */ new Set(); -function publish(view) { - shared = view; - for (const listener of listeners) listener(view); -} -function publishFailure(error) { - publish({ available: false, tasks: [], readFailure: hostText(error?.message ?? String(error)) }); -} -function useSchedules(actions) { - const [view, setView] = (0, import_react21.useState)(shared); - (0, import_react21.useEffect)(() => { - listeners.add(setView); - let stopped = false; - let timer; - const load = () => actions.schedules?.().then((next) => { - if (!stopped) publish(next); - }).catch((error) => { - if (!stopped) publishFailure(error); - }).finally(() => { - if (!stopped) timer = setTimeout(load, POLL_MS4); - }); - load(); - return () => { - stopped = true; - clearTimeout(timer); - listeners.delete(setView); - }; - }, []); - return view; +var botIdOf = (entry) => entry !== null && typeof entry === "object" ? text(entry.botId) : ""; +var idList = (value) => Array.isArray(value) ? value.map((id) => text(id)).filter((id) => id !== "") : []; +function projectTeamView(project) { + const stored = project?.roles !== null && typeof project?.roles === "object" ? project.roles : {}; + const owner = []; + const addOwner = (id) => { + const clean = text(id); + if (clean !== "" && !owner.includes(clean)) owner.push(clean); + }; + addOwner(project?.ownerUserId); + for (const id of idList(project?.ownerUserIds)) addOwner(id); + const maintainers = idList(stored.maintainers).filter((id) => !owner.includes(id)); + const members = idList(stored.members).filter((id) => !owner.includes(id) && !maintainers.includes(id)); + return { + chief: botIdOf(project?.chief), + agent: botIdOf(project?.assistant), + roles: { owner, maintainers, members }, + empty: owner.length === 0 && maintainers.length === 0 && members.length === 0, + // Both come from the `projects` listing (#1828), which the overlay keeps on the record: the + // `project-read` record answers no rank, and the panel never re-derives the host's policy. + role: text(project?.role), + choices: idList(project?.choices) + }; } -var schedulesReadFailure = (failure) => typeof failure === "string" && failure !== "" ? t("The scheduled tasks could not be read: {reason}", { reason: failure }) : null; -var localZone = () => Intl.DateTimeFormat().resolvedOptions().timeZone; -var clock2 = (time) => String(time ?? "").slice(0, 5); -var when2 = (ms, options) => new Intl.DateTimeFormat(dateLocale(), options).format(ms); -function dayAndTime(ms) { - const sameYear = new Date(ms).getFullYear() === (/* @__PURE__ */ new Date()).getFullYear(); - return when2(ms, { ...sameYear ? {} : { year: "numeric" }, month: "short", day: "numeric", hour: "2-digit", minute: "2-digit" }); +var teamRow = (role, label, botId, onOpenBot, onOpenDossier) => (0, import_react20.createElement)( + "div", + { + key: role, + className: "bt-project-team-row", + "data-team-role": role, + // A row with no Bot is the fallback the slice specifies: still drawn, with nothing to open. + ...botId === "" ? { "data-team-state": "not-set" } : { + "data-open-bot": botId, + role: "button", + tabIndex: 0, + "aria-label": t("Open {name}'s chat", { name: botId }), + onClick: () => onOpenBot?.(botId), + onKeyDown: (event) => { + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + onOpenBot?.(botId); + } + } + } + }, + (0, import_react20.createElement)("span", { key: "label", className: "bt-project-team-label" }, label), + botId === "" ? (0, import_react20.createElement)("span", { key: "value", className: "bt-project-missing" }, t("Not set")) : (0, import_react20.createElement)("span", { key: "value", className: "bt-project-team-bot" }, `@${botId}`), + // #1864: the dossier control sits inside the row, so the row still opens the chat (#1860) and a + // reader can look a Bot up first. A row with no Bot keeps the control: its panel reads "not set". + (0, import_react20.createElement)("button", { + key: "dossier", + type: "button", + className: "bt-project-team-dossier", + "data-dossier-role": role, + ...botId === "" ? {} : { "data-dossier-bot": botId }, + "aria-label": t("Open {name}'s dossier", { name: botId === "" ? label : botId }), + onClick: (event) => { + event?.stopPropagation(); + onOpenDossier?.({ role, label, botId }); + }, + onKeyDown: (event) => { + if (event.key === "Enter" || event.key === " ") { + event?.stopPropagation(); + onOpenDossier?.({ role, label, botId }); + } + } + }, t("Dossier")) +); +var POOL_RANKS = [["owner", "owner"], ["maintainer", "maintainers"], ["member", "members"]]; +function ProjectTeam({ project, onOpenBot, onOpenDossier }) { + const view = projectTeamView(project); + const poolId = (id) => (0, import_react20.createElement)("span", { key: id }, `@${id}`); + const ranks = view.empty ? (0, import_react20.createElement)("span", { className: "bt-project-missing" }, t("No one in this pool yet.")) : (0, import_react20.createElement)( + "span", + { className: "bt-project-team-roles" }, + POOL_RANKS.map(([role, key]) => view.roles[key].length === 0 ? null : (0, import_react20.createElement)( + "span", + { key: role, className: "bt-project-team-role", "data-pool-role": role }, + `${roleText(role)}: `, + view.roles[key].map((id) => poolId(id)) + )) + ); + return (0, import_react20.createElement)( + "section", + { className: "bt-project-team", "data-team": "chief,agent,pool" }, + (0, import_react20.createElement)("h4", null, t("Project team")), + teamRow("chief", t("Project Chief"), view.chief, onOpenBot, onOpenDossier), + teamRow("agent", t("My agent"), view.agent, onOpenBot, onOpenDossier), + (0, import_react20.createElement)( + "div", + { key: "pool", className: "bt-project-team-row", "data-team-role": "pool" }, + (0, import_react20.createElement)("span", { key: "label", className: "bt-project-team-label" }, t("Role pool")), + ranks, + // The caller's own rank and what the host said they may switch to: the same answer the + // sidebar menu reads, printed where the pool is. + view.role === "" ? null : (0, import_react20.createElement)( + "span", + { key: "mine", className: "bt-project-team-myrole", "data-team-my-role": view.role }, + [ + t("My role"), + ": ", + roleText(view.role), + view.choices.length === 0 ? null : ` · ${t("Can switch to {roles}", { roles: view.choices.map(roleText).join(", ") })}` + ] + ) + ) + ); } -function instant(value) { - const at = Date.parse(value); - return Number.isFinite(at) ? dayAndTime(at) : ""; +function dossierRowOf(team, botId) { + const id = text(botId); + if (id === "") return null; + const rows = Array.isArray(team?.rows) ? team.rows : []; + return rows.find((row) => row !== null && typeof row === "object" && text(row.botId) === id) ?? null; } -function weekdayNames(days) { - const names = days.map((day) => when2(Date.UTC(2024, 0, day), { weekday: "short", timeZone: "UTC" })); - return names.join(dateLocale().startsWith("zh") ? "、" : ", "); +var dossierSection = (title, children) => (0, import_react20.createElement)( + "div", + { key: title, className: "bt-project-dossier-section" }, + (0, import_react20.createElement)("h4", null, title), + children +); +function ProjectDossierPanel({ row, label, onClose, onOpenBot } = {}) { + const known = row !== null && typeof row === "object"; + const botId = known ? text(row.botId) : ""; + const topics = known && Array.isArray(row.memory?.topics) ? row.memory.topics.map((name) => text(name)).filter((name) => name !== "") : []; + const skills = known && Array.isArray(row.skills) ? row.skills.map((name) => text(name)).filter((name) => name !== "") : []; + const meta = known ? [text(row.role), text(row.model)].filter((value) => value !== "") : []; + return (0, import_react20.createElement)( + "div", + { className: "bt-project-panel bt-project-dossier", "data-panel": "project-dossier", "data-dossier-bot": botId === "" ? void 0 : botId }, + (0, import_react20.createElement)("h3", null, label === "" || label === void 0 ? t("Dossier") : `${label} · ${t("Dossier")}`), + known ? [ + dossierSection(t("Persona"), [ + meta.length === 0 ? null : (0, import_react20.createElement)("div", { key: "meta", className: "bt-project-dossier-meta" }, meta.map((value) => (0, import_react20.createElement)("span", { key: value }, value))), + (0, import_react20.createElement)("p", { key: "persona", className: "bt-project-dossier-text" }, text(row.persona) || t("Not set")) + ]), + dossierSection(t("Memory"), [ + (0, import_react20.createElement)("p", { key: "head", className: "bt-project-dossier-text" }, text(row.memory?.head) || t("No memory yet.")), + topics.length === 0 ? null : (0, import_react20.createElement)("div", { key: "topics", className: "bt-project-dossier-topics" }, topics.map((topic) => (0, import_react20.createElement)("span", { key: topic, className: "bt-project-dossier-topic" }, topic))) + ]), + dossierSection(t("Skills"), (0, import_react20.createElement)("p", { key: "skills", className: "bt-project-dossier-text" }, skills.length === 0 ? t("No skills attached.") : skills.join(", "))) + ] : (0, import_react20.createElement)("p", { className: "bt-project-missing" }, t("Not set")), + botId === "" || !known ? null : (0, import_react20.createElement)( + "div", + { key: "actions", className: "bt-project-row-actions" }, + (0, import_react20.createElement)("button", { + type: "button", + className: "bt-soft bt-soft-sm", + "data-open-bot": botId, + onClick: () => onOpenBot?.(botId) + }, t("Open chat")) + ), + (0, import_react20.createElement)( + "div", + { className: "bt-project-row-actions" }, + (0, import_react20.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: () => onClose?.() }, t("Back to board")) + ) + ); } -function everyText(seconds) { - if (seconds % 86400 === 0) return seconds === 86400 ? t("Every day") : t("Every {count} days", { count: seconds / 86400 }); - if (seconds % 3600 === 0) return seconds === 3600 ? t("Every hour") : t("Every {count} hours", { count: seconds / 3600 }); - if (seconds % 60 === 0) return t("Every {count} minutes", { count: seconds / 60 }); - return t("Every {count} seconds", { count: seconds }); +function projectAgentFor(bot, selfId) { + if (bot === null || typeof bot !== "object" || bot.remote !== void 0) return null; + const projectId = text(bot.projectId); + if (projectId === "") return null; + const owner = text(bot.ownerId); + const self = text(selfId); + if (self !== "" && owner !== "" && owner !== self) return null; + return { projectId }; } -function timingText(task) { - const zone = task.timeZone && task.timeZone !== localZone() ? ` (${task.timeZone})` : ""; - switch (task.kind) { - case "after": - case "at": { - const time = instant(task.scheduledAt); - return time === "" ? "" : t("Once, {time}", { time }); - } - case "every": - return everyText(task.everySeconds); - case "daily": - return `${t("Every day at {time}", { time: clock2(task.time) })}${zone}`; - case "weekly": { - const days = (task.weekdays ?? []).join(); - const text2 = days === "1,2,3,4,5" ? t("Every weekday at {time}", { time: clock2(task.time) }) : days === "6,7" ? t("Every weekend at {time}", { time: clock2(task.time) }) : days === "1,2,3,4,5,6,7" ? t("Every day at {time}", { time: clock2(task.time) }) : t("Every {days} at {time}", { days: weekdayNames(task.weekdays ?? []), time: clock2(task.time) }); - return `${text2}${zone}`; +function ProjectAgentEntry({ projectId, read, onOpen } = {}) { + const [agentId, setAgentId] = (0, import_react20.useState)(""); + const id = text(projectId); + const open = async () => { + let target = agentId; + if (target === "") { + try { + const record = await (read ?? call)("project-read", { id }); + target = botIdOf(record?.project?.assistant); + } catch { + target = ""; + } + if (target !== "") setAgentId(target); } - case "cron": - return `${t("On the schedule {expression}", { expression: task.expression })}${zone}`; - default: - return ""; - } + if (target !== "") onOpen?.(target); + }; + if (id === "") return null; + return (0, import_react20.createElement)("button", { + type: "button", + className: "bt-project-agent-entry", + "data-project-agent": id, + "data-open-bot": agentId === "" ? void 0 : agentId, + title: t("Open my agent"), + "aria-label": t("Open my agent"), + onClick: open + }, t("My agent")); } -var nextText = (task) => { - if (task.status === "paused") return t("Paused"); - if (task.status !== "active") return t("Finished"); - const at = instant(task.scheduledAt); - return at === "" ? timingText(task) : t("Next: {time}", { time: at }); -}; -function lastRunText(run) { - if (run === void 0 || !Number.isFinite(run.at)) return ""; - const time = dayAndTime(run.at); - switch (run.result) { - case "running": - return t("Running since {time}", { time }); - case "stopped": - return t("Last run {time} · Stopped", { time }); - case "failed": - return t("Last run {time} · Failed: {reason}", { time, reason: hostText(run.reason || "Unknown error") }); - default: - return t("Last run {time}", { time }); - } +function ProjectBoardPane({ project, rows, done, canManage, tasks, decisions, meetings, queues, metrics, onOpen, onSettings, onMembers, onOpenBot, onOpenDossier, call: call4 }) { + const view = projectBoardView({ project, rows, done, canManage, tasks, decisions, meetings, queues, metrics }); + return (0, import_react20.createElement)( + "div", + { className: "bt-project-board", "data-can-manage": view.canManage ? "true" : "false" }, + (0, import_react20.createElement)("h3", null, view.title), + (0, import_react20.createElement)(ProjectTeam, { project, onOpenBot, onOpenDossier }), + // Header counts (#1768): four numbers read from the host, an unreadable one shown as unknown. + view.counts === null ? null : (0, import_react20.createElement)( + "div", + { className: "bt-project-counts", "data-counts": "waiting,inflight,blocked,done" }, + (0, import_react20.createElement)("span", null, [t("Waiting"), ": ", (0, import_react20.createElement)("b", { key: "w" }, countText(view.counts.waiting))]), + (0, import_react20.createElement)("span", null, [t("In flight"), ": ", (0, import_react20.createElement)("b", { key: "f" }, countText(view.counts.inflight))]), + (0, import_react20.createElement)("span", null, [t("Blocked"), ": ", (0, import_react20.createElement)("b", { key: "b" }, countText(view.counts.blocked))]), + (0, import_react20.createElement)("span", null, [t("Done"), ": ", (0, import_react20.createElement)("b", { key: "d" }, countText(view.counts.done))]) + ), + // #1749: both buttons open a panel and neither is hidden from a non-owner. The host refuses + // every write a non-owner attempts, so the refusal is an answer the reader sees, not a + // control that silently disappeared. + (0, import_react20.createElement)( + "div", + { className: "bt-project-actions" }, + (0, import_react20.createElement)("button", { type: "button", "data-action": "project-settings", onClick: () => onSettings?.() }, t("Project settings")), + (0, import_react20.createElement)("button", { type: "button", "data-action": "project-members", onClick: () => onMembers?.() }, t("Members")) + ), + (0, import_react20.createElement)(ProjectMetrics, { view: view.metrics }), + (0, import_react20.createElement)(ProjectRunningTable, { view: view.metrics, onOpen }), + (0, import_react20.createElement)(ProjectBoardRows, { view, onOpen }), + (0, import_react20.createElement)(ProjectQueueGroups, { view, onOpen }), + (0, import_react20.createElement)(ProjectDoneGroup, { view, onOpen }), + (0, import_react20.createElement)(ProjectBoardRecord, { record: view.record, projectId: view.projectId, call: call4 }) + ); } -var lockedWindow = (roster) => typeof roster?.readOnly === "string" && roster.readOnly.trim() !== ""; -function TaskRow({ task, actions, onView, onEdit, onCopy, showBot, roster, locked }) { - const [confirming, setConfirming] = (0, import_react21.useState)(false); - const [busy, setBusy] = (0, import_react21.useState)(false); - const [rowError, setRowError] = (0, import_react21.useState)(""); - const refuse = (error) => setRowError(hostText(error?.message ?? String(error))); - (0, import_react21.useEffect)(() => { - if (!confirming) return void 0; - const timer = setTimeout(() => setConfirming(false), 3e3); - return () => clearTimeout(timer); - }, [confirming]); - const remove = () => { - if (!confirming) { - setConfirming(true); - return; +function ProjectSettingsPanel({ project, assistantMine, call: request, onClose, onSaved } = {}) { + const save = request ?? call; + const [kind, setKind] = (0, import_react20.useState)("bot"); + const [memberId, setMemberId] = (0, import_react20.useState)(""); + const [note, setNote] = (0, import_react20.useState)(""); + const noteLength = memoryLength(note); + const noteOver = noteLength > MEMORY_TEXT_MAX; + const noteSaveDisabled = text(note) === "" || noteOver; + const [assistantOff, setAssistantOff] = (0, import_react20.useState)(assistantMine === void 0 ? project?.assistant?.off === true : assistantMine?.off === true); + const [status, setStatus] = (0, import_react20.useState)(""); + const id = text(project?.id); + const run = async (endpoint, payload, done) => { + try { + const result = await save(endpoint, payload); + setStatus(done); + onSaved?.(result); + return result; + } catch (error) { + setStatus(hostText(error?.message ?? String(error))); + return null; } - setBusy(true); - setRowError(""); - void actions.scheduleDelete(task.sessionId, task.id).then((next) => { - setBusy(false); - setRowError(""); - onView(next); - }, (error) => { - setBusy(false); - refuse(error); - }); - }; - const toggle = () => { - setBusy(true); - setRowError(""); - const step = task.status === "paused" ? actions.scheduleResume : actions.schedulePause; - void step(task.sessionId, task.id).then((next) => { - setBusy(false); - setRowError(""); - onView(next); - }, (error) => { - setBusy(false); - refuse(error); - }); }; - const retry = () => { - setBusy(true); - setRowError(""); - void actions.scheduleRetry(task.sessionId, task.id).then((next) => { - setBusy(false); - onView(next); - }, (error) => { - setBusy(false); - refuse(error); - }); + return (0, import_react20.createElement)( + "div", + { className: "bt-project-panel", "data-panel": "project-settings" }, + (0, import_react20.createElement)("h3", null, t("Project settings")), + (0, import_react20.createElement)( + "div", + { className: "bt-project-field" }, + (0, import_react20.createElement)("label", { htmlFor: "bt-project-member-id" }, t("Bind a member to this Project")), + (0, import_react20.createElement)( + "div", + { className: "bt-project-row-actions" }, + (0, import_react20.createElement)( + "select", + { "aria-label": t("Member kind"), value: kind, onChange: (event) => setKind(event.target.value) }, + (0, import_react20.createElement)("option", { value: "bot" }, t("Bot")), + (0, import_react20.createElement)("option", { value: "group" }, t("Group")) + ), + (0, import_react20.createElement)("input", { id: "bt-project-member-id", value: memberId, placeholder: t("Member id"), onChange: (event) => setMemberId(event.target.value) }), + (0, import_react20.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", "data-action": "project-bind", onClick: () => run("project-bind", { kind, id: text(memberId), projectId: id }, t("Member bound.")) }, t("Bind")), + (0, import_react20.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", "data-action": "project-unbind", onClick: () => run("project-bind", { kind, id: text(memberId), projectId: null }, t("Member unbound.")) }, t("Unbind")) + ) + ), + (0, import_react20.createElement)( + "div", + { className: "bt-project-field" }, + (0, import_react20.createElement)("label", null, t("Project Assistant")), + (0, import_react20.createElement)( + "div", + { className: "bt-project-row-actions" }, + (0, import_react20.createElement)("label", null, (0, import_react20.createElement)("input", { type: "checkbox", "data-action": "assistant-off", checked: assistantOff, onChange: (event) => setAssistantOff(event.target.checked) }), " ", t("Pause the daily digest")), + (0, import_react20.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", "data-action": "project-assistant-save", onClick: () => run("project-assistant-owner", { id, settings: { off: assistantOff === true } }, t("Assistant settings saved.")) }, t("Save")) + ) + ), + (0, import_react20.createElement)( + "div", + { className: "bt-project-field" }, + // The host knows two memory kinds; the free text the panel takes is recorded as a decision, + // never as a third kind the host would refuse (#1820). + (0, import_react20.createElement)("label", { htmlFor: "bt-project-note" }, t("Add to the assistant memory")), + (0, import_react20.createElement)("textarea", { id: "bt-project-note", value: note, onChange: (event) => setNote(event.target.value) }), + (0, import_react20.createElement)("div", { className: "bt-project-count", "data-role": "project-memory-count", "data-over": noteOver ? "true" : "false" }, `${noteLength}/${MEMORY_TEXT_MAX}`), + (0, import_react20.createElement)( + "div", + { className: "bt-project-row-actions" }, + (0, import_react20.createElement)("button", { + type: "button", + className: "bt-soft bt-soft-sm", + "data-action": "project-memory-save", + disabled: noteSaveDisabled, + // A disabled button draws no click, but the guard is what a keyboard or a stale tree + // could still reach: neither state sends a request or writes anything. + onClick: () => { + if (noteSaveDisabled) return; + run("project-memory-write", { id, kind: "decision", text: text(note) }, t("Memory saved.")); + } + }, t("Save memory")) + ) + ), + (0, import_react20.createElement)("div", { className: "bt-project-status-line", role: "status" }, status), + (0, import_react20.createElement)( + "div", + { className: "bt-project-row-actions" }, + (0, import_react20.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: () => onClose?.() }, t("Back to board")) + ) + ); +} +function ProjectMembersPanel({ project, rows, onClose } = {}) { + const owners = []; + const add = (id) => { + const clean = text(id); + if (clean !== "" && !owners.includes(clean)) owners.push(clean); }; - const room2 = task.roomId ? roster.roomsById[task.roomId] : void 0; - const stoppable = task.status === "active" || task.status === "paused"; - const failed = task.lastRun?.result === "failed"; - const last = lastRunText(task.lastRun); - return (0, import_react21.createElement)( + add(project?.ownerUserId); + for (const id of Array.isArray(project?.ownerUserIds) ? project.ownerUserIds : []) add(id); + const boardMembers = []; + for (const row of Array.isArray(rows) ? rows : []) { + const name = text(row?.assignee); + if (name !== "" && !boardMembers.includes(name)) boardMembers.push(name); + } + const chief = text(project?.chief?.botId); + return (0, import_react20.createElement)( "div", - { className: "bt-set-row bt-task", "data-done": stoppable ? void 0 : "", "data-paused": task.status === "paused" || void 0, "data-busy": busy || void 0 }, - showBot ? (0, import_react21.createElement)(BotAvatar, { bot: roster.byId[task.botId], size: 28, badge: false, live: false }) : null, - (0, import_react21.createElement)( - "span", - { className: "bt-set-copy" }, - (0, import_react21.createElement)("span", { className: "bt-task-title" }, task.title), - (0, import_react21.createElement)("span", { className: "bt-set-hint" }, [timingText(task), room2 ? t("in {name}", { name: room2.name }) : ""].filter(Boolean).join(" · ")), - task.prompt && task.prompt !== task.title ? (0, import_react21.createElement)("span", { className: "bt-task-prompt" }, task.prompt) : null, - last ? (0, import_react21.createElement)("span", { className: "bt-task-last", "data-failed": failed || void 0 }, last) : null, - rowError ? (0, import_react21.createElement)("span", { className: "bt-task-last", "data-failed": "", role: "alert" }, rowError) : null + { className: "bt-project-panel", "data-panel": "project-members" }, + (0, import_react20.createElement)("h3", null, t("Members")), + (0, import_react20.createElement)( + "ul", + { className: "bt-project-members" }, + owners.map((owner) => (0, import_react20.createElement)( + "li", + { key: `owner:${owner}`, className: "bt-project-member", "data-member-role": "owner", "data-member-id": owner }, + (0, import_react20.createElement)("span", null, `@${owner}`), + (0, import_react20.createElement)("span", { className: "bt-project-member-kind" }, t("Owner")) + )), + chief === "" ? null : (0, import_react20.createElement)( + "li", + { key: `chief:${chief}`, className: "bt-project-member", "data-member-role": "chief", "data-member-id": chief }, + (0, import_react20.createElement)("span", null, t("Project Chief")), + (0, import_react20.createElement)("span", { className: "bt-project-member-kind" }, t("Chief of this Project")) + ), + boardMembers.map((name) => (0, import_react20.createElement)( + "li", + { key: `board:${name}`, className: "bt-project-member", "data-member-role": "board", "data-member-id": name }, + (0, import_react20.createElement)("span", null, `@${name}`), + (0, import_react20.createElement)("span", { className: "bt-project-member-kind" }, t("On the board")) + )) ), - (0, import_react21.createElement)("span", { className: "bt-task-next" }, nextText(task)), - // A locked window draws none of the writes the host would refuse (#2232). Hiding, not - // disabling: a disabled button still says this window can do it. - !locked && failed && task.status !== "paused" && actions.scheduleRetry ? (0, import_react21.createElement)("button", { type: "button", className: "bt-mini bt-task-pause", disabled: busy, title: t("Run the task once now"), onClick: retry }, t("Retry")) : null, - !locked && onEdit ? (0, import_react21.createElement)("button", { type: "button", className: "bt-mini bt-task-tool", disabled: busy, "aria-label": t("Edit"), title: t("Edit"), onClick: () => onEdit(task) }, (0, import_react21.createElement)(PencilIcon)) : null, - !locked && onCopy ? (0, import_react21.createElement)("button", { type: "button", className: "bt-mini bt-task-tool", disabled: busy, "aria-label": t("Copy to another Bot or group chat"), title: t("Copy to…"), onClick: () => onCopy(task) }, (0, import_react21.createElement)(DuplicateIcon)) : null, - !locked && stoppable ? (0, import_react21.createElement)("button", { type: "button", className: "bt-mini bt-task-pause", disabled: busy, onClick: toggle }, task.status === "paused" ? t("Resume") : t("Pause")) : null, - locked ? null : (0, import_react21.createElement)("button", { - type: "button", - className: "bt-mini bt-task-remove", - "data-confirm": confirming || void 0, - disabled: busy, - "aria-label": confirming ? t("Click again to delete") : t("Delete task"), - title: confirming ? void 0 : t("Delete task"), - onClick: remove - }, confirming ? t("Delete") : (0, import_react21.createElement)(TrashIcon)) + owners.length === 0 && boardMembers.length === 0 && chief === "" ? (0, import_react20.createElement)("div", { className: "bt-project-empty" }, t("No member is named yet.")) : null, + (0, import_react20.createElement)("p", { className: "bt-project-note" }, t("Member management is not part of this slice; owners and the members their cards name are listed here.")), + (0, import_react20.createElement)( + "div", + { className: "bt-project-row-actions" }, + (0, import_react20.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: () => onClose?.() }, t("Back to board")) + ) ); } -var pad = (number) => String(number).padStart(2, "0"); -function inAnHour() { - const date = new Date(Date.now() + 36e5); - date.setMinutes(0, 0, 0); - return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:00`; -} -var KINDS = [["at", "Once"], ["daily", "Every day"], ["weekly", "Every week"], ["every", "Repeat"]]; -var UNITS = [["seconds", 1, "seconds"], ["minutes", 60, "minutes"], ["hours", 3600, "hours"], ["days", 86400, "days"]]; -function localInput(ms) { - const date = new Date(ms); - return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}`; -} -function formStart(task) { - const seconds = task?.kind === "every" ? Number(task.everySeconds) : 3600; - const [unit, size] = seconds % 86400 === 0 ? ["days", 86400] : seconds % 3600 === 0 ? ["hours", 3600] : seconds % 60 === 0 ? ["minutes", 60] : ["seconds", 1]; - const once = task?.kind === "at" || task?.kind === "after"; +async function readProjectBoard(read, wanted, knownTeam) { + const listing = await read("projects"); + const projects = Array.isArray(listing?.projects) ? listing.projects : []; + const project = wanted === "" ? projects[0] : projects.find((item) => item.id === wanted); + if (project === void 0) return { project: null, rows: [], done: [], canManage: false, tasks: [], team: { rows: [] }, teamFresh: true }; + const zone = typeof Intl === "undefined" || typeof Intl.DateTimeFormat !== "function" ? void 0 : Intl.DateTimeFormat().resolvedOptions().timeZone; + const [record, board, taskList, dossier] = await Promise.all([ + read("project-read", { id: project.id }), + read("project-board", { id: project.id, timeZone: zone }), + read("tasks", {}), + // #1910: #1902 turned this failure into an ordinary empty team, which the poll then served for + // the rest of the open. Keep the two answers apart: a refusal is not fresh, so nothing is cached + // and the next tick asks again, while a real `{ rows: [] }` answer is fresh and frozen as usual. + knownTeam !== void 0 ? { answer: knownTeam, fresh: true } : read("project-team", { id: project.id }).then( + (answer) => ({ answer, fresh: answer !== null && typeof answer === "object" }), + () => ({ answer: null, fresh: false }) + ) + ]); + const tasks = Array.isArray(taskList) ? taskList : taskList?.tasks ?? []; + const queues = { waiting: board?.waiting, inflight: board?.inflight, blocked: board?.blocked, counts: board?.counts ?? null }; + const base = record?.project ?? project; + const withRole = { ...base, role: project.role ?? null, choices: Array.isArray(project.choices) ? project.choices : [] }; return { - title: task?.title ?? "", - prompt: task?.prompt ?? "", - kind: once ? "at" : task?.kind ?? "daily", - at: once && !Number.isNaN(Date.parse(task.scheduledAt)) ? localInput(Date.parse(task.scheduledAt)) : inAnHour(), - time: task?.time ? clock2(task.time) : "09:00", - weekdays: task?.kind === "weekly" && Array.isArray(task.weekdays) ? task.weekdays : [1, 2, 3, 4, 5], - count: Math.max(1, Math.round(seconds / size)), - unit, - timeZone: task?.timeZone || localZone() + project: withRole, + assistantMine: record?.assistantMine ?? null, + rows: board?.tasks ?? [], + done: board?.done ?? [], + canManage: record?.canManage === true, + tasks, + decisions: board?.decisions, + meetings: board?.meetings, + queues, + metrics: board?.metrics ?? null, + team: dossier.answer !== null && typeof dossier.answer === "object" ? dossier.answer : { rows: [] }, + teamFresh: dossier.fresh }; } -function TaskForm({ roster, actions, start, task, onDone, onCancel }) { - const first = formStart(task); - const [target, setTarget] = (0, import_react21.useState)(task ? task.botId ?? task.roomId : start ?? ""); - const [title, setTitle] = (0, import_react21.useState)(first.title); - const [prompt, setPrompt] = (0, import_react21.useState)(first.prompt); - const [kind, setKind] = (0, import_react21.useState)(first.kind); - const [at, setAt] = (0, import_react21.useState)(first.at); - const [time, setTime] = (0, import_react21.useState)(first.time); - const [weekdays, setWeekdays] = (0, import_react21.useState)(first.weekdays); - const [count, setCount] = (0, import_react21.useState)(first.count); - const [unit, setUnit] = (0, import_react21.useState)(first.unit); - const [busy, setBusy] = (0, import_react21.useState)(false); - const [error, setError] = (0, import_react21.useState)(""); - const timing = () => { - if (kind === "cron") return { kind: "keep" }; - if (kind === "at") return { kind, at: new Date(at).toISOString(), timeZone: first.timeZone }; - if (kind === "every") return { kind, seconds: Math.round(Number(count) * UNITS.find(([id]) => id === unit)[1]) }; - return { kind, time, timeZone: first.timeZone, ...kind === "weekly" ? { weekdays } : {} }; - }; - const submit = (event) => { - event.preventDefault(); - if (busy || target === "") return; - setBusy(true); - setError(""); - let when3; +var PROJECT_BOARD_POLL_MS = 5e3; +var boardPollPaused = () => typeof document !== "undefined" && document.visibilityState === "hidden" || typeof window !== "undefined" && !window.location.hash.startsWith("#project"); +function ProjectBoardOverlay({ actions, call: request } = {}) { + const read = request ?? call; + const [state, setState] = (0, import_react20.useState)(null); + const [panel, setPanel] = (0, import_react20.useState)(null); + const [dossier, setDossier] = (0, import_react20.useState)(null); + const team = (0, import_react20.useRef)(void 0); + const teamGen = (0, import_react20.useRef)(0); + const [hash, setHash] = (0, import_react20.useState)(() => typeof window === "undefined" ? "" : window.location.hash); + const close = (0, import_react20.useCallback)(() => { + if (typeof window !== "undefined") window.location.hash = ""; + setPanel(null); + setState(null); + }, []); + (0, import_react20.useEffect)(() => { + if (typeof window === "undefined") return void 0; + const onKey = (event) => { + if (event.key !== "Escape") return; + if (panel !== null) setPanel(null); + else close(); + }; + window.addEventListener("keydown", onKey); + return () => window.removeEventListener("keydown", onKey); + }, [panel, close]); + (0, import_react20.useEffect)(() => { + if (typeof window === "undefined") return void 0; + const sync = () => setHash(window.location.hash); + window.addEventListener("hashchange", sync); + sync(); + return () => window.removeEventListener("hashchange", sync); + }, []); + (0, import_react20.useEffect)(() => { + if (typeof window === "undefined") return void 0; + if (!hash.startsWith("#project")) { + setState(null); + return void 0; + } + const wanted = hash.slice("#project".length).replace(/^=/, ""); + let gone = false; + team.current = void 0; + teamGen.current = 0; + const loadBoard = () => readProjectBoard(read, wanted, team.current); + const refresh = async (first) => { + const gen = teamGen.current; + try { + const next = await loadBoard(); + if (!gone) { + const newer = teamGen.current !== gen; + if (next.teamFresh !== false && !newer) team.current = next.team; + setState(newer && team.current !== void 0 ? { ...next, team: team.current } : next); + } + } catch { + if (!gone && first) setState({ project: null, rows: [], canManage: false, tasks: [] }); + } + }; + void refresh(true); + const timer = setInterval(() => { + if (!boardPollPaused()) void refresh(false); + }, PROJECT_BOARD_POLL_MS); + return () => { + gone = true; + clearInterval(timer); + }; + }, [hash]); + const openDossier = async (value) => { + setDossier(value); + setPanel("dossier"); + const id = state?.project?.id; + if (typeof id !== "string" || id === "") return; + const gen = teamGen.current + 1; + teamGen.current = gen; try { - when3 = timing(); + const answer = await read("project-team", { id }); + if (teamGen.current !== gen || answer === null || typeof answer !== "object") return; + team.current = answer; + setState((previous) => previous === null ? previous : { ...previous, team: answer }); } catch { - setError(t("Pick a date and time")); - setBusy(false); - return; } - const saved = task ? actions.scheduleUpdate({ sessionId: task.sessionId, id: task.id, target, title, prompt, when: when3 }) : actions.scheduleCreate({ target, title, prompt, when: when3 }); - void saved.then(onDone, (failure) => { - setError(hostText(failure?.message ?? String(failure))); - setBusy(false); - }); }; - const toggleDay = (day) => setWeekdays((days) => days.includes(day) ? days.filter((item) => item !== day) : [...days, day].sort()); - return (0, import_react21.createElement)( - "form", - { className: "bt-set-card bt-task-form", onSubmit: submit }, - (0, import_react21.createElement)( - "label", - { className: "bt-set-row bt-bs-row" }, - (0, import_react21.createElement)("span", { className: "bt-set-copy" }, (0, import_react21.createElement)("span", { className: "bt-bs-label" }, t("Send to"))), - (0, import_react21.createElement)( - "select", - { className: "bt-select bt-task-bot", value: target, onChange: (event) => setTarget(event.target.value) }, - (0, import_react21.createElement)("option", { value: "" }, t("Pick a Bot or group chat")), - (0, import_react21.createElement)("optgroup", { label: t("Bots") }, roster.bots.map((bot) => (0, import_react21.createElement)("option", { key: bot.id, value: bot.id }, bot.name))), - roster.rooms.length > 0 ? (0, import_react21.createElement)("optgroup", { label: t("Group chats") }, roster.rooms.map((room2) => (0, import_react21.createElement)("option", { key: room2.id, value: room2.id }, room2.name))) : null - ) - ), - (0, import_react21.createElement)( - "label", - { className: "bt-set-row bt-bs-row" }, - (0, import_react21.createElement)("span", { className: "bt-set-copy" }, (0, import_react21.createElement)("span", { className: "bt-bs-label" }, t("Name"))), - (0, import_react21.createElement)("input", { className: "bt-bs-input", value: title, maxLength: 120, placeholder: t("Morning briefing"), autoFocus: true, onChange: (event) => setTitle(event.target.value) }) - ), - (0, import_react21.createElement)( - "label", - { className: "bt-set-row bt-bs-row bt-bs-stack" }, - (0, import_react21.createElement)("span", { className: "bt-set-copy" }, (0, import_react21.createElement)("span", { className: "bt-bs-label" }, t("What to do"))), - (0, import_react21.createElement)("textarea", { className: "bt-bs-input bt-bs-area", value: prompt, rows: 3, placeholder: t("Sum up what changed since yesterday and what needs me today."), onChange: (event) => setPrompt(event.target.value) }) - ), - (0, import_react21.createElement)( + if (state === null) return null; + return (0, import_react20.createElement)( + "div", + { className: "bt-project-overlay" }, + (0, import_react20.createElement)("div", { className: "bt-scrim", onClick: close }), + (0, import_react20.createElement)( "div", - { className: "bt-set-row bt-bs-row bt-bs-stack" }, - (0, import_react21.createElement)("span", { className: "bt-set-copy" }, (0, import_react21.createElement)("span", { className: "bt-bs-label" }, t("When"))), - (0, import_react21.createElement)(Segmented, { label: t("When"), value: kind }, KINDS.map(([id, label]) => (0, import_react21.createElement)("button", { key: id, type: "button", "aria-pressed": kind === id, onClick: () => setKind(id) }, t(label)))), - (0, import_react21.createElement)( + { className: "bt-mem bt-project-dialog", role: "dialog", "aria-modal": true, "aria-label": t("Project board") }, + (0, import_react20.createElement)( "div", - { key: kind, className: "bt-task-when bt-veil" }, - kind === "cron" ? (0, import_react21.createElement)("span", { className: "bt-task-every" }, timingText(task)) : null, - kind === "at" ? (0, import_react21.createElement)("input", { className: "bt-input", type: "datetime-local", value: at, "aria-label": t("Date and time"), onChange: (event) => setAt(event.target.value) }) : null, - kind === "weekly" ? (0, import_react21.createElement)("div", { className: "bt-task-days", role: "group", "aria-label": t("Days") }, [1, 2, 3, 4, 5, 6, 7].map((day) => (0, import_react21.createElement)("button", { - key: day, - type: "button", - className: "bt-task-day", - "aria-pressed": weekdays.includes(day), - onClick: () => toggleDay(day) - }, when2(Date.UTC(2024, 0, day), { weekday: "narrow", timeZone: "UTC" })))) : null, - kind === "daily" || kind === "weekly" ? (0, import_react21.createElement)("input", { className: "bt-input bt-task-time", type: "time", value: time, "aria-label": t("Time"), onChange: (event) => setTime(event.target.value) }) : null, - kind === "every" ? (0, import_react21.createElement)( - import_react21.Fragment, - null, - (0, import_react21.createElement)("span", { className: "bt-task-every" }, t("Every")), - (0, import_react21.createElement)("input", { className: "bt-input bt-task-count", type: "number", min: 1, value: count, "aria-label": t("How many"), onChange: (event) => setCount(event.target.value) }), - (0, import_react21.createElement)( - "select", - { className: "bt-select bt-task-unit", value: unit, "aria-label": t("Unit"), onChange: (event) => setUnit(event.target.value) }, - UNITS.map(([id, , label]) => (0, import_react21.createElement)("option", { key: id, value: id }, t(label))) - ) - ) : null - ) - ), - error ? (0, import_react21.createElement)("div", { className: "bt-set-row" }, (0, import_react21.createElement)("span", { className: "bt-danger bt-set-hint", role: "alert" }, error)) : null, - (0, import_react21.createElement)( - "div", - { className: "bt-set-row bt-task-foot" }, - (0, import_react21.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: onCancel }, t("Cancel")), - (0, import_react21.createElement)( - "button", - { type: "submit", className: "bt-send bt-soft-sm", disabled: busy || !target || title.trim() === "" || prompt.trim() === "" }, - task ? busy ? t("Saving…") : t("Save changes") : busy ? t("Creating…") : t("Create task") + { className: "bt-mem-head" }, + (0, import_react20.createElement)("h2", {}, t("Project board")), + (0, import_react20.createElement)("span", { className: "bt-mem-head-gap" }), + (0, import_react20.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm bt-project-close", autoFocus: true, onClick: close }, t("Close")) + ), + (0, import_react20.createElement)( + "div", + { className: "bt-project-body" }, + state.project === null ? (0, import_react20.createElement)( + "div", + { className: "bt-project-empty" }, + (0, import_react20.createElement)("p", null, t("No Project yet — create one.")), + (0, import_react20.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: () => { + close(); + actions?.openSettings?.("projects"); + } }, t("Open Projects in Settings")) + ) : panel === "dossier" ? (0, import_react20.createElement)(ProjectDossierPanel, { + row: dossierRowOf(state.team, dossier?.botId), + label: dossier?.label ?? "", + onClose: () => setPanel(null), + onOpenBot: (id) => { + close(); + actions?.openSession?.(id); + } + }) : panel === "settings" ? (0, import_react20.createElement)(ProjectSettingsPanel, { project: state.project, assistantMine: state.assistantMine, call: read, onClose: () => setPanel(null) }) : panel === "members" ? (0, import_react20.createElement)(ProjectMembersPanel, { project: state.project, rows: state.rows, onClose: () => setPanel(null) }) : (0, import_react20.createElement)(ProjectBoardPane, { + project: state.project, + rows: state.rows, + done: state.done, + canManage: state.canManage, + tasks: state.tasks, + decisions: state.decisions, + meetings: state.meetings, + queues: state.queues, + metrics: state.metrics, + team: state.team, + // #2041: a decision row opens through the same channel read the page polls with. + call: read, + onSettings: () => setPanel("settings"), + onMembers: () => setPanel("members"), + // #1864: a team row's Dossier control opens the read-only panel, fed by the same read. + onOpenDossier: (value) => { + void openDossier(value); + }, + // #1860: a team row opens that Bot's chat. The board is a modal over the conversation, + // so it closes first — otherwise the chat opens behind it. + onOpenBot: (id) => { + close(); + actions?.openSession?.(id); + }, + // The task's own board is where a row leads: the Tasks dialog reads `#task=` on mount and + // lands on that Bot or group. Nothing here re-decides which board that is. + onOpen: (board) => { + if (typeof window !== "undefined") window.location.hash = `#task=${board.kind}:${board.id}`; + actions?.openBoard?.(); + setState(null); + } + }) ) ) ); } -function openPlugins(actions) { - actions.closeSettings(); - actions.selectPanel("plugins"); + +// src/client/header.js +var import_react21 = require("react"); +var import_react_dom6 = require("react-dom"); +var GoArrow = () => (0, import_react21.createElement)("svg", { width: 14, height: 14, viewBox: "-12 -12 24 24", fill: "none", stroke: "currentColor", strokeWidth: 2.2, strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": true }, (0, import_react21.createElement)("path", { d: "M-7 0H7M2 -5L7 0L2 5" })); +var MEMORY_NOTICE_MS = 3200; +var noNews = (select) => select({}); +function useMemoryNotice(ids, useMemoryNews) { + const seen = useMemoryNews((map) => Math.max(0, ...ids.map((id) => map?.[id]?.seen ?? 0))); + const [shown, setShown] = (0, import_react21.useState)(false); + (0, import_react21.useEffect)(() => { + const left = seen + MEMORY_NOTICE_MS - Date.now(); + setShown(seen > 0 && left > 0); + if (seen === 0 || left <= 0) return void 0; + const timer = setTimeout(() => setShown(false), left); + return () => clearTimeout(timer); + }, [seen]); + return shown; } -function AutomationOff({ actions }) { +function MemoryNotice({ shown }) { + const inner = (0, import_react21.useRef)(null); + const [width, setWidth] = (0, import_react21.useState)(0); + const label = t("Memory updated"); + (0, import_react21.useLayoutEffect)(() => { + if (shown) setWidth(inner.current?.scrollWidth ?? 0); + }, [label, shown]); return (0, import_react21.createElement)( - "div", - { className: "bt-set-card bt-task-off" }, - (0, import_react21.createElement)("span", { className: "bt-task-off-mark" }, (0, import_react21.createElement)(ClockIcon)), - (0, import_react21.createElement)("span", { className: "bt-task-off-title" }, t("Turn on Automation tasks in DSH")), - (0, import_react21.createElement)("span", { className: "bt-set-hint" }, t("Switch on Automation tasks on the Plugins page.")), - (0, import_react21.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: () => openPlugins(actions) }, t("Open Plugins")) + "span", + { className: "bt-pill-mem", "aria-hidden": true, style: { "--bt-mem-w": `${width}px` } }, + (0, import_react21.createElement)("span", { className: "bt-pill-mem-in", ref: inner }, (0, import_react21.createElement)("span", { className: "bt-pill-mem-ico" }, (0, import_react21.createElement)(MemoryIcon)), label) ); } -function QuietHoursCard({ view, actions, locked }) { - const quiet = view.quietHours ?? { on: false }; - const [busy, setBusy] = (0, import_react21.useState)(false); - const [error, setError] = (0, import_react21.useState)(""); - const [from, setFrom] = (0, import_react21.useState)(quiet.from ?? "22:00"); - const [to, setTo] = (0, import_react21.useState)(quiet.to ?? "07:00"); - const unchecked = view.quietUnchecked === true; - const refresh = () => { - void Promise.resolve(actions.schedules?.()).then((next) => { - if (next) publish(next); - }).catch(() => { - }); - }; - const save = (next) => { - if (busy) return; - setBusy(true); - setError(""); - const shown = next.on ? { on: true, from: next.from ?? from, to: next.to ?? to } : { on: false }; - void Promise.resolve(actions.setPrefs({ quietHours: next })).then(() => { - publish({ ...view, quietHours: shown }); - refresh(); - }, (failure) => setError(hostText(failure?.message ?? String(failure)))).finally(() => setBusy(false)); - }; +function HeaderPill({ sessionId, useRoster, useSessions, useSessionStatus, useSession, useActivity, useBusyAcks, useMemoryNews, useTurnClock, useUi, useLive, actions }) { + const roster = useRoster((value) => value); + const title = useSessions((list) => list.byId[sessionId]?.displayTitle ?? list.byId[sessionId]?.title ?? ""); + const firstTurns = useSessions((list) => { + const outline = list.projectionsBySession?.[sessionId]?.values?.turnOutline ?? list.byId[sessionId]?.projectionValues?.turnOutline; + return Array.isArray(outline) ? outline.slice(0, 8).map((entry) => entry.turn).join(",") : ""; + }); + const hiddenTurns = roster.exchangeTurns?.[sessionId]; + const visit = useUi((state) => state.visits?.[sessionId]); + const opening = useTurnClock((state) => { + const first = openingTurn(state[sessionId], firstTurns, hiddenTurns); + return first && !unseenSince(visit, first.start) ? clockLabel(first.start) : ""; + }); + (0, import_react21.useEffect)(() => { + if (opening) document.body.style.setProperty("--bt-first-sep", JSON.stringify(opening)); + else document.body.style.removeProperty("--bt-first-sep"); + return () => document.body.style.removeProperty("--bt-first-sep"); + }, [opening]); + const running = useSessionStatus((map) => map.get(sessionId)?.running === true); + const owner = roster.byId[sessionId]?.id ?? sessionId; + const doing = useActivity((map) => map[owner]); + const bot = roster.byId[sessionId]; + const projectAgent = projectAgentFor(bot, roster.self?.memberId); + const room2 = roster.roomsById[sessionId]; + const detailsOpen = useUi((state) => state.details === sessionId); + const full2 = bot?.remote ? friendBotTitle(bot) : roomTitle(room2) || bot?.name || ""; + const face = room2?.origin === "face-to-face"; + const name = face ? faceRowTitle(full2) : full2; + const useNews = useMemoryNews ?? noNews; + const memoryIds = bot ? [bot.id] : room2?.members ?? []; + const notice = useMemoryNotice(memoryIds, useNews); + const changed2 = useNews((map) => latestMemoryChange(map, memoryIds)); + const memoryNotice = notice && changed2 !== null && roster.memory !== false; return (0, import_react21.createElement)( - "section", - { className: "bt-set-section" }, - (0, import_react21.createElement)("h3", null, t("Quiet hours")), + "div", + { className: "bt-head" }, (0, import_react21.createElement)( - "div", - { className: "bt-set-card" }, - (0, import_react21.createElement)( - "div", - { className: "bt-set-row bt-bs-row" }, - (0, import_react21.createElement)( - "span", - { className: "bt-set-copy" }, - (0, import_react21.createElement)("span", { className: "bt-bs-label" }, quiet.on ? t("On, {from}–{to}", { from: quiet.from, to: quiet.to }) : t("Off")), - (0, import_react21.createElement)("span", { className: "bt-set-hint" }, t("A new or edited task cannot be due inside the window. It never mutes a task that already exists.")) - ), - locked ? null : (0, import_react21.createElement)("button", { - type: "button", - className: "bt-soft bt-soft-sm", - disabled: busy, - "aria-pressed": quiet.on, - onClick: () => save(quiet.on ? { on: false } : { on: true, from, to }) - }, quiet.on ? t("Turn off") : t("Turn on")) - ), - quiet.on && !locked ? (0, import_react21.createElement)( - "div", - { className: "bt-set-row bt-bs-row" }, - (0, import_react21.createElement)("span", { className: "bt-set-copy" }, (0, import_react21.createElement)("span", { className: "bt-bs-label" }, t("Window"))), - (0, import_react21.createElement)( - "div", - { className: "bt-task-when" }, - (0, import_react21.createElement)("input", { className: "bt-input bt-task-time", type: "time", value: from, "aria-label": t("Quiet hours start"), disabled: busy, onChange: (event) => setFrom(event.target.value) }), - (0, import_react21.createElement)("span", { className: "bt-task-every" }, "–"), - (0, import_react21.createElement)("input", { className: "bt-input bt-task-time", type: "time", value: to, "aria-label": t("Quiet hours end"), disabled: busy, onChange: (event) => setTo(event.target.value) }), - (0, import_react21.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", disabled: busy, onClick: () => save({ on: true, from, to }) }, t("Save window")) - ) - ) : null, - error ? (0, import_react21.createElement)("div", { className: "bt-set-row" }, (0, import_react21.createElement)("span", { className: "bt-danger bt-set-hint", role: "alert" }, error)) : null, - quiet.on && unchecked ? (0, import_react21.createElement)("div", { className: "bt-set-row" }, (0, import_react21.createElement)("span", { className: "bt-set-hint" }, t("A repeat or cron task can still run inside quiet hours, because DSH decides its next run. FlyBot says so here rather than claiming a silence it cannot keep."))) : null - ) + "button", + { + type: "button", + className: "bt-pill", + "aria-expanded": detailsOpen, + "data-memory": memoryNotice ? "updated" : void 0, + "aria-label": memoryNotice ? t("Memory updated. Open {name}'s memory", { name: roster.byId[changed2]?.name ?? full2 }) : t("View conversation details"), + onClick: () => memoryNotice ? actions.openMemory(changed2) : actions.toggleDetails(sessionId) + }, + bot ? (0, import_react21.createElement)(BotAvatar, { bot, size: 24, state: botState(running, doing, roster.questions?.[currentPart(bot)] !== void 0), badge: false }) : room2 ? (0, import_react21.createElement)(RoomAvatar, { room: room2, roster, size: 24 }) : null, + (0, import_react21.createElement)("span", { className: face ? "bt-pill-name bt-pill-name-face" : "bt-pill-name", title: face ? full2 : void 0 }, name || title || t("New chat")), + (0, import_react21.createElement)(MemoryNotice, { shown: memoryNotice }), + (0, import_react21.createElement)("span", { className: "bt-pill-go", "aria-hidden": true }, (0, import_react21.createElement)(GoArrow)) + ), + (0, import_react21.createElement)("span", { className: "bt-sr", role: "status" }, memoryNotice ? t("Memory updated") : ""), + projectAgent === null ? null : (0, import_react21.createElement)(ProjectAgentEntry, { + projectId: projectAgent.projectId, + read: call, + onOpen: (id) => actions?.openSession?.(id) + }), + (0, import_react21.createElement)(NewMessagePills, { key: sessionId, sessionId, hiddenTurns, useSessions, useUi, useTurnClock, actions }), + (0, import_react21.createElement)(ActivityIndicator, { key: `activity:${sessionId}`, sessionId, useRoster, useSessionStatus, useSession, useLive, actions }), + (0, import_react21.createElement)(BusyAckBubble, { key: `busy-ack:${sessionId}`, sessionId, useRoster, useBusyAcks, useSessionStatus, useSession, useLive }) ); } -function SchedulesPage({ roster, actions, start }) { - const view = useSchedules(actions); - const [adding, setAdding] = (0, import_react21.useState)(Boolean(start)); - const [editing, setEditing] = (0, import_react21.useState)(null); - const locked = lockedWindow(roster); - const refused = schedulesReadFailure(view?.readFailure); - if (refused !== null) return (0, import_react21.createElement)("div", { className: "bt-set-hint bt-danger", role: "status" }, refused); - if (view === null) return (0, import_react21.createElement)("div", { className: "bt-set-hint" }, t("Loading…")); - if (!view.available) return (0, import_react21.createElement)(AutomationOff, { actions }); - const shown = (next) => { - publish(next); - setAdding(false); - setEditing(null); - }; - const edit = (task) => { - setAdding(false); - setEditing(task); - }; - const byChat = [ - ...roster.bots.map((bot) => [bot.id, (0, import_react21.createElement)(BotAvatar, { bot, size: 18, badge: false, live: false }), bot.name, view.tasks.filter((task) => task.botId === bot.id)]), - ...roster.rooms.map((room2) => [room2.id, (0, import_react21.createElement)(RoomAvatar, { room: room2, roster, size: 18 }), room2.name, view.tasks.filter((task) => task.botId === void 0 && task.roomId === room2.id)]) - ].filter(([, , , tasks]) => tasks.length > 0); - return [ - (0, import_react21.createElement)(QuietHoursCard, { key: "quiet", view, actions, locked }), - // A locked window draws no create control and opens no form (#2232): the header already says - // why, and a button the host refuses is the defect this page had. - locked ? null : adding ? (0, import_react21.createElement)(TaskForm, { key: "form", roster, actions, start, onDone: shown, onCancel: () => setAdding(false) }) : (0, import_react21.createElement)("div", { key: "add", className: "bt-actions" }, (0, import_react21.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm bt-with-icon", onClick: () => { - setEditing(null); - setAdding(true); - } }, (0, import_react21.createElement)(PlusIcon), t("New task"))), - byChat.length === 0 && !adding ? (0, import_react21.createElement)("div", { key: "none", className: "bt-set-card" }, (0, import_react21.createElement)("div", { className: "bt-set-row" }, (0, import_react21.createElement)("span", { className: "bt-set-hint" }, view.someHidden ? t("No scheduled tasks you can see. Another member's tasks stay hidden.") : t("No scheduled tasks yet.")))) : byChat.map(([id, avatar, name, tasks]) => (0, import_react21.createElement)( - "section", - { key: id, className: "bt-set-section" }, - (0, import_react21.createElement)("h3", { className: "bt-task-head" }, avatar, name), - (0, import_react21.createElement)(TaskList, { tasks, editing, roster, actions, locked, onEdit: edit, onDone: shown, onCancel: () => setEditing(null) }) - )) - ]; -} -function TaskList({ tasks, editing, roster, actions, locked, onEdit, onDone, onCancel, extra = [] }) { - const open = !locked && editing ? tasks.find((task) => task.id === editing.id && task.sessionId === editing.sessionId) : void 0; - const [copying, setCopying] = (0, import_react21.useState)(null); - const source = copying ? tasks.find((task) => task.id === copying.id && task.sessionId === copying.sessionId) : void 0; - const rows = tasks.filter((task) => task !== open && task !== source).map((task) => (0, import_react21.createElement)(TaskRow, { key: task.id, task, actions, roster, locked, onView: publish, onEdit, onCopy: actions.scheduleCreate ? setCopying : void 0 })); - return (0, import_react21.createElement)( - import_react21.Fragment, - null, - open ? (0, import_react21.createElement)(TaskForm, { key: `edit-${open.id}`, roster, actions, task: open, onDone, onCancel }) : null, - source ? (0, import_react21.createElement)(CopyRow, { key: `copy-${source.id}`, task: source, roster, actions, onView: (next) => { - setCopying(null); - onDone(next); - }, onCancel: () => setCopying(null) }) : null, - rows.length + extra.length > 0 ? (0, import_react21.createElement)("div", { className: "bt-set-card" }, ...rows, ...extra) : null - ); +function useTranscriptView() { + const [view, setView] = (0, import_react21.useState)(null); + (0, import_react21.useEffect)(() => { + let frame = 0; + let watched = null; + const observer = new MutationObserver(() => schedule()); + const measure = () => { + frame = 0; + const scroller = document.querySelector("[data-conversation-scroll]"); + if (scroller !== watched) { + observer.disconnect(); + watched = scroller; + if (scroller) observer.observe(scroller, { childList: true, subtree: true }); + } + if (!scroller) { + setView(null); + return; + } + const box = scroller.getBoundingClientRect(); + const headerBottom = document.querySelector('header[class*="_header"]')?.getBoundingClientRect().bottom ?? box.top + 50; + const sep = scroller.querySelector(".bt-new-sep"); + let divider = null; + if (sep) { + const at = sep.getBoundingClientRect(); + divider = at.bottom <= headerBottom ? "above" : at.top >= box.bottom ? "below" : "in-view"; + } + const composer = parseFloat(getComputedStyle(scroller).getPropertyValue("--dsh-composer-height")) || 152; + const next = { + unpinned: scroller.querySelector('[class*="_toBottomSlot"]>button') !== null, + divider, + center: Math.round(box.left + box.width / 2), + top: Math.round(headerBottom + 8), + bottom: Math.round(window.innerHeight - box.bottom + composer + 8) + }; + setView((prev) => prev && Object.keys(next).every((key) => prev[key] === next[key]) ? prev : next); + }; + const schedule = () => { + if (!frame) frame = requestAnimationFrame(measure); + }; + document.addEventListener("scroll", schedule, true); + window.addEventListener("resize", schedule); + schedule(); + return () => { + cancelAnimationFrame(frame); + observer.disconnect(); + document.removeEventListener("scroll", schedule, true); + window.removeEventListener("resize", schedule); + }; + }, []); + return view; } -function CopyRow({ task, roster, actions, onView, onCancel }) { - const others = [ - ...roster.bots.filter((bot) => bot.id !== task.botId).map((bot) => [bot.id, bot.name]), - ...roster.rooms.filter((room2) => room2.id !== task.roomId).map((room2) => [room2.id, room2.name]) - ]; - const [target, setTarget] = (0, import_react21.useState)(""); - const [busy, setBusy] = (0, import_react21.useState)(false); - const [error, setError] = (0, import_react21.useState)(""); - const submit = (event) => { - event.preventDefault(); - if (busy || target === "") return; - setBusy(true); - setError(""); - void actions.scheduleCreate({ target, copyFrom: { sessionId: task.sessionId, id: task.id } }).then(onView, (failure) => { - setBusy(false); - setError(hostText(failure?.message ?? String(failure))); - }); - }; - return (0, import_react21.createElement)( - "form", - { className: "bt-set-row bt-task-copy", onSubmit: submit }, - (0, import_react21.createElement)("span", { className: "bt-set-copy" }, (0, import_react21.createElement)("span", { className: "bt-bs-label" }, t("Copy “{name}” to", { name: task.title }))), +function NewMessagePills({ sessionId, hiddenTurns, useSessions, useUi, useTurnClock, actions }) { + const replies = useSessions((list) => { + const outline = list.projectionsBySession?.[sessionId]?.values?.turnOutline ?? list.byId[sessionId]?.projectionValues?.turnOutline; + return Array.isArray(outline) ? outline.reduce((count, entry) => count + (entry.response ? 1 : 0), 0) : 0; + }); + const visit = useUi((state) => state.visits?.[sessionId]); + const unread = useTurnClock((state) => { + if (typeof visit?.seen !== "number") return 0; + let count = 0; + for (const entry of Object.values(state[sessionId] ?? {})) { + if (entry.first !== void 0 && entry.first > visit.seen && entry.first <= visit.entered) count += 1; + } + return count; + }); + const view = useTranscriptView(); + const unpinned = view?.unpinned === true; + const [release, setRelease] = (0, import_react21.useState)({ base: null, dismissed: false }); + const [above, setAbove] = (0, import_react21.useState)({ seen: false, dismissed: false }); + (0, import_react21.useEffect)(() => { + setRelease({ base: unpinned ? replies : null, dismissed: false }); + }, [unpinned]); + (0, import_react21.useEffect)(() => { + if (view?.divider === "in-view") setAbove((state) => state.seen ? state : { ...state, seen: true }); + }, [view?.divider]); + const movedOn = useTurnClock((state) => movedOnSince(visit, state[sessionId], hiddenTurns)); + (0, import_react21.useEffect)(() => { + if (above.seen && movedOn) actions?.settleVisit?.(sessionId); + }, [above.seen, movedOn, sessionId]); + const fresh = release.base === null ? 0 : replies - release.base; + const showDown = unpinned && fresh > 0 && !release.dismissed; + const showUp = view?.divider === "above" && !above.seen && !above.dismissed; + (0, import_react21.useEffect)(() => { + const scroller = document.querySelector("[data-conversation-scroll]"); + if (!scroller) return void 0; + scroller.toggleAttribute("data-bt-news", showDown); + return () => scroller.removeAttribute("data-bt-news"); + }, [showDown]); + if (!view || !showDown && !showUp) return null; + const label = (count) => count === 1 ? t("1 new message") : count > 0 ? t("{count} new messages", { count }) : t("New messages"); + const pill = (direction, count, style, onJump, onDismiss) => (0, import_react21.createElement)( + "div", + { className: "bt-news", "data-direction": direction, style }, (0, import_react21.createElement)( - "select", - { className: "bt-select bt-task-bot", value: target, "aria-label": t("Copy to"), onChange: (event) => setTarget(event.target.value) }, - (0, import_react21.createElement)("option", { value: "" }, t("Pick a Bot or group chat")), - others.map(([id, name]) => (0, import_react21.createElement)("option", { key: id, value: id }, name)) + "button", + { type: "button", className: "bt-news-jump", onClick: onJump }, + (0, import_react21.createElement)("span", { className: "bt-news-ico" }, (0, import_react21.createElement)(direction === "up" ? ArrowUpIcon : ArrowDownIcon)), + label(count) ), - (0, import_react21.createElement)("button", { type: "submit", className: "bt-soft bt-soft-sm", disabled: busy || target === "" }, busy ? t("Copying…") : t("Copy")), - (0, import_react21.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: onCancel }, t("Cancel")), - error ? (0, import_react21.createElement)("span", { className: "bt-danger bt-set-hint", role: "alert" }, error) : null - ); -} -function BotSchedules({ bot, roster, actions }) { - const view = useSchedules(actions); - const [editing, setEditing] = (0, import_react21.useState)(null); - const locked = lockedWindow(roster); - const refused = schedulesReadFailure(view?.readFailure); - if (refused !== null) { - return (0, import_react21.createElement)( - "section", - { className: "bt-set-section" }, - (0, import_react21.createElement)("h3", null, t("Scheduled tasks")), - (0, import_react21.createElement)("div", { className: "bt-set-card" }, (0, import_react21.createElement)( - "div", - { className: "bt-set-row bt-bs-row" }, - (0, import_react21.createElement)("span", { className: "bt-set-copy" }, (0, import_react21.createElement)("span", { className: "bt-set-hint bt-danger", role: "status" }, refused)) - )) - ); - } - if (view === null) return null; - const tasks = view.available ? view.tasks.filter((task) => task.botId === bot.id) : []; - return (0, import_react21.createElement)( - "section", - { className: "bt-set-section" }, - (0, import_react21.createElement)("h3", null, t("Scheduled tasks")), - !view.available ? (0, import_react21.createElement)( - "div", - { className: "bt-set-card" }, - (0, import_react21.createElement)( - "div", - { className: "bt-set-row bt-bs-row" }, - (0, import_react21.createElement)("span", { className: "bt-set-copy" }, (0, import_react21.createElement)("span", { className: "bt-set-hint" }, t("Switch on Automation tasks on the Plugins page."))), - (0, import_react21.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: () => openPlugins(actions) }, t("Open Plugins")) - ) - ) : (0, import_react21.createElement)(TaskList, { - tasks, - editing, - roster, - actions, - locked, - onEdit: locked ? void 0 : setEditing, - onDone: (next) => { - publish(next); - setEditing(null); - }, - onCancel: () => setEditing(null), - extra: locked ? [] : [(0, import_react21.createElement)("button", { key: "new", type: "button", className: "bt-set-row bt-bs-row bt-gs-add", onClick: () => actions.openSettings("schedules", bot.id) }, (0, import_react21.createElement)(PlusIcon), t("New task"))] - }) + (0, import_react21.createElement)("button", { + type: "button", + className: "bt-news-x", + "aria-label": t("Dismiss new messages"), + onClick: (event) => { + event.stopPropagation(); + onDismiss(); + } + }, (0, import_react21.createElement)(CloseIcon)) ); -} -function SummaryLines({ tasks, actions }) { - const shown = tasks.slice(0, 3); - const hidden = tasks.length - shown.length; - return (0, import_react21.createElement)( - import_react21.Fragment, - null, - (0, import_react21.createElement)("div", { className: "bt-section-title" }, hidden > 0 ? t("Scheduled tasks · {count}", { count: tasks.length }) : t("Scheduled tasks")), + const toLatest = () => { + const scroller = document.querySelector("[data-conversation-scroll]"); + const back = scroller?.querySelector('[class*="_toBottomSlot"]>button'); + if (back) back.click(); + else scroller?.scrollTo({ top: scroller.scrollHeight, behavior: "smooth" }); + }; + const toDivider = () => document.querySelector("[data-conversation-scroll] .bt-new-sep")?.scrollIntoView({ block: "center", behavior: "smooth" }); + return (0, import_react_dom6.createPortal)( (0, import_react21.createElement)( - "div", - { className: "bt-task-list" }, - shown.map((task) => (0, import_react21.createElement)( - "button", - { key: task.id, type: "button", className: "bt-task-line", onClick: () => actions.openSettings("schedules") }, - (0, import_react21.createElement)(ClockIcon), - (0, import_react21.createElement)("span", { className: "bt-task-line-title" }, task.title), - (0, import_react21.createElement)("span", { className: "bt-task-line-when" }, nextText(task)) - )), - hidden > 0 ? (0, import_react21.createElement)("button", { key: "more", type: "button", className: "bt-more", onClick: () => actions.openSettings("schedules") }, t("{count} more not shown", { count: hidden })) : null - ) + import_react21.Fragment, + null, + showUp ? pill("up", unread, { left: view.center, top: view.top }, toDivider, () => setAbove((state) => ({ ...state, dismissed: true }))) : null, + showDown ? pill("down", fresh, { left: view.center, bottom: view.bottom }, toLatest, () => setRelease((state) => ({ ...state, dismissed: true }))) : null + ), + document.body ); } -function ScheduleSummary({ bot, roster, actions }) { - const view = useSchedules(actions); - if (view === null || view.readFailure !== void 0) return null; - const tasks = view.available ? view.tasks.filter((task) => task.botId === bot.id) : []; - if (lockedWindow(roster) && tasks.length === 0) return null; - return (0, import_react21.createElement)( - "div", - null, - tasks.length === 0 ? (0, import_react21.createElement)("div", { className: "bt-section-title" }, t("Scheduled tasks")) : (0, import_react21.createElement)(SummaryLines, { tasks, actions }), - tasks.length === 0 ? (0, import_react21.createElement)("button", { type: "button", className: "bt-more", onClick: () => actions.openSettings("schedules", bot.id) }, view.available === false ? t("Switch on Automation tasks on the Plugins page.") : view.someHidden ? t("No scheduled tasks you can see. Add one") : t("No scheduled tasks yet. Add one")) : null - ); + +// src/client/composer-label.js +var ATTRIBUTES = ["aria-label", "data-placeholder"]; +function holdComposerLabel(doc, label) { + doc.body.style.setProperty("--bt-placeholder", JSON.stringify(label)); + const shell = /* @__PURE__ */ new Map(); + const apply2 = () => { + for (const el of doc.querySelectorAll("[data-composer-input]")) { + for (const name of ATTRIBUTES) { + const current = el.getAttribute(name); + if (current === label) continue; + if (!shell.has(el)) shell.set(el, {}); + shell.get(el)[name] = current; + el.setAttribute(name, label); + } + } + }; + apply2(); + const Observer = doc.defaultView?.MutationObserver ?? globalThis.MutationObserver; + const watcher = typeof Observer === "function" ? new Observer(apply2) : null; + watcher?.observe(doc.body, { subtree: true, childList: true, attributes: true, attributeFilter: ATTRIBUTES }); + return () => { + watcher?.disconnect(); + for (const [el, values] of shell) { + if (!el.isConnected) continue; + for (const [name, value] of Object.entries(values)) { + if (value == null) el.removeAttribute(name); + else el.setAttribute(name, value); + } + } + }; } -function RoomSchedules({ room: room2, actions }) { - const view = useSchedules(actions); - if (view === null || view.readFailure !== void 0 || room2.origin === "face-to-face") return null; - const tasks = view.available ? view.tasks.filter((task) => task.botId === void 0 && task.roomId === room2.id) : []; - if (tasks.length === 0) return null; - return (0, import_react21.createElement)("div", null, (0, import_react21.createElement)(SummaryLines, { tasks, actions })); + +// src/client/header-effects.js +function installHeaderEffects({ sessions, roster, actions, doc = globalThis.document }) { + if (!doc) return () => { + }; + const slots = /* @__PURE__ */ new Map(); + const run = (id, dep, setup) => { + const held = slots.get(id); + if (held !== void 0 && held.dep === dep) return; + held?.off(); + slots.set(id, { dep, off: setup() ?? (() => { + }) }); + }; + let running = false; + let again = false; + const pass = () => { + const list = sessions.getSnapshot(); + const state = roster.getSnapshot(); + const row = Object.values(list?.byId ?? {}).find((entry) => (entry?.retainedBy?.mainView ?? 0) > 0); + const sessionId = row?.id; + const bot = sessionId === void 0 ? void 0 : state?.byId?.[sessionId]; + const room2 = sessionId === void 0 ? void 0 : state?.roomsById?.[sessionId]; + const name = bot?.remote ? friendBotTitle(bot) : roomTitle(room2) || bot?.name || ""; + run("friend-chat", `${sessionId}|${bot?.remote === true}`, () => { + if (!bot?.remote) return void 0; + doc.body.dataset.btFriendChat = ""; + return () => { + delete doc.body.dataset.btFriendChat; + }; + }); + run("remote-empty", `${sessionId}|${bot?.remote === true}|${row?.blank === true}`, () => { + if (!bot?.remote || row?.blank !== true) return void 0; + doc.body.dataset.btRemoteChat = "empty"; + doc.body.style.setProperty("--bt-remote-empty", JSON.stringify(t("Send a message. Your friend approves unless Auto-send is on."))); + return () => { + delete doc.body.dataset.btRemoteChat; + doc.body.style.removeProperty("--bt-remote-empty"); + }; + }); + run("composer-label", `${sessionId}|${name}`, () => holdComposerLabel(doc, name ? t("Message {name}", { name }) : t("Message"))); + run("visit", sessionId, () => { + if (sessionId === void 0) return void 0; + actions.beginVisit(sessionId); + return () => actions.endVisit(sessionId); + }); + run("face-at-menu", `${sessionId}|${room2?.origin === "face-to-face"}`, () => { + if (room2?.origin !== "face-to-face") return void 0; + const stop = actions.filterFaceAtMenu?.(sessionId); + return typeof stop === "function" ? stop : void 0; + }); + run("room-flag", `${sessionId}|${room2 !== void 0}`, () => { + if (room2 === void 0) return void 0; + doc.body.dataset.btRoom = ""; + return () => { + delete doc.body.dataset.btRoom; + }; + }); + }; + const apply2 = () => { + if (running) { + again = true; + return; + } + running = true; + try { + pass(); + } finally { + running = false; + } + if (again) { + again = false; + apply2(); + } + }; + apply2(); + const offSessions = sessions.subscribe(apply2); + const offRoster = roster.subscribe(apply2); + return () => { + offSessions?.(); + offRoster?.(); + for (const held of slots.values()) held.off(); + slots.clear(); + }; } -var SCHEDULE_CSS = ` -.bt-task{gap:10px} -.bt-task-copy{flex-wrap:wrap;gap:8px} -.bt-task-copy .bt-task-bot{flex:1;min-width:140px;max-width:100%} -.bt-task[data-done]{opacity:.6} -.bt-task[data-busy]{opacity:.4} -.bt-task-title{font-size:13px;line-height:18px;font-weight:500;overflow-wrap:anywhere} -.bt-task-prompt{font-size:12px;line-height:16px;color:var(--bt-ink-2);display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden} -.bt-task-next{flex:none;font-size:12px;line-height:16px;color:var(--bt-ink-3);font-variant-numeric:tabular-nums;white-space:nowrap} -.bt-task-remove{display:inline-flex;align-items:center;justify-content:center;min-width:26px;height:26px;color:var(--bt-ink-3)} -.bt-task-remove svg{width:14px;height:14px} -.bt-task-tool{display:inline-flex;align-items:center;justify-content:center;min-width:26px;height:26px;color:var(--bt-ink-3)} -.bt-task-tool svg{width:14px;height:14px} -.bt-task-pause{height:26px;color:var(--bt-ink-2)} -.bt-task-last{font-size:12px;line-height:16px;color:var(--bt-ink-3);overflow-wrap:anywhere} -.bt-task-last[data-failed]{color:#c21d2e} -.bt-task[data-paused] .bt-task-title,.bt-task[data-paused] .bt-task-prompt{color:var(--bt-ink-3)} -.bt-task-remove[data-confirm]{background:rgba(224,33,53,.1);color:#e02135} -.bt-task-head{display:flex;align-items:center;gap:6px} -.bt-task-form{animation:bt-q-in .4s cubic-bezier(.22,1,.36,1),bt-veil-in .2s ease-out} -.bt-task-bot{width:auto;min-width:160px;max-width:58%} -.bt-task-when{display:flex;align-items:center;flex-wrap:wrap;gap:8px} -.bt-task-when .bt-input{width:auto;height:32px} -.bt-task-time{min-width:110px} -.bt-task-count{width:72px!important} -.bt-task-unit{width:auto;height:32px} -.bt-task-every{font-size:13px;color:var(--bt-ink-2)} -.bt-task-days{display:flex;gap:4px} -.bt-task-day{width:30px;height:30px;border-radius:50%;border:1px solid var(--bt-line);background:none;color:var(--bt-ink-2);font:inherit;font-size:12px;cursor:pointer;transition:background-color .12s ease,color .12s ease,border-color .12s ease} -.bt-task-day[aria-pressed=true]{background:var(--bt-user);border-color:var(--bt-user);color:var(--bt-user-ink)} -.bt-task-foot{justify-content:flex-end;gap:8px} -.bt-task-foot .bt-send{height:28px;font-size:12px} -.bt-task-off{align-items:center;gap:8px;padding:28px 20px;text-align:center} -.bt-task-off>*+*{border-top:0} -.bt-task-off-mark{width:44px;height:44px;border-radius:50%;background:var(--bt-hover);display:flex;align-items:center;justify-content:center;color:var(--bt-ink-2)} -.bt-task-off-mark svg{width:20px;height:20px} -.bt-task-off-title{font-size:14px;line-height:20px;font-weight:600} -.bt-task-off .bt-set-hint{max-width:380px} -.bt-task-list{display:flex;flex-direction:column;gap:2px} -.bt-task-line{display:flex;align-items:center;gap:8px;width:100%;min-width:0;padding:6px 8px;border:0;border-radius:8px;background:none;color:var(--bt-ink);font:inherit;font-size:13px;text-align:left;cursor:pointer;transition:background-color .12s ease} -.bt-task-line:hover{background:var(--bt-hover)} -.bt-task-line svg{flex:none;color:var(--bt-ink-3)} -.bt-task-line-title{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} -.bt-task-line-when{flex:none;font-size:12px;color:var(--bt-ink-3)} -@media (prefers-reduced-motion:reduce){.bt-task-form{animation:none}} -`; // src/client/composer.js var import_react22 = require("react"); @@ -14199,7 +14201,7 @@ function EntryRow({ entry, remove }) { (0, import_react28.createElement)("span", { className: "bt-mem-entry-text" }, entry.text), meta ? (0, import_react28.createElement)("span", { className: "bt-mem-entry-meta" }, meta) : null ), - (0, import_react28.createElement)("button", { + remove ? (0, import_react28.createElement)("button", { type: "button", className: "bt-mem-remove", "data-confirm": confirming || void 0, @@ -14207,7 +14209,7 @@ function EntryRow({ entry, remove }) { "aria-label": confirming ? t("Click again to remove") : t("Remove from memory"), title: confirming ? void 0 : t("Remove from memory"), onClick: click - }, confirming ? t("Remove") : (0, import_react28.createElement)(TrashIcon)) + }, confirming ? t("Remove") : (0, import_react28.createElement)(TrashIcon)) : null ); } function Entries({ bot, view, remove }) { @@ -14311,8 +14313,9 @@ function useNow3(every) { }, [every]); return now; } -function MemoryDialog({ bot, actions, leaving = false }) { +function MemoryDialog({ bot, actions, roster, leaving = false }) { const { view, error, load, show } = useMemoryView(bot.id, actions); + const locked = typeof roster?.readOnly === "string" && roster.readOnly.trim() !== ""; const [page, setPage] = (0, import_react28.useState)("summary"); const [menu, setMenu] = (0, import_react28.useState)(false); const [menuShown, menuLeaving] = useLinger(menu, MENU_EXIT_MS2); @@ -14421,14 +14424,15 @@ function MemoryDialog({ bot, actions, leaving = false }) { (0, import_react28.createElement)( "div", { className: "bt-mem-body" }, + locked ? (0, import_react28.createElement)("p", { className: "bt-hint bt-read-only", role: "status" }, hostText(roster.readOnly)) : null, error ? (0, import_react28.createElement)("div", { className: "bt-error", role: "alert", style: { padding: 0 } }, hostText(error)) : null, - view === null ? error ? null : (0, import_react28.createElement)(Skeleton) : page === "entries" ? (0, import_react28.createElement)(Entries, { bot, view, remove }) : (0, import_react28.createElement)(Summary, { bot, view, regenerate }) + view === null ? error ? null : (0, import_react28.createElement)(Skeleton) : page === "entries" ? (0, import_react28.createElement)(Entries, { bot, view, remove: locked ? null : remove }) : (0, import_react28.createElement)(Summary, { bot, view, regenerate }) ), (0, import_react28.createElement)( "div", { className: "bt-mem-foot" }, answer ? (0, import_react28.createElement)(Reply, { answer, dismiss: () => setAnswer(null) }) : null, - (0, import_react28.createElement)( + locked ? null : (0, import_react28.createElement)( "form", { className: "bt-mem-ask", onSubmit: ask }, (0, import_react28.createElement)("input", { @@ -19934,7 +19938,7 @@ function DetailsDrawer({ sessionId, startRenaming, startTab, hasStrip, roster, a "div", { key: "set-body", className: "bt-drawer-body bt-set-body", "data-move": move }, errorLine, - bot ? (0, import_react44.createElement)(BotSettingsForm, { bot, roster, actions, run }) : (0, import_react44.createElement)(GroupSettingsForm, { room: room2, roster, actions, run, onDeleted: close }) + bot && lockedWindow(roster) ? (0, import_react44.createElement)("div", { className: "bt-hint bt-read-only", role: "status" }, hostText(roster.readOnly)) : bot ? (0, import_react44.createElement)(BotSettingsForm, { bot, roster, actions, run }) : (0, import_react44.createElement)(GroupSettingsForm, { room: room2, roster, actions, run, onDeleted: close }) ) ]; } @@ -20795,7 +20799,7 @@ function Overlays({ useRoster, useUi, useSessions, useSessionStatus, useActivity menu ? (0, import_react_dom11.createPortal)((0, import_react47.createElement)(ContextMenu, { key: `${menu.kind ?? ""}:${menu.id}:${menu.x}:${menu.y}`, menu, roster, actions, leaving: menuLeaving }), document.body) : null, settings ? (0, import_react_dom11.createPortal)((0, import_react47.createElement)(SettingsDialog, { page: settings.page, id: settings.id, roster, actions, useUpdate, leaving: settingsLeaving }), document.body) : null, remoteBotSettings ? (0, import_react_dom11.createPortal)((0, import_react47.createElement)(RemoteBotSettingsSheet, { key: remoteBotSettings, bot: roster.byId[remoteBotSettings], roster, actions, leaving: remoteLeaving }), document.body) : null, - memory && roster.byId[memory] ? (0, import_react_dom11.createPortal)((0, import_react47.createElement)(MemoryDialog, { key: memory, bot: roster.byId[memory], actions, leaving: memoryLeaving }), document.body) : null, + memory && roster.byId[memory] ? (0, import_react_dom11.createPortal)((0, import_react47.createElement)(MemoryDialog, { key: memory, bot: roster.byId[memory], roster, actions, leaving: memoryLeaving }), document.body) : null, inbox ? (0, import_react_dom11.createPortal)((0, import_react47.createElement)(InboxDialog, { roster, useWork, useSessions, actions, leaving: inboxLeaving }), document.body) : null, board ? (0, import_react_dom11.createPortal)((0, import_react47.createElement)(KanbanDialog, { roster, actions, leaving: boardLeaving }), document.body) : null ); diff --git a/core/src/client/cells.js b/core/src/client/cells.js index f7aa8002..7ba64fd9 100644 --- a/core/src/client/cells.js +++ b/core/src/client/cells.js @@ -1109,7 +1109,7 @@ function renderCall(call, { sessionId, roster, actions, useUi, findBot }) { const question = roster.questions?.[sessionId] // Answered or closed: the card leaves no gap where it was. if (!question || question.callId !== call.callId) return h('span', { className: 'bt-q-gone', hidden: true }) - return h(PendingQuestion, { sessionId, question, actions, inline: true }) + return h(PendingQuestion, { sessionId, question, actions, roster, inline: true }) } case 'request_secret': return h(SecretToolCell, { sessionId, callId: call.callId, name: arg('name'), done, content: call.content, roster, actions }) diff --git a/core/src/client/details-drawer.js b/core/src/client/details-drawer.js index 3225304a..82d51203 100644 --- a/core/src/client/details-drawer.js +++ b/core/src/client/details-drawer.js @@ -10,10 +10,10 @@ import { FaceLead, FacePersonRow, GroupSettingsForm, ROOM_MODES, facePeople, roo import { ModelMenu, modelOf } from './model-menu.js' import { FolderMenu } from './folder-menu.js' import { MemoryRow } from './memory-panel.js' -import { RoomSchedules, ScheduleSummary } from './schedules.js' +import { RoomSchedules, ScheduleSummary, lockedWindow } from './schedules.js' import { Segmented, useGlide, useMorph } from './motion.js' import { MemberWork, useMemberWork } from './member-status.js' -import { t } from './i18n.js' +import { hostText, t } from './i18n.js' import { remoteBotTitle, roomTitle, copiedToClipboard } from '../shared/remote-label.js' import { FlyBotRoomOverview, FlyBotDetails } from './flybot-slots.js' import { call } from './api.js' @@ -267,7 +267,9 @@ export function DetailsDrawer({ sessionId, startRenaming, startTab, hasStrip, ro h('span', { className: 'bt-subhead-title' }, bot ? t('Settings') : t('Group settings')), hasStrip ? null : h('button', { type: 'button', className: 'bt-icon-btn', 'aria-label': t('Close details'), onClick: close }, h(CloseIcon))), h('div', { key: 'set-body', className: 'bt-drawer-body bt-set-body', 'data-move': move }, errorLine, - bot ? h(BotSettingsForm, { bot, roster, actions, run }) : h(GroupSettingsForm, { room, roster, actions, run, onDeleted: close })), + bot && lockedWindow(roster) + ? h('div', { className: 'bt-hint bt-read-only', role: 'status' }, hostText(roster.readOnly)) + : bot ? h(BotSettingsForm, { bot, roster, actions, run }) : h(GroupSettingsForm, { room, roster, actions, run, onDeleted: close })), ] } const top = h('div', { className: 'bt-drawer-top', 'data-move': side }, diff --git a/core/src/client/memory-panel.js b/core/src/client/memory-panel.js index b02e2bf2..4b246ae3 100644 --- a/core/src/client/memory-panel.js +++ b/core/src/client/memory-panel.js @@ -116,10 +116,10 @@ function EntryRow({ entry, remove }) { h('div', { className: 'bt-mem-entry-copy' }, h('span', { className: 'bt-mem-entry-text' }, entry.text), meta ? h('span', { className: 'bt-mem-entry-meta' }, meta) : null), - h('button', { + remove ? h('button', { type: 'button', className: 'bt-mem-remove', 'data-confirm': confirming || undefined, disabled: busy, 'aria-label': confirming ? t('Click again to remove') : t('Remove from memory'), title: confirming ? undefined : t('Remove from memory'), onClick: click, - }, confirming ? t('Remove') : h(TrashIcon))) + }, confirming ? t('Remove') : h(TrashIcon)) : null) } function Entries({ bot, view, remove }) { @@ -208,8 +208,11 @@ function useNow(every) { return now } -export function MemoryDialog({ bot, actions, leaving = false }) { +export function MemoryDialog({ bot, actions, roster, leaving = false }) { const { view, error, load, show } = useMemoryView(bot.id, actions) + // A read-only window refuses both writes below at the host, so the panel draws neither and + // says why in the host's own sentence — the same one the sidebar and the Settings header carry. + const locked = typeof roster?.readOnly === 'string' && roster.readOnly.trim() !== '' const [page, setPage] = useState('summary') const [menu, setMenu] = useState(false) const [menuShown, menuLeaving] = useLinger(menu, MENU_EXIT_MS) @@ -288,13 +291,14 @@ export function MemoryDialog({ bot, actions, leaving = false }) { h('button', { type: 'button', role: 'menuitem', disabled: count === 0 || view?.summarizing, onClick: regenerate }, h('span', { className: 'bt-menu-label' }, t('Regenerate summary')))) : null), h('div', { className: 'bt-mem-body' }, + locked ? h('p', { className: 'bt-hint bt-read-only', role: 'status' }, hostText(roster.readOnly)) : null, error ? h('div', { className: 'bt-error', role: 'alert', style: { padding: 0 } }, hostText(error)) : null, view === null ? (error ? null : h(Skeleton)) - : page === 'entries' ? h(Entries, { bot, view, remove }) + : page === 'entries' ? h(Entries, { bot, view, remove: locked ? null : remove }) : h(Summary, { bot, view, regenerate })), h('div', { className: 'bt-mem-foot' }, answer ? h(Reply, { answer, dismiss: () => setAnswer(null) }) : null, - h('form', { className: 'bt-mem-ask', onSubmit: ask }, + locked ? null : h('form', { className: 'bt-mem-ask', onSubmit: ask }, h('input', { ref: inputRef, className: 'bt-mem-input', value: text, maxLength: 2000, disabled: view === null, placeholder: t('Ask or update'), 'aria-label': t("Ask about or update {name}'s memory", { name: bot.name }), diff --git a/core/src/client/overlays.js b/core/src/client/overlays.js index 192d850c..7b4d7af3 100644 --- a/core/src/client/overlays.js +++ b/core/src/client/overlays.js @@ -54,7 +54,7 @@ export function Overlays({ useRoster, useUi, useSessions, useSessionStatus, useA menu ? createPortal(h(ContextMenu, { key: `${menu.kind ?? ''}:${menu.id}:${menu.x}:${menu.y}`, menu, roster, actions, leaving: menuLeaving }), document.body) : null, settings ? createPortal(h(SettingsDialog, { page: settings.page, id: settings.id, roster, actions, useUpdate, leaving: settingsLeaving }), document.body) : null, remoteBotSettings ? createPortal(h(RemoteBotSettingsSheet, { key: remoteBotSettings, bot: roster.byId[remoteBotSettings], roster, actions, leaving: remoteLeaving }), document.body) : null, - memory && roster.byId[memory] ? createPortal(h(MemoryDialog, { key: memory, bot: roster.byId[memory], actions, leaving: memoryLeaving }), document.body) : null, + memory && roster.byId[memory] ? createPortal(h(MemoryDialog, { key: memory, bot: roster.byId[memory], roster, actions, leaving: memoryLeaving }), document.body) : null, inbox ? createPortal(h(InboxDialog, { roster, useWork, useSessions, actions, leaving: inboxLeaving }), document.body) : null, board ? createPortal(h(KanbanDialog, { roster, actions, leaving: boardLeaving }), document.body) : null) } diff --git a/core/src/client/questions.js b/core/src/client/questions.js index 4c3db483..864c64f4 100644 --- a/core/src/client/questions.js +++ b/core/src/client/questions.js @@ -1,6 +1,7 @@ import { createElement as h, useEffect, useId, useRef, useState } from 'react' import { CloseIcon, CheckIcon } from './icons.js' -import { t } from './i18n.js' +import { hostText, t } from './i18n.js' +import { lockedWindow } from './schedules.js' import { historyLoading } from './text.js' // ------------------------------------------------------------------------- @@ -40,7 +41,7 @@ function useOptionKeys(count, onPick, ownRef) { // A question shows two ways: as a bubble in the transcript, and raised when it // sits in the dock above the composer. -function QuestionView({ question, onAnswer, onDismiss, ownAnswer = false, raised = false }) { +function QuestionView({ question, onAnswer, onDismiss, ownAnswer = false, raised = false, locked = false }) { const [own, setOwn] = useState('') const [picked, setPicked] = useState([]) const ownRef = useRef(null) @@ -60,9 +61,9 @@ function QuestionView({ question, onAnswer, onDismiss, ownAnswer = false, raised if (!canSubmit) return onAnswer({ selected: multi ? picked : [], ...(custom ? { custom } : {}) }) } - useOptionKeys(options.length, index => choose(options[index].label), ownRef) + useOptionKeys(locked ? 0 : options.length, index => choose(options[index].label), ownRef) const ownLetter = options.length > 0 ? keyLetter(options.length) : undefined - const field = ownAnswer ? h('textarea', { + const field = ownAnswer && !locked ? h('textarea', { ref: ownRef, rows: 1, value: own, autoComplete: 'off', spellCheck: false, className: 'bt-q-input', 'aria-label': t('Your own answer'), placeholder: t('Type your own answer'), 'aria-keyshortcuts': ownLetter?.toLowerCase(), @@ -75,17 +76,23 @@ function QuestionView({ question, onAnswer, onDismiss, ownAnswer = false, raised }) : null const rows = options.map((option, optionIndex) => { const selected = multi && picked.includes(option.label) - return h('button', { - key: option.label, type: 'button', className: 'bt-q-opt', - 'aria-pressed': multi ? selected : undefined, - 'aria-keyshortcuts': keyLetter(optionIndex).toLowerCase(), - onClick: () => choose(option.label), - }, - h('span', { className: 'bt-key', 'aria-hidden': true }, keyLetter(optionIndex)), - h('span', { className: 'bt-q-body' }, - h('span', { className: 'bt-q-label' }, option.label), - option.description ? h('span', { className: 'bt-q-desc' }, option.description) : null), - selected && !raised ? h('span', { className: 'bt-q-check', title: t('Selected') }, h(CheckIcon)) : null) + const body = [ + h('span', { className: 'bt-key', 'aria-hidden': true }, keyLetter(optionIndex)), + h('span', { className: 'bt-q-body' }, + h('span', { className: 'bt-q-label' }, option.label), + option.description ? h('span', { className: 'bt-q-desc' }, option.description) : null), + selected && !raised ? h('span', { className: 'bt-q-check', title: t('Selected') }, h(CheckIcon)) : null, + ] + // A read-only window keeps the question and its rows as text: an option answers at once on a + // single-select card, so a button there is a write the host refuses (#2277). + return locked + ? h('div', { key: option.label, className: 'bt-q-opt' }, ...body) + : h('button', { + key: option.label, type: 'button', className: 'bt-q-opt', + 'aria-pressed': multi ? selected : undefined, + 'aria-keyshortcuts': keyLetter(optionIndex).toLowerCase(), + onClick: () => choose(option.label), + }, ...body) }) // Raised cards keep the custom answer as one more keyed row; bubbles put it below the list. if (raised && field) { @@ -94,23 +101,24 @@ function QuestionView({ question, onAnswer, onDismiss, ownAnswer = false, raised h('span', { className: 'bt-q-body' }, field))) } let foot = null - if (raised && multi) { + if (!locked && raised && multi) { foot = h('div', { className: 'bt-q-foot' }, h('button', { type: 'button', className: 'bt-q-btn', onClick: onDismiss }, t('Skip')), h('button', { type: 'submit', className: 'bt-q-submit', disabled: !canSubmit }, t('Submit'))) - } else if ((raised || multi) && canSubmit) { + } else if (!locked && (raised || multi) && canSubmit) { foot = h('div', { className: 'bt-q-foot' }, h('button', { type: 'submit', className: 'bt-q-submit' }, t('Submit'))) } - return h('form', { + // A read-only card is a plain box, not a form: it has nothing to submit, so it draws no handler. + return h(locked ? 'div' : 'form', { className: raised ? 'bt-q bt-q-raised' : 'bt-q', 'aria-labelledby': titleId, - onSubmit: (event) => { event.preventDefault(); submit() }, + ...(locked ? {} : { onSubmit: (event) => { event.preventDefault(); submit() } }), }, h('div', { className: 'bt-q-head' }, h('div', { className: 'bt-q-text' }, h('p', { className: 'bt-q-title', id: titleId }, question.header ? `${question.header} · ` : '', question.question), question.detail ? h('div', { className: 'bt-q-detail' }, question.detail) : null, raised && multi ? h('div', { className: 'bt-q-detail' }, t('Choose all that apply')) : null), - h('button', { type: 'button', className: 'bt-q-x', 'aria-label': t('Dismiss question'), title: t('Dismiss'), onClick: onDismiss }, h(CloseIcon))), + locked ? null : h('button', { type: 'button', className: 'bt-q-x', 'aria-label': t('Dismiss question'), title: t('Dismiss'), onClick: onDismiss }, h(CloseIcon))), rows.length > 0 ? h('div', { className: 'bt-q-list', role: 'group' }, rows) : null, field && !raised ? h('div', { className: 'bt-q-custom' }, h('label', { className: 'bt-q-field' }, field), @@ -159,9 +167,10 @@ export function QuestionCard({ matched }) { return h(QuestionView, { key: index, question, onAnswer, onDismiss: () => pending.dismiss(), ownAnswer: true, raised: true }) } -export function PendingQuestion({ sessionId, question, actions, inline }) { +export function PendingQuestion({ sessionId, question, actions, roster, inline }) { const [answered, setAnswered] = useState(null) if (answered === question.id) return null + const locked = lockedWindow(roster) const onAnswer = (answer) => { setAnswered(question.id) void actions.answerQuestion(sessionId, question.id, { selected: answer.selected ?? [], custom: answer.custom ?? '' }).catch(() => setAnswered(null)) @@ -171,17 +180,22 @@ export function PendingQuestion({ sessionId, question, actions, inline }) { void actions.dismissQuestion(sessionId) } return h('div', { className: inline ? 'bt-q-inline' : 'bt-q-dock' }, - h(QuestionView, { key: question.id, question, onAnswer, onDismiss, ownAnswer: question.allowCustom === true, raised: !inline })) + // Both controls are writes the host refuses, and the refusal was invisible: the answer came + // back to a card that looked answered and the × hid the card until the next poll (#2277). + // A read-only window draws the question and the host's sentence, and no way to press either. + ...(locked ? [h('div', { className: 'bt-hint bt-read-only', role: 'status' }, hostText(roster.readOnly))] : []), + h(QuestionView, { key: question.id, question, onAnswer, onDismiss, ownAnswer: question.allowCustom === true, raised: !inline, locked })) } // Questions asked by a tool call render inline at that call (see ToolCell); the dock // carries the ones whose call the transcript does not draw. export function QuestionDock({ sessionId, useRoster, useCards, useSession, actions }) { - const question = useRoster(value => value.questions?.[sessionId]) + const roster = useRoster(value => value) + const question = roster.questions?.[sessionId] const drawn = useCards?.(state => state[sessionId]?.[question?.callId] > 0) === true const loading = useSession?.(historyLoading) === true if (question === undefined || (question.callId && (drawn || loading))) return null - return h(PendingQuestion, { sessionId, question, actions, inline: false }) + return h(PendingQuestion, { sessionId, question, actions, roster, inline: false }) } // An answered card in the transcript: the question stays where it was asked, with the diff --git a/core/test/details-bot-settings-readonly.test.js b/core/test/details-bot-settings-readonly.test.js new file mode 100644 index 00000000..ae5d0a12 --- /dev/null +++ b/core/test/details-bot-settings-readonly.test.js @@ -0,0 +1,169 @@ +// A read-only window still opens a Bot's Settings page from the details drawer. The drawer mounted +// `BotSettingsForm` with the roster that carries the host's own read-only sentence but the form +// never read it, so every write control was drawn and every press could only be refused. When the +// roster holds that sentence the drawer now draws the sentence in place of the form, and a +// writable window keeps today's form unchanged. +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { fileURLToPath } from 'node:url' +import { runInNewContext } from 'node:vm' +import { build } from 'esbuild' + +const { outputFiles } = await build({ + stdin: { + contents: "export { DetailsDrawer } from './src/client/details-drawer.js'", + resolveDir: fileURLToPath(new URL('..', import.meta.url)), + }, + bundle: true, format: 'cjs', platform: 'browser', write: false, logLevel: 'silent', + external: ['react', 'react-dom', '@deepseek-ai/dsh-client-ui-primitives'], +}) + +const h = (type, props, ...children) => ({ type, props: { ...props, children: children.flat(Infinity) } }) +// A hook runtime that survives a re-render, the same one the Settings dialog is drawn with: the +// read-only sentence has to come out of the drawer's own tree, not from a re-implementation of it. +let slots = [] +let index = 0 +let effects = [] +const react = { + createElement: h, Component: class {}, Fragment: Symbol('F'), + useMemo: (fn) => { const at = index++; if (!(at in slots)) slots[at] = fn(); return slots[at] }, + useState: (initial) => { + const at = index++ + if (!(at in slots)) slots[at] = typeof initial === 'function' ? initial() : initial + return [slots[at], (value) => { slots[at] = typeof value === 'function' ? value(slots[at]) : value }] + }, + useCallback: (fn) => { const at = index++; if (!(at in slots)) slots[at] = fn; return slots[at] }, + useRef: (value) => { const at = index++; if (!(at in slots)) slots[at] = { current: value }; return slots[at] }, + useEffect: (fn) => { const at = index++; if (!(at in slots)) { slots[at] = true; effects.push(fn) } }, + useLayoutEffect() {}, + useSyncExternalStore: (_subscribe, get) => get(), +} +const win = { location: { hash: '' }, addEventListener() {}, removeEventListener() {}, matchMedia: () => ({ matches: false, addEventListener() {}, removeEventListener() {} }) } +const module = { exports: {} } +runInNewContext(outputFiles[0].text, { + module, exports: module.exports, console, setTimeout, clearTimeout, requestAnimationFrame: () => 0, cancelAnimationFrame() {}, + document: { hidden: false, addEventListener() {}, removeEventListener() {} }, + navigator: { language: 'en' }, + localStorage: { getItem: () => null, setItem() {} }, + window: win, + require: (name) => { + if (name === 'react') return react + if (name === 'react-dom') return { createPortal: node => node } + if (name === '@deepseek-ai/dsh-client-ui-primitives') return {} + throw new Error(`Unexpected platform module ${name}`) + }, +}) +const { DetailsDrawer } = module.exports + +const bot = { id: 'a', name: 'Chief', pinned: false, hidden: false, model: '', instructions: '', workDir: '' } +const base = { bots: [bot], rooms: [], byId: { a: bot }, roomsById: {}, prefs: {}, mainBotIds: ['a'], connectors: [], memory: false, exchangeTurns: {}, questions: {} } +const rosterWith = (readOnly) => (readOnly === undefined ? { ...base } : { ...base, readOnly }) +const SENTENCE = 'The Bot team in /home/xuke/.flybot is open in another dsh (Web or Desktop, pid 41 on host). Close the other one and reload.' +const actions = { + openSettings() {}, closeSettings() {}, closeOverlay() {}, openSession() {}, openBrowser() {}, + notify() {}, reportError() {}, setFlags: () => Promise.resolve(), +} +// The drawer reads its stores through the hooks the shell hands it; these stand in for one render. +const render = (roster, tab = 'settings', acts = actions) => { + index = 0 + effects = [] + const map = () => new Map() + return DetailsDrawer({ + sessionId: 'a', startTab: tab, hasStrip: false, roster, actions: acts, + useSessions: selector => selector({ byId: {}, projectionsBySession: {} }), useSessionStatus: selector => selector(map()), + useActivity: selector => selector(map()), useWork: selector => selector(map()), + useBrowserTabs: selector => selector(map()), useBrowserPages: selector => selector(map()), + }) +} +// The drawer returns elements; a function component is drawn by React, so the tree is expanded +// here the same way: every function component runs with the hook slots above, in tree order. +const expand = (node) => { + if (Array.isArray(node)) return node.map(expand) + if (!node || typeof node !== 'object') return node + const props = node.props ?? {} + if (typeof node.type === 'function') return expand(node.type(props)) + return { ...node, props: { ...props, children: expand(props.children) } } +} +const all = (node, out = []) => { + if (Array.isArray(node)) { for (const item of node) all(item, out); return out } + if (node && typeof node === 'object') { out.push(node); all(node.props?.children, out) } + return out +} +const tree = (roster) => all(expand(render(roster))) +const ownText = (node) => { + const walk = (value) => { + if (Array.isArray(value)) return value.map(walk).join('') + if (typeof value === 'string' || typeof value === 'number') return String(value) + if (value && typeof value === 'object') return walk(value.props?.children) + return '' + } + return walk(node?.props?.children) +} +const bodyOf = (nodes) => nodes.find(node => node.props?.className === 'bt-drawer-body bt-set-body') +// A write entry is anything in the Bot settings body a person can change something with: a text +// field or a button. The drawer's own Back/Close buttons live in the head, outside this body. +const writes = (nodes) => nodes.filter(node => ['input', 'textarea', 'select', 'button'].includes(node.type)) + +test('a read-only window draws no Bot-settings write entry and says why in the host\'s words', () => { + const nodes = tree(rosterWith(SENTENCE)) + const body = bodyOf(nodes) + assert.ok(body, 'the drawer draws its settings body') + const inside = all(body) + assert.equal(writes(inside).length, 0, 'no write entry is drawn in a read-only window') + const note = inside.filter(node => node.props?.role === 'status' && ownText(node).trim() === SENTENCE) + assert.equal(note.length, 1, "the host's sentence is drawn once") + assert.equal(note[0].props?.role, 'status', 'the sentence is a status line') + assert.ok(String(note[0].props?.className ?? '').includes('bt-read-only'), 'the sentence is the read-only hint') +}) + +test('a writable window still draws the Bot settings form', () => { + for (const roster of [rosterWith(undefined), rosterWith(''), rosterWith(' ')]) { + const nodes = tree(roster) + const body = bodyOf(nodes) + assert.ok(body, 'the drawer draws its settings body') + const inside = all(body) + assert.ok(writes(inside).length > 0, 'a writable window keeps its write entries') + assert.equal(inside.some(node => typeof node === 'object' && ownText(node).trim() === SENTENCE), false, 'no read-only sentence is drawn') + } + // The writable form is the same component, mounted with the same roster: the counts are not zero + // on both sides, which is what makes the read-only zero above mean something. + const inside = all(bodyOf(tree(rosterWith(undefined)))) + assert.ok(inside.some(node => node.type === 'input'), 'the writable form draws a text field') +}) + +// P-457/#2232 has a second entry point: the details overview mounts `ScheduleSummary`, and that +// line decides on `roster.readOnly` whether its "No scheduled tasks yet. Add one" is drawn — the +// entry opens the create form a locked settings page cannot draw. `schedules-client.test.js` calls +// `ScheduleSummary` directly and hands it a roster, so a drawer that forgets to pass one keeps that +// suite green; this goes in through the overview's own tree instead. +const readView = { available: true, tasks: [] } +const overviewActions = { ...actions, schedules: () => Promise.resolve(readView) } +// The overview reads its tasks in an effect (one read, then a poll), so it is drawn twice: once to +// queue that read, then again once the view has been published into the hook slots. +const overview = async (roster) => { + // Fresh slots: the two tests above drew the settings page, and these hook slots are positional. + slots = [] + expand(render(roster, 'details', overviewActions)) + const stops = [] + for (const effect of effects) { + try { + const stop = effect() + if (typeof stop === 'function') stops.push(stop) + } catch { /* other cards' own first reads are not this test's subject */ } + } + await new Promise(resolve => setTimeout(resolve, 0)) + const drawn = all(expand(render(roster, 'details', overviewActions))) + // The line polls every 30 s; leaving that timer behind holds the whole file open. + for (const stop of stops) stop() + return drawn +} +const addOneEntries = (nodes) => nodes.filter(node => node.type === 'button' && ownText(node).includes('Add one')) + +test('a read-only details overview draws no Add one for scheduled tasks', async () => { + const locked = await overview(rosterWith(SENTENCE)) + assert.deepEqual(addOneEntries(locked).map(ownText), [], 'no create entry is drawn in a read-only window') + // The same overview in a writable window still offers it, so the empty list above is the lock and + // not a task view that never reached the line. + const writable = await overview(rosterWith(undefined)) + assert.deepEqual(addOneEntries(writable).map(ownText), ['No scheduled tasks yet. Add one'], 'a writable window keeps the Add one entry') +}) diff --git a/core/test/memory-panel-readonly.test.js b/core/test/memory-panel-readonly.test.js new file mode 100644 index 00000000..be03b7f0 --- /dev/null +++ b/core/test/memory-panel-readonly.test.js @@ -0,0 +1,174 @@ +// A window that holds the team read-only can open a Bot's memory dialog, and the panel draws +// two writes — a Remove on every entry and the "Ask or update" form — that the host refuses in +// that window, while nothing inside the panel says why (P-475). The message is the host's own +// sentence, already drawn by the sidebar and the Settings header; this panel is the surface +// that was missing it. A writable window must keep both writes exactly as they are. +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import { fileURLToPath } from 'node:url' +import { runInNewContext } from 'node:vm' +import { build } from 'esbuild' + +const { outputFiles } = await build({ + stdin: { + contents: "export { MemoryDialog } from './src/client/memory-panel.js'\nexport { hostText } from './src/client/i18n.js'", + resolveDir: fileURLToPath(new URL('..', import.meta.url)), + }, + bundle: true, format: 'cjs', platform: 'browser', write: false, logLevel: 'silent', + external: ['react', 'react-dom', '@deepseek-ai/dsh-client-ui-primitives'], +}) +const h = (type, props, ...children) => ({ type, props: { ...props, children: children.flat(Infinity) } }) +// A hook runtime that survives a re-render: the memory view arrives from an async effect, so the +// dialog is drawn once as its skeleton and again with the view, and the test clicks the ⋯ menu +// between those draws. Intervals are stubbed out (the clock never matters here) so the suite does +// not wait on the panel's 30-second "updated ago" beat. +let slots = [] +let index = 0 +let effects = [] +const react = { + createElement: h, Component: class {}, Fragment: Symbol('F'), + useMemo: (fn) => { const at = index++; if (!(at in slots)) slots[at] = fn(); return slots[at] }, + useState: (initial) => { + const at = index++ + if (!(at in slots)) slots[at] = typeof initial === 'function' ? initial() : initial + return [slots[at], (value) => { slots[at] = typeof value === 'function' ? value(slots[at]) : value }] + }, + useCallback: (fn) => { const at = index++; if (!(at in slots)) slots[at] = fn; return slots[at] }, + useRef: (value) => { const at = index++; if (!(at in slots)) slots[at] = { current: value }; return slots[at] }, + useEffect: (fn) => { const at = index++; if (!(at in slots)) { slots[at] = true; effects.push(fn) } }, + useLayoutEffect() {}, + useSyncExternalStore: (_subscribe, get) => get(), +} +const win = { location: { hash: '' }, addEventListener() {}, removeEventListener() {}, matchMedia: () => ({ matches: false, addEventListener() {}, removeEventListener() {} }) } +const module = { exports: {} } +runInNewContext(outputFiles[0].text, { + module, exports: module.exports, console, setTimeout, clearTimeout, setInterval: () => 0, clearInterval() {}, + requestAnimationFrame: () => 0, cancelAnimationFrame() {}, + document: { hidden: false, addEventListener() {}, removeEventListener() {} }, + navigator: { language: 'en' }, + localStorage: { getItem: () => null, setItem() {} }, + window: win, + require: (name) => { + if (name === 'react') return react + if (name === 'react-dom') return { createPortal: node => node } + if (name === '@deepseek-ai/dsh-client-ui-primitives') return {} + throw new Error(`Unexpected platform module ${name}`) + }, +}) +const { MemoryDialog, hostText } = module.exports + +// The dialog's own tree holds component elements (an entry row is a function, not a node), so the +// draw pass renders every function component the way the renderer would, with one hook index +// continuing through the tree. +const draw = (node) => { + if (Array.isArray(node)) return node.map(draw) + if (!node || typeof node !== 'object') return node + if (typeof node.type === 'function') return draw(node.type(node.props)) + if (node.props?.children === undefined) return node + return { ...node, props: { ...node.props, children: draw(node.props.children) } } +} +const render = (props) => { index = 0; return draw(MemoryDialog(props)) } +const reset = () => { slots = []; effects = []; index = 0 } +const flush = async () => { + const queued = effects + effects = [] + for (const fn of queued) await fn() + // Two microtask turns: the loader's promise resolves, then its continuation draws the view. + await Promise.resolve() + await Promise.resolve() +} +const all = (node, out = []) => { + if (Array.isArray(node)) { for (const item of node) all(item, out); return out } + if (node && typeof node === 'object') { out.push(node); all(node.props?.children, out) } + return out +} +const textOf = (node) => { + const walk = (value) => { + if (Array.isArray(value)) return value.map(walk).join('') + if (typeof value === 'string' || typeof value === 'number') return String(value) + if (value && typeof value === 'object') return walk(value.props?.children) + return '' + } + return walk(node?.props?.children) +} +const byClass = (node, name) => all(node).filter(item => typeof item.props?.className === 'string' && item.props.className.split(' ').includes(name)) + +const view = () => ({ + entries: [ + { text: 'A saved fact', scope: 'own', source: 'memory panel', added: 'today' }, + { text: 'A second fact', scope: 'own', source: 'memory panel', added: 'today' }, + ], + sizes: { own: 14, team: 0 }, + limits: { main: 4000 }, + blocked: [], + summary: { at: Date.now() - 60_000, text: 'One saved fact' }, + fresh: true, + summarizing: false, +}) +const actions = () => ({ + memoryView: async () => view(), + memoryForget: async () => { throw new Error('the host should have refused this write') }, + memoryAsk: async () => { throw new Error('the host should have refused this write') }, + closeMemory() {}, +}) + +// The dialog opens on the summary; the Remove buttons live on the entries page, so every case +// walks the same path a reader does: wait for the view, open ⋯, choose All entries. Removing the +// view wait is what would fake the locked result — the skeleton draws no write either. +async function drawEntries(roster) { + reset() + const props = { bot: { id: 'bot1', name: 'Scout' }, actions: actions(), roster } + let tree = render(props) + await flush() + tree = render(props) + // The view resolved: the summary text is drawn and the loading skeleton is gone. Without this, + // the skeleton's 0 writes would make the locked case pass for the wrong reason. + assert.match(textOf(tree), /One saved fact/, 'the view resolved') + assert.equal(byClass(tree, 'bt-mem-skel-block').length, 0) + const menu = all(tree).find(node => node.props?.['aria-label'] === 'Memory options') + assert.ok(menu, 'the ⋯ menu is there') + menu.props.onClick() + tree = render(props) + const entriesItem = all(tree).find(node => node.props?.role === 'menuitem' && textOf(node).includes('All entries')) + assert.ok(entriesItem, 'the menu offers All entries') + const regenerateItem = all(tree).find(node => node.props?.role === 'menuitem' && textOf(node).includes('Regenerate summary')) + entriesItem.props.onClick() + tree = render(props) + await flush() + return { tree, regenerateItem } +} + +test('P-475: a writable memory panel keeps both writes and draws no read-only line', async () => { + const { tree } = await drawEntries({}) + // Two entries in the fixture, so two Remove buttons: the count is real, not "one at least". + assert.equal(byClass(tree, 'bt-mem-remove').length, 2) + assert.equal(byClass(tree, 'bt-mem-ask').length, 1) + assert.equal(byClass(tree, 'bt-read-only').length, 0) + assert.match(textOf(tree), /A saved fact/) + assert.match(textOf(tree), /A second fact/) +}) + +test('P-475: a read-only memory panel draws no write and says in place why', async () => { + const sentence = 'This window is read-only.' + const { tree, regenerateItem } = await drawEntries({ readOnly: sentence }) + // One read of the whole shape: two writes and no sentence (the defect), or no write and one + // sentence (the fix). A skeleton would read 0/0/0 and prove nothing, which is why the entry + // path above waits for the view instead of mounting. + assert.deepEqual([byClass(tree, 'bt-mem-remove').length, byClass(tree, 'bt-mem-ask').length, byClass(tree, 'bt-read-only').length], [0, 0, 1]) + const hints = byClass(tree, 'bt-read-only') + assert.equal(hints.length, 1) + assert.equal(hints[0].props.role, 'status') + assert.equal(textOf(hints[0]), hostText(sentence)) + // The panel is still readable, and the summary actions that read rather than write stay. + assert.match(textOf(tree), /A saved fact/) + assert.match(textOf(tree), /A second fact/) + // Regenerate reads the view and writes no entry, so the lock must not take it away. + assert.ok(regenerateItem, 'Regenerate summary is still offered') + assert.equal(regenerateItem.props.disabled, false) +}) + +test('P-475: the dialog is handed the roster, or the panel cannot know the window', () => { + const overlays = readFileSync(new URL('../src/client/overlays.js', import.meta.url), 'utf8') + assert.match(overlays, /h\(MemoryDialog, \{ key: memory, bot: roster\.byId\[memory\], roster, actions, leaving: memoryLeaving \}\)/) +}) diff --git a/core/test/questions-readonly.test.js b/core/test/questions-readonly.test.js new file mode 100644 index 00000000..51a365cf --- /dev/null +++ b/core/test/questions-readonly.test.js @@ -0,0 +1,182 @@ +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { createHash } from 'node:crypto' +import { fileURLToPath } from 'node:url' +import { runInNewContext } from 'node:vm' +import { build } from 'esbuild' + +// A read-only window draws the `ask_user` card in the dock and inline in the transcript. The card +// was live there: an option reached `answer-question`, whose refusal PendingQuestion swallowed +// (`.catch(() => setAnswered(null))`), so the card came back with nothing said; the × reached +// `dismiss-question` and hid the card until the next poll, which answered it back. Both are writes +// the host refuses (core/src/host/channel.js READS), so a read-only card offers neither. + +const { outputFiles } = await build({ + stdin: { + contents: [ + "export { PendingQuestion, QuestionDock } from './src/client/questions.js'", + "export { ToolCell } from './src/client/cells.js'", + ].join('\n'), + resolveDir: fileURLToPath(new URL('..', import.meta.url)), + }, + bundle: true, format: 'cjs', platform: 'browser', write: false, logLevel: 'silent', + external: ['react', 'react-dom', '@deepseek-ai/dsh-client-ui-primitives'], +}) + +const h = (type, props, ...children) => ({ type, props: { ...props, children } }) +const written = [] +const react = { + createElement: h, Fragment: Symbol('F'), useEffect() {}, useLayoutEffect() {}, + useState: initial => [typeof initial === 'function' ? initial() : initial, value => written.push(value)], + useMemo: make => make(), useCallback: fn => fn, useRef: current => ({ current }), + useId: () => 'card-id', useSyncExternalStore: (_subscribe, get) => get(), +} +const module = { exports: {} } +runInNewContext(outputFiles[0].text, { + module, exports: module.exports, console, + document: { hidden: false, addEventListener() {}, removeEventListener() {}, querySelector: () => null }, + navigator: { language: 'en' }, localStorage: { getItem: () => null, setItem() {} }, + requestAnimationFrame: run => run(), + require: (name) => { + if (name === 'react') return react + if (name === 'react-dom') return { createPortal: node => node } + if (name === '@deepseek-ai/dsh-client-ui-primitives') return { MarkdownText: ({ text }) => text } + throw new Error(`Unexpected platform module ${name}`) + }, +}) +const { PendingQuestion, QuestionDock, ToolCell } = module.exports + +const LOCK = 'Another FlyBot window has this home open; this window is read-only.' +const WRITABLE = { bots: [], byId: {}, rooms: [], roomsById: {}, readOnly: '', questions: {}, exchangeTurns: {} } +const LOCKED = { ...WRITABLE, readOnly: LOCK } +const single = { id: 'q1', callId: 'call-1', header: 'Draft', question: 'Which draft?', options: [{ label: 'Short' }, { label: 'Long' }], allowCustom: true } +const multi = { ...single, id: 'q2', multiSelect: true } + +const expand = (node) => { + if (node === null || node === undefined || typeof node === 'boolean') return null + if (Array.isArray(node)) return node.map(expand) + if (typeof node !== 'object') return node + if (typeof node.type === 'function') return expand(node.type(node.props)) + return { ...node, props: { ...node.props, children: expand(node.props.children) } } +} +const walk = (node, out = []) => { + if (Array.isArray(node)) { for (const child of node) walk(child, out); return out } + if (!node || typeof node !== 'object') return out + out.push(node) + walk(node.props.children, out) + return out +} +const text = (node) => { + if (node === null || node === undefined) return '' + if (Array.isArray(node)) return node.map(text).join('') + return typeof node === 'object' ? text(node.props.children) : String(node) +} +const controlName = (node) => { + if (node.props['aria-label']) return node.props['aria-label'] + if (node.type === 'form') return 'form:submit' + const named = walk(node.props.children).find(child => child.props.className === 'bt-q-label') + return named ? text(named.props.children) : text(node.props.children) +} + +// What the window can be pressed on: every button and field the card draws, and every handler. +const buttons = tree => walk(tree).filter(node => node.type === 'button') +const fields = tree => walk(tree).filter(node => node.type === 'textarea' || node.type === 'input') +const handlers = tree => walk(tree).filter(node => typeof node.props.onClick === 'function' || typeof node.props.onSubmit === 'function') + +const actionsFor = (rejectAnswer) => { + const log = [] + return { + log, + answerQuestion: (...args) => { log.push(['answer-question', ...args]); return rejectAnswer ? Promise.reject(new Error('bot/read-only')) : Promise.resolve({}) }, + dismissQuestion: (...args) => { log.push(['dismiss-question', ...args]); return Promise.resolve({}) }, + } +} +const pressAll = (tree) => { + for (const node of handlers(tree)) { + if (typeof node.props.onClick === 'function') node.props.onClick({ preventDefault() {}, stopPropagation() {} }) + else node.props.onSubmit({ preventDefault() {} }) + } +} +const settle = () => new Promise(resolve => setTimeout(resolve, 0)) + +const dock = (roster, question, actions) => expand(QuestionDock({ + sessionId: 'chief', actions, + useRoster: select => select({ ...roster, questions: { chief: question } }), + useCards: select => select({ chief: {} }), + useSession: select => select({ openState: 'open' }), +})) +const inline = (roster, question, actions) => expand(PendingQuestion({ sessionId: 'chief', question, roster, actions, inline: true })) + +// The same tree the writable window draws, hashed: the fix must leave it byte-for-byte as it was. +const canonical = node => JSON.stringify(node, (_key, value) => (typeof value === 'function' ? `fn:${value.name || 'anon'}` : value)) +const sha256 = node => createHash('sha256').update(canonical(node)).digest('hex') + +test('a read-only window draws no answer, no skip and no dismissal on the question card', async () => { + const actions = actionsFor(true) + const tree = dock(LOCKED, multi, actions) + const drawn = { buttons: buttons(tree).map(controlName), fields: fields(tree).length, handlers: handlers(tree).length } + assert.deepEqual(drawn, { buttons: [], fields: 0, handlers: 0 }, `the read-only card still draws a write: ${JSON.stringify(drawn)}`) + const shown = text(tree) + assert.ok(!shown.includes('Submit') && !shown.includes('Skip'), `the read-only card still says: ${shown}`) +}) + +test('a read-only window neither sends an answer nor hides the card it cannot answer', async () => { + const actions = actionsFor(true) + const tree = dock(LOCKED, single, actions) + written.length = 0 + pressAll(tree) + await settle() + const seen = { pressed: buttons(tree).map(controlName), writes: actions.log, states: [...written] } + // Nothing was sent, so nothing was swallowed and nothing hid: the card a read-only window draws + // is not a button that silently fails, it is the question with the host's sentence above it. + assert.deepEqual(JSON.parse(JSON.stringify({ writes: seen.writes, states: seen.states })), { writes: [], states: [] }, `read-only card pressed: ${JSON.stringify(seen)}`) + assert.match(text(tree), /Another FlyBot window has this home open; this window is read-only/) +}) + +test('a read-only window says why, in the host sentence, above the card it cannot answer', async () => { + const actions = actionsFor(true) + for (const tree of [dock(LOCKED, single, actions), inline(LOCKED, single, actions)]) { + const hints = walk(tree).filter(node => node.props.className === 'bt-hint bt-read-only' && node.props.role === 'status') + assert.equal(hints.length, 1, `the host sentence is drawn ${hints.length} times: ${text(tree)}`) + assert.equal(text(hints[0]), LOCK) + assert.match(text(tree), /Which draft\?.*Short.*Long/s, 'the question and its rows stay readable') + } +}) + +test('the transcript card a Bot turn draws obeys the same window', async () => { + const actions = actionsFor(true) + const call = { kind: 'tool-result', name: 'ask_user', callId: 'call-1', call: { name: 'ask_user', argsRaw: '{}' }, content: [{ type: 'text', text: '' }], isError: false, subCalls: [] } + const tree = expand(ToolCell({ + node: { key: 'node', data: { root: call }, location: { turn: { turn: 1 } } }, sessionId: 'chief', + useRoster: select => select({ ...LOCKED, questions: { chief: single } }), + useUi: select => select({ reactions: {}, visits: {} }), actions, + })) + const drawn = { buttons: buttons(tree).map(controlName), fields: fields(tree).length, handlers: handlers(tree).length } + assert.deepEqual(drawn, { buttons: [], fields: 0, handlers: 0 }, `the inline read-only card still draws a write: ${JSON.stringify(drawn)}`) +}) + +test('a writable window keeps the answer, the own-answer field and the dismissal, all wired', async () => { + const actions = actionsFor(false) + const docked = dock(WRITABLE, single, actions) + const dockedText = text(docked) + assert.match(dockedText, /Which draft\?.*Short.*Long/s) + assert.equal(fields(docked).length, 1, `custom answer field missing: ${dockedText}`) + const optionRow = buttons(docked).find(node => node.props.className === 'bt-q-opt' && controlName(node) === 'Short') + optionRow.props.onClick({ preventDefault() {}, stopPropagation() {} }) + const dismiss = buttons(docked).find(node => node.props['aria-label'] === 'Dismiss question') + assert.ok(dismiss, `dismissal missing from the writable card: ${dockedText}`) + dismiss.props.onClick({ preventDefault() {}, stopPropagation() {} }) + assert.deepEqual(JSON.parse(JSON.stringify(actions.log)), [ + ['answer-question', 'chief', 'q1', { selected: ['Short'], custom: '' }], + ['dismiss-question', 'chief'], + ]) + assert.equal(walk(docked).filter(node => node.props.className === 'bt-hint bt-read-only').length, 0, 'a writable card draws no read-only hint') + console.log(`writable-question-tree-sha256 ${sha256(docked)}`) +}) + +test('a writable window keeps Skip and Submit on the multi-select card', async () => { + const actions = actionsFor(false) + const tree = dock(WRITABLE, multi, actions) + const drawn = buttons(tree).map(controlName) + assert.ok(drawn.includes('Skip') && drawn.includes('Submit'), `writable multi card: ${JSON.stringify(drawn)}`) +}) diff --git a/docs/subsystems/core-client.md b/docs/subsystems/core-client.md index 954ebdb3..c5c3cbe8 100644 --- a/docs/subsystems/core-client.md +++ b/docs/subsystems/core-client.md @@ -13,6 +13,7 @@ The browser side of the patched `ds-bot` core (`core/`): sources in `core/src/cl - Browser client: sidebar, chat, panels, themes, characters, Bot browser panel (`core/client.js` generated from `core/src/client/`). The sidebar **+** new-chat picker (`core/src/client/new-chat.js`) takes extra rows from slot `flybot.new-chat.action` (after New group chat; `label` / `hint` go through the same `t()` map) and lists only `listedChatBots` (gone, hidden, and home-level remotes stay out; owner labels match the sidebar). Covered by `core/test/new-chat.test.js` in patch `0003-flybot-api-client`. +- The chat's `ask_user` card (`questions.js`) answers where it is drawn: one option answers a single-select card at once, a multi-select card toggles its rows and **Submit** sends the set, and the × dismisses it; the same card waits in the dock above the composer when its call is not on screen. In a window that holds the team read-only the card keeps the question, its rows and the host's sentence (`roster.readOnly`, read through `lockedWindow` from `schedules.js`) and draws none of those controls — no option button, **Submit**, **Skip**, × or own-answer field, and no form to submit. Each of them reached a write the host refuses (`answer-question`, `dismiss-question`), and the refusal was invisible: a pressed answer marked the card answered and the next poll said nothing, while the × hid the card until that poll brought it back. Tests: `core/test/questions-readonly.test.js`. - Chat context rows: a tool the plugin registers at install draws one `tool-addition` row, and `cells.js` keeps every name in `TEAM_TOOLS` out of the chat (`SILENT_REGISTRATION_TOOLS`, #1101) — the Project Assistant's `project_digest` joins them (#1712), so its 09:00 Session shows the digest, not its registration. - A Settings dialog takes focus when it opens (`useDialogFocus` in `core/src/client/overlay-hooks.js`; the `.bt-settings` dialog carries `tabIndex -1` and a ref) and gives it back to the still-connected opener when it closes; it is not a focus trap, so Tab can still leave the dialog for the page behind the scrim (#2251). @@ -30,6 +31,7 @@ The browser side of the patched `ds-bot` core (`core/`): sources in `core/src/cl | `core/src/client/` | Client sources → build to `core/client.js`. | | `core/src/client/busy-ack.js` / `busy-ack-logic.js` | Busy-send spoken acknowledgment (#1584): `BusyAckBubble` draws one short bot bubble while a human send is accepted during a running turn (host `busyAcks` or local queue/steer pending). Pure helpers in `busy-ack-logic.js`. Tests: `core/test/busy-ack-client.test.js`. | | `core/src/client/memory-empty.js` | The memory dialog's refused-folder line (#2101), decided by pure helpers because the suite has no renderer: `memoryEmptyNotice(view)` answers one line naming the folder when `entries` is empty and the host reported refused scopes in `view.blocked`, and `null` for the ordinary empty view, so `memory-panel.js` draws it above `{name} has not saved anything yet`; `memoryBlockedNotice(view)` answers that same line from `view.blocked` alone, whatever `entries` holds, and the panel draws it above the summary and above the entries list, because `entries` is own and team merged and one scope's entries would otherwise silence the other scope's refusal. A refused folder and a fresh Bot both answer `entries: []`; the host side of that field is in [core-host](core-host.md). Tests: `core/test/memory-empty.test.js`. | +| `core/src/client/memory-panel.js` | The memory dialog. `MemoryRow` is the Bot details pane's row — how much it keeps and the way into `MemoryDialog` — and `useMemoryCount` keeps that count from the same read the dialog does. The dialog has two pages: the summary the model writes from the entries (stale and failed states, **Try again**), and the entries themselves behind the ⋯ menu, grouped by scope and topic with a two-click **Remove**. The foot's **Ask or update** is one model call that answers from the entries or changes them, and lists each change as applied or refused. `useMemoryView` re-reads every `POLL_MS` while a summary is being written and only the latest answer counts. Since #2276 a read-only window (`roster.readOnly` a non-empty string) draws neither **Remove** nor the ask form — the host refuses `memory-forget` and `memory-ask` — and says why in the host's own sentence, while the entries and **Regenerate summary** (a read) still work. Tests: `core/test/memory-panel-readonly.test.js`, `core/test/memory.test.js`, `core/test/memory-empty.test.js`, `core/test/memory-search.test.js`. | | `core/src/client/exchange.js` | The Bot-exchange sheet: `ExchangeDialog` polls the stored turns between two Bots and `ExchangeLine` draws one. A stored line is read once, by `exchangeText`: a leading `Chief → 助手` delegation trace and an `` fence are routing, not words, so the bubble and the message tools' **Copy** receive the same cleaned string. Unlike a sidebar row it is not folded to one line — a reply's paragraph breaks are the writer's own and stay exactly as stored. Tests: `core/test/exchange-read.test.js`. | | `core/src/client/i18n.js` | The zh map the host `t()` reads. A plugin Settings page that calls `t('…')` adds the English key here (Settings → Bot Social friends and sharing, #1074); `npm run build` in `core/` copies the same map into `core/client.js`. | | `core/src/client/new-chat.js` | Sidebar **+** picker. Plugin rows via `flybot.new-chat.action` (`newChatActionRows` in `flybot-registry.js`); social adds Face-to-face room and Add friend (copy actions live inside Add friend, #181). The DSH Agent row reads **Add coding agent** (hint "A plain DSH session, not a Bot") so it is not mistaken for **Create new Bot** (#181). The short + menu does not list individual Bots. A group the host refuses because one of my Bots is not shared with the friend whose Bot is in it (`error.details.reason === 'ungranted-local'`, put on the error by `client/api.js`) is drawn as a card, not a red line: it names the Bot(s) and friend(s), and offers **Copy invite link for {Bot}** (`__flybot__.social.createInvite`; shown only when social is loaded and can invite that Bot, nothing is granted by the click; it says *copied* only when `copiedToClipboard` in `shared/remote-label.js` reports the write took, else it shows the link in a read-only, select-on-focus field to copy by hand) and **Leave {Bot} out** (`ungrantedLocalFix`, `shared/remote-label.js`). | @@ -124,7 +126,7 @@ The overlay (`flybot-slots.js` `FlyBotApprovals`) is the fallback, not a second - The "@" reader is shared with the host (`core/src/shared/mentions.js`, bundled into `core/client.js`). #287 added `unmatchedMentions` there (the `@` words that name nobody, used by the host for the face-room hint); `mentionSpans` and what the bubbles draw are unchanged. - A friend's Bot settings surface is not the local editor (#160): no rename, avatar editor, model, folder, instructions, Main Bot, Hide, Duplicate, or Delete. The details gear and context menu say **Bot settings**. The menu is Mark as Unread, Bot settings, and Copy conversation ID. Per-friend **Auto-send** and **Remove this Bot** stay on that page (`flybot.bot.details`); Remove closes the overlay so a stale local editor cannot appear. The Bots settings list is local Bots only. - A friend's Bot details overview is that compact panel: handle plus `FlyBotDetails` (Auto-send and a muted Remove link), no Browser tab, no Usage / model / folder. Opening the gear uses `remote-bot-settings` (`RemoteBotSettingsSheet`): a bottom sheet on narrow viewports and a centered card from 520px up, with the scrim behind the dialog (`z-index` stacking like `bt-settings` / `bt-mem`) so the sheet stays clickable through enter/leave animations. The surrounding DSH settings-shell chrome (back/title) is not redesigned (#166). The sheet takes focus when it opens (`tabIndex -1`, `preventScroll`), closes on Escape from its own `onKeyDown` as well as `useEscape`, and gives focus back without scrolling; in the tree a friend's `.bt-row-wrap` (row plus gear) shrinks like a plain row (`.bt-tree-item>.bt-row-wrap`), so the gear never overflows the sidebar and focusing it cannot scroll it sideways (#274). On every wide row (own Bots too) the gear's focus and click run `keepSidebarStill`, which puts back any sideways scroll a focus left on the row's ancestors or the page, now and after the settings dialog opened (#421). The Settings nav's last entry reads **Harness settings ↗** with a tooltip saying it opens DeepSeek Harness's own settings in a separate window; FlyBot does not embed or retitle that dialog (#421). -- An own Bot's settings (`BotSettingsForm`) draw the `flybot.bot.details` entries once, as a titled **Sharing** card right after **Instructions** (`FlyBotDetails` with `wrap` → `Card`), so sharing is not at the end of the page; with no entry there is no card. Bot settings and group settings both end with a **More** card (Duplicate / Delete). A write the host refuses is reported on the dialog's own line under its heading, outside the page's scrolling box: `SettingsDialog` owns the sentence `run` stores, and `.bt-settings-scroll` no longer holds it, so scrolling down to the control that was pressed cannot leave the refusal above the viewport with the switch apparently unmoved ([#2194](https://github.com/ThinkFlowLab/FlyBot/issues/2194)). A window that holds the team read-only says so in that same header: `SettingsDialog` prints the host's own sentence from `roster.readOnly` as a `role="status"` line under the heading and above the refused-write line, so the standing state comes first and the answer to one pressed control sits closest to it, and a blank or missing `readOnly` draws nothing at all (#2199). The composer card's `:focus-within` border is 55% ink, hover stays 30% (#406). Keyboard Tab order, checked in headless Chrome on a mock bench (#406): sidebar (Search, New…, the Bots, Connect apps, Account) → chat header pill → message box → Add, Dictate, voice → the turn stats; the `shell.overlay` layer (approval cards) comes after the main column in the DOM, and no stop focuses an invisible element. The message box comes before **+** because DSH's DOM puts the input first and FlyBot's CSS only reorders it visually. +- An own Bot's settings (`BotSettingsForm`) draw the `flybot.bot.details` entries once, as a titled **Sharing** card right after **Instructions** (`FlyBotDetails` with `wrap` → `Card`), so sharing is not at the end of the page; with no entry there is no card. Bot settings and group settings both end with a **More** card (Duplicate / Delete). A write the host refuses is reported on the dialog's own line under its heading, outside the page's scrolling box: `SettingsDialog` owns the sentence `run` stores, and `.bt-settings-scroll` no longer holds it, so scrolling down to the control that was pressed cannot leave the refusal above the viewport with the switch apparently unmoved ([#2194](https://github.com/ThinkFlowLab/FlyBot/issues/2194)). A window that holds the team read-only says so in that same header: `SettingsDialog` prints the host's own sentence from `roster.readOnly` as a `role="status"` line under the heading and above the refused-write line, so the standing state comes first and the answer to one pressed control sits closest to it, and a blank or missing `readOnly` draws nothing at all (#2199). The details drawer's own Settings view is not that dialog and had no header sentence: it now reads the same window lock (`lockedWindow`, `schedules.js`) and, in a read-only window, draws the host's sentence in the form's place instead of mounting `BotSettingsForm`, so no write control the host would refuse is drawn and a writable window keeps the form unchanged. Tests: `core/test/details-bot-settings-readonly.test.js`. The composer card's `:focus-within` border is 55% ink, hover stays 30% (#406). Keyboard Tab order, checked in headless Chrome on a mock bench (#406): sidebar (Search, New…, the Bots, Connect apps, Account) → chat header pill → message box → Add, Dictate, voice → the turn stats; the `shell.overlay` layer (approval cards) comes after the main column in the DOM, and no stop focuses an invisible element. The message box comes before **+** because DSH's DOM puts the input first and FlyBot's CSS only reorders it visually. - Every listed Bot row in wide sidebar mode is a `bt-row-wrap`: the session `bt-row` and a sibling gear (`bt-row-settings`, hover/focus) that opens Edit Bot for an own Bot or Bot settings for a friend's (#166, #276). - The Session header is the shell's own: FlyBot registers the Bot pill in `conversation.session.header.utilities` rather than claiming the single `conversation.session.header` slot, so the seats that header renders — the lineage title and `conversation.session.header.actions`, where `@deepseek-ai/ui-subagent` puts the subagent catalog — keep rendering (#803). The floating header style FlyBot used to draw over the Session header is gone. - What the pill does to the page is not in the pill and not in a seat of its own (#803). The shell draws `.utilities` — and the pill with it — only while its chrome shows, which a Session without a first message hides, so a side effect parked in the pill stops there: the friend's empty-chat hint, `body[data-bt-friend-chat]`, the `Message {name}` composer label, the visit ledger, `body[data-bt-room]` and the face-room `@` filter. `installHeaderEffects` (`src/client/header-effects.js`) is installed once from `apply` and keeps them on the Session in the main view. It carries no React, and each effect has its own dependency key: the sessions list answers before the roster, so a single "did the Session change" guard would drop a friend's flags on the first pass, while `beginVisit`/`endVisit` keyed on more than the Session would open a second visit for a chat that was only just opened. `test/header-effects.test.js` drives those transitions on jsdom; `test/header-slot.test.js` pins that no such effect runs in `HeaderPill` and that FlyBot claims no other header seat. diff --git a/patches/ds-bot/0001-core-fixes.patch b/patches/ds-bot/0001-core-fixes.patch index ce6aa607..3911bd56 100644 --- a/patches/ds-bot/0001-core-fixes.patch +++ b/patches/ds-bot/0001-core-fixes.patch @@ -1946,7 +1946,17 @@ diff --git a/src/client/memory-panel.js b/src/client/memory-panel.js h('div', { key: view.summary.at, className: 'bt-mem-summary', 'data-stale': !view.fresh || undefined }, summaryBlocks(view.summary.text).map((block, index) => (block.kind === 'h' ? h('h3', { key: index }, block.text) -@@ -119,11 +119,13 @@ function EntryRow({ entry, remove }) { +@@ -111,19 +111,21 @@ function EntryRow({ entry, remove }) { + h('div', { className: 'bt-mem-entry-copy' }, + h('span', { className: 'bt-mem-entry-text' }, entry.text), + meta ? h('span', { className: 'bt-mem-entry-meta' }, meta) : null), +- h('button', { ++ remove ? h('button', { + type: 'button', className: 'bt-mem-remove', 'data-confirm': confirming || undefined, disabled: busy, + 'aria-label': confirming ? t('Click again to remove') : t('Remove from memory'), title: confirming ? undefined : t('Remove from memory'), onClick: click, +- }, confirming ? t('Remove') : h(TrashIcon))) ++ }, confirming ? t('Remove') : h(TrashIcon)) : null) + } function Entries({ bot, view, remove }) { const groups = groupEntries(view.entries) @@ -1960,6 +1970,36 @@ diff --git a/src/client/memory-panel.js b/src/client/memory-panel.js sections.map(section => h('section', { key: section.scope, className: 'bt-mem-section' }, h('div', { className: 'bt-mem-section-head' }, h('h3', null, section.title), +@@ -201,8 +201,11 @@ function useNow(every) { + return now + } + +-export function MemoryDialog({ bot, actions, leaving = false }) { ++export function MemoryDialog({ bot, actions, roster, leaving = false }) { + const { view, error, load, show } = useMemoryView(bot.id, actions) ++ // A read-only window refuses both writes below at the host, so the panel draws neither and ++ // says why in the host's own sentence — the same one the sidebar and the Settings header carry. ++ const locked = typeof roster?.readOnly === 'string' && roster.readOnly.trim() !== '' + const [page, setPage] = useState('summary') + const [menu, setMenu] = useState(false) + const [menuShown, menuLeaving] = useLinger(menu, MENU_EXIT_MS) +@@ -281,13 +281,14 @@ export function MemoryDialog({ bot, actions, leaving = false }) { + h('button', { type: 'button', role: 'menuitem', disabled: count === 0 || view?.summarizing, onClick: regenerate }, + h('span', { className: 'bt-menu-label' }, t('Regenerate summary')))) : null), + h('div', { className: 'bt-mem-body' }, ++ locked ? h('p', { className: 'bt-hint bt-read-only', role: 'status' }, hostText(roster.readOnly)) : null, + error ? h('div', { className: 'bt-error', role: 'alert', style: { padding: 0 } }, hostText(error)) : null, + view === null ? (error ? null : h(Skeleton)) +- : page === 'entries' ? h(Entries, { bot, view, remove }) ++ : page === 'entries' ? h(Entries, { bot, view, remove: locked ? null : remove }) + : h(Summary, { bot, view, regenerate })), + h('div', { className: 'bt-mem-foot' }, + answer ? h(Reply, { answer, dismiss: () => setAnswer(null) }) : null, +- h('form', { className: 'bt-mem-ask', onSubmit: ask }, ++ locked ? null : h('form', { className: 'bt-mem-ask', onSubmit: ask }, + h('input', { + ref: inputRef, className: 'bt-mem-input', value: text, maxLength: 2000, disabled: view === null, + placeholder: t('Ask or update'), 'aria-label': t("Ask about or update {name}'s memory", { name: bot.name }), diff --git a/src/client/model-menu.js b/src/client/model-menu.js --- a/src/client/model-menu.js +++ b/src/client/model-menu.js diff --git a/patches/ds-bot/0003-flybot-api-client.patch b/patches/ds-bot/0003-flybot-api-client.patch index 52cb389a..e09a51c9 100644 --- a/patches/ds-bot/0003-flybot-api-client.patch +++ b/patches/ds-bot/0003-flybot-api-client.patch @@ -1191,6 +1191,15 @@ diff --git a/src/client/cells.js b/src/client/cells.js case 'create_group': case 'update_group': case 'delete_group': +@@ -693,7 +693,7 @@ function renderCall(call, { sessionId, roster, actions, useUi, useWork, findBot + const question = roster.questions?.[sessionId] + // Answered or closed: the card leaves no gap where it was. + if (!question || question.callId !== call.callId) return h('span', { className: 'bt-q-gone', hidden: true }) +- return h(PendingQuestion, { sessionId, question, actions, inline: true }) ++ return h(PendingQuestion, { sessionId, question, actions, roster, inline: true }) + } + case 'request_secret': + return h(SecretToolCell, { sessionId, callId: call.callId, name: arg('name'), done, content: call.content, roster, actions }) @@ -719,12 +719,12 @@ function renderCall(call, { sessionId, roster, actions, useUi, useWork, findBot } } @@ -2265,7 +2274,7 @@ diff --git a/src/client/overlays.js b/src/client/overlays.js settings ? createPortal(h(SettingsDialog, { page: settings.page, id: settings.id, roster, actions, useUpdate, leaving: settingsLeaving }), document.body) : null, - memory && roster.byId[memory] ? createPortal(h(MemoryDialog, { key: memory, bot: roster.byId[memory], actions, leaving: memoryLeaving }), document.body) : null) + remoteBotSettings ? createPortal(h(RemoteBotSettingsSheet, { key: remoteBotSettings, bot: roster.byId[remoteBotSettings], roster, actions, leaving: remoteLeaving }), document.body) : null, -+ memory && roster.byId[memory] ? createPortal(h(MemoryDialog, { key: memory, bot: roster.byId[memory], actions, leaving: memoryLeaving }), document.body) : null, ++ memory && roster.byId[memory] ? createPortal(h(MemoryDialog, { key: memory, bot: roster.byId[memory], roster, actions, leaving: memoryLeaving }), document.body) : null, + inbox ? createPortal(h(InboxDialog, { roster, useWork, useSessions, actions, leaving: inboxLeaving }), document.body) : null, + board ? createPortal(h(KanbanDialog, { roster, actions, leaving: boardLeaving }), document.body) : null) } @@ -2338,6 +2347,142 @@ diff --git a/src/client/palette.js b/src/client/palette.js ].filter(command => matchesName(command.title)) if (commands.length) sections.push([t('Commands'), commands]) const rows = sections.flatMap(([, items]) => items) +diff --git a/src/client/questions.js b/src/client/questions.js +--- a/src/client/questions.js ++++ b/src/client/questions.js +@@ -1,6 +1,7 @@ + import { createElement as h, useEffect, useId, useRef, useState } from 'react' + import { CloseIcon, CheckIcon } from './icons.js' +-import { t } from './i18n.js' ++import { hostText, t } from './i18n.js' ++import { lockedWindow } from './schedules.js' + import { historyLoading } from './text.js' + + // ------------------------------------------------------------------------- +@@ -40,7 +40,7 @@ function useOptionKeys(count, onPick, ownRef) { + + // A question shows two ways: as a bubble in the transcript, and raised when it + // sits in the dock above the composer. +-function QuestionView({ question, onAnswer, onDismiss, ownAnswer = false, raised = false }) { ++function QuestionView({ question, onAnswer, onDismiss, ownAnswer = false, raised = false, locked = false }) { + const [own, setOwn] = useState('') + const [picked, setPicked] = useState([]) + const ownRef = useRef(null) +@@ -60,9 +60,9 @@ function QuestionView({ question, onAnswer, onDismiss, ownAnswer = false, raised + if (!canSubmit) return + onAnswer({ selected: multi ? picked : [], ...(custom ? { custom } : {}) }) + } +- useOptionKeys(options.length, index => choose(options[index].label), ownRef) ++ useOptionKeys(locked ? 0 : options.length, index => choose(options[index].label), ownRef) + const ownLetter = options.length > 0 ? keyLetter(options.length) : undefined +- const field = ownAnswer ? h('textarea', { ++ const field = ownAnswer && !locked ? h('textarea', { + ref: ownRef, rows: 1, value: own, autoComplete: 'off', spellCheck: false, className: 'bt-q-input', + 'aria-label': t('Your own answer'), placeholder: t('Type your own answer'), + 'aria-keyshortcuts': ownLetter?.toLowerCase(), +@@ -75,17 +75,23 @@ function QuestionView({ question, onAnswer, onDismiss, ownAnswer = false, raised + }) : null + const rows = options.map((option, optionIndex) => { + const selected = multi && picked.includes(option.label) +- return h('button', { +- key: option.label, type: 'button', className: 'bt-q-opt', +- 'aria-pressed': multi ? selected : undefined, +- 'aria-keyshortcuts': keyLetter(optionIndex).toLowerCase(), +- onClick: () => choose(option.label), +- }, +- h('span', { className: 'bt-key', 'aria-hidden': true }, keyLetter(optionIndex)), +- h('span', { className: 'bt-q-body' }, +- h('span', { className: 'bt-q-label' }, option.label), +- option.description ? h('span', { className: 'bt-q-desc' }, option.description) : null), +- selected && !raised ? h('span', { className: 'bt-q-check', title: t('Selected') }, h(CheckIcon)) : null) ++ const body = [ ++ h('span', { className: 'bt-key', 'aria-hidden': true }, keyLetter(optionIndex)), ++ h('span', { className: 'bt-q-body' }, ++ h('span', { className: 'bt-q-label' }, option.label), ++ option.description ? h('span', { className: 'bt-q-desc' }, option.description) : null), ++ selected && !raised ? h('span', { className: 'bt-q-check', title: t('Selected') }, h(CheckIcon)) : null, ++ ] ++ // A read-only window keeps the question and its rows as text: an option answers at once on a ++ // single-select card, so a button there is a write the host refuses (#2277). ++ return locked ++ ? h('div', { key: option.label, className: 'bt-q-opt' }, ...body) ++ : h('button', { ++ key: option.label, type: 'button', className: 'bt-q-opt', ++ 'aria-pressed': multi ? selected : undefined, ++ 'aria-keyshortcuts': keyLetter(optionIndex).toLowerCase(), ++ onClick: () => choose(option.label), ++ }, ...body) + }) + // Raised cards keep the custom answer as one more keyed row; bubbles put it below the list. + if (raised && field) { +@@ -94,23 +94,24 @@ function QuestionView({ question, onAnswer, onDismiss, ownAnswer = false, raised + h('span', { className: 'bt-q-body' }, field))) + } + let foot = null +- if (raised && multi) { ++ if (!locked && raised && multi) { + foot = h('div', { className: 'bt-q-foot' }, + h('button', { type: 'button', className: 'bt-q-btn', onClick: onDismiss }, t('Skip')), + h('button', { type: 'submit', className: 'bt-q-submit', disabled: !canSubmit }, t('Submit'))) +- } else if ((raised || multi) && canSubmit) { ++ } else if (!locked && (raised || multi) && canSubmit) { + foot = h('div', { className: 'bt-q-foot' }, h('button', { type: 'submit', className: 'bt-q-submit' }, t('Submit'))) + } +- return h('form', { ++ // A read-only card is a plain box, not a form: it has nothing to submit, so it draws no handler. ++ return h(locked ? 'div' : 'form', { + className: raised ? 'bt-q bt-q-raised' : 'bt-q', 'aria-labelledby': titleId, +- onSubmit: (event) => { event.preventDefault(); submit() }, ++ ...(locked ? {} : { onSubmit: (event) => { event.preventDefault(); submit() } }), + }, + h('div', { className: 'bt-q-head' }, + h('div', { className: 'bt-q-text' }, + h('p', { className: 'bt-q-title', id: titleId }, question.header ? `${question.header} · ` : '', question.question), + question.detail ? h('div', { className: 'bt-q-detail' }, question.detail) : null, + raised && multi ? h('div', { className: 'bt-q-detail' }, t('Choose all that apply')) : null), +- h('button', { type: 'button', className: 'bt-q-x', 'aria-label': t('Dismiss question'), title: t('Dismiss'), onClick: onDismiss }, h(CloseIcon))), ++ locked ? null : h('button', { type: 'button', className: 'bt-q-x', 'aria-label': t('Dismiss question'), title: t('Dismiss'), onClick: onDismiss }, h(CloseIcon))), + rows.length > 0 ? h('div', { className: 'bt-q-list', role: 'group' }, rows) : null, + field && !raised ? h('div', { className: 'bt-q-custom' }, + h('label', { className: 'bt-q-field' }, field), +@@ -159,9 +159,10 @@ export function QuestionCard({ matched }) { + return h(QuestionView, { key: index, question, onAnswer, onDismiss: () => pending.dismiss(), ownAnswer: true, raised: true }) + } + +-export function PendingQuestion({ sessionId, question, actions, inline }) { ++export function PendingQuestion({ sessionId, question, actions, roster, inline }) { + const [answered, setAnswered] = useState(null) + if (answered === question.id) return null ++ const locked = lockedWindow(roster) + const onAnswer = (answer) => { + setAnswered(question.id) + void actions.answerQuestion(sessionId, question.id, { selected: answer.selected ?? [], custom: answer.custom ?? '' }).catch(() => setAnswered(null)) +@@ -171,17 +171,22 @@ export function PendingQuestion({ sessionId, question, actions, inline }) { + void actions.dismissQuestion(sessionId) + } + return h('div', { className: inline ? 'bt-q-inline' : 'bt-q-dock' }, +- h(QuestionView, { key: question.id, question, onAnswer, onDismiss, ownAnswer: question.allowCustom === true, raised: !inline })) ++ // Both controls are writes the host refuses, and the refusal was invisible: the answer came ++ // back to a card that looked answered and the × hid the card until the next poll (#2277). ++ // A read-only window draws the question and the host's sentence, and no way to press either. ++ ...(locked ? [h('div', { className: 'bt-hint bt-read-only', role: 'status' }, hostText(roster.readOnly))] : []), ++ h(QuestionView, { key: question.id, question, onAnswer, onDismiss, ownAnswer: question.allowCustom === true, raised: !inline, locked })) + } + + // Questions asked by a tool call render inline at that call (see ToolCell); the dock + // carries the ones whose call the transcript does not draw. + export function QuestionDock({ sessionId, useRoster, useCards, useSession, actions }) { +- const question = useRoster(value => value.questions?.[sessionId]) ++ const roster = useRoster(value => value) ++ const question = roster.questions?.[sessionId] + const drawn = useCards?.(state => state[sessionId]?.[question?.callId] > 0) === true + const loading = useSession?.(historyLoading) === true + if (question === undefined || (question.callId && (drawn || loading))) return null +- return h(PendingQuestion, { sessionId, question, actions, inline: false }) ++ return h(PendingQuestion, { sessionId, question, actions, roster, inline: false }) + } + + // An answered card in the transcript: the question stays where it was asked, with the diff --git a/src/client/sidebar.js b/src/client/sidebar.js --- a/src/client/sidebar.js +++ b/src/client/sidebar.js diff --git a/patches/ds-bot/0008-client-bundle.patch b/patches/ds-bot/0008-client-bundle.patch index 43f2aa7c..b735e5db 100644 --- a/patches/ds-bot/0008-client-bundle.patch +++ b/patches/ds-bot/0008-client-bundle.patch @@ -2170,7 +2170,7 @@ diff --git a/client.js b/client.js var formatTokens = (count) => count >= 1e6 ? `${(count / 1e6).toFixed(1)}M` : count >= 1e3 ? `${(count / 1e3).toFixed(1)}K` : String(count); function usageOf(list, ids) { let input = 0; -@@ -1624,969 +1624,2052 @@ function usageOf(list, ids) { +@@ -1624,901 +1624,1984 @@ function usageOf(list, ids) { const hit = input > 0 ? Math.round(cacheRead / input * 100) : 0; return t("{tokens} tok · Cache hit {hit}%", { tokens: formatTokens(input + output), hit }); } @@ -4016,60 +4016,6 @@ diff --git a/client.js b/client.js -.bt-mem-summary>:nth-child(5){animation-delay:.12s} -.bt-mem-summary>:nth-child(6){animation-delay:.15s} -.bt-mem-summary>:nth-child(n+7){animation-delay:.18s} --@keyframes bt-mem-line{from{opacity:0;transform:translateY(4px)}} --.bt-mem-skel{display:flex;flex-direction:column;gap:24px} --.bt-mem-skel-block{display:flex;flex-direction:column;gap:10px} --.bt-mem-skel-block span{display:block;height:12px;border-radius:6px;background:linear-gradient(90deg,var(--bt-hover) 25%,var(--bt-active) 50%,var(--bt-hover) 75%);background-size:200% 100%;animation:bt-mem-skel 1.4s ease-in-out infinite} --.bt-mem-skel-block .bt-mem-skel-head{width:30%;height:16px;margin-bottom:2px} --@keyframes bt-mem-skel{from{background-position:150% 0}to{background-position:-50% 0}} --.bt-mem-skel-label{font-size:13px;line-height:18px;color:var(--bt-ink-3)} --.bt-mem-empty{margin:auto;max-width:400px;display:flex;flex-direction:column;align-items:center;gap:8px;text-align:center;padding:24px 0;animation:bt-mem-line .45s cubic-bezier(.16,1,.3,1) both} --.bt-mem-empty-mark{width:48px;height:48px;border-radius:50%;background:var(--bt-hover);display:flex;align-items:center;justify-content:center;color:var(--bt-ink-2);margin-bottom:4px} --.bt-mem-empty-mark svg{width:22px;height:22px} --.bt-mem-empty-title{font-size:15px;line-height:22px;font-weight:600} --.bt-mem-empty-text{font-size:13px;line-height:20px;color:var(--bt-ink-2)} --.bt-mem-failed{display:flex;align-items:center;gap:12px;padding:10px 12px;border-radius:12px;background:var(--bt-hover);font-size:13px;line-height:18px;color:var(--bt-ink-2)} --.bt-mem-failed>span{flex:1;min-width:0;overflow-wrap:anywhere} --.bt-mem-entries{display:flex;flex-direction:column;gap:28px;animation:bt-page-in ${SPRING_SHAPE.duration}ms ${SPRING_SHAPE.easing},bt-veil-in .22s ease-out} --.bt-mem-section-head{display:flex;align-items:baseline;gap:8px;margin-bottom:4px} --.bt-mem-section-head h3{margin:0;font-size:15px;line-height:22px;font-weight:600} --.bt-mem-section-head span{font-size:12px;line-height:16px;color:var(--bt-ink-3)} --.bt-mem-section-head .bt-mem-size{margin-left:auto;font-variant-numeric:tabular-nums;white-space:nowrap} --.bt-mem-topic{font-size:12px;line-height:16px;color:var(--bt-ink-2);margin:14px 0 2px 4px;font-weight:500} --.bt-mem-list{list-style:none;margin:0;padding:0;display:flex;flex-direction:column} --.bt-mem-entry{display:flex;align-items:center;gap:10px;padding:9px 4px;border-bottom:.5px solid var(--bt-line);transition:opacity .2s ease} --.bt-mem-entry[data-busy]{opacity:.4} --.bt-mem-entry-copy{flex:1;min-width:0;display:flex;flex-direction:column;gap:2px} --.bt-mem-entry-text{font-size:14px;line-height:20px;overflow-wrap:anywhere} --.bt-mem-entry-meta{font-size:12px;line-height:16px;color:var(--bt-ink-3);font-variant-numeric:tabular-nums} --.bt-mem-remove{flex:none;height:28px;min-width:28px;border-radius:999px;border:0;background:none;color:var(--bt-ink-3);display:inline-flex;align-items:center;justify-content:center;cursor:pointer;padding:0 6px;font:inherit;font-size:12px;opacity:0;transition:opacity .12s ease,background-color .12s ease,color .12s ease} --.bt-mem-entry:hover .bt-mem-remove,.bt-mem-remove:focus-visible,.bt-mem-remove[data-confirm]{opacity:1} --.bt-mem-remove:hover{background:var(--bt-hover);color:var(--bt-ink)} --.bt-mem-remove[data-confirm]{background:rgba(224,33,53,.1);color:#e02135;padding:0 10px} --@media (hover:none){.bt-mem-remove{opacity:1}} --.bt-mem-none{font-size:13px;line-height:18px;color:var(--bt-ink-3);padding:8px 4px} --.bt-mem-foot{flex:none;padding:8px 20px 20px;display:flex;flex-direction:column;gap:10px} --.bt-mem-reply{border-radius:16px;background:var(--bt-hover);padding:8px 8px 12px 16px;display:flex;flex-direction:column;gap:6px;max-height:36vh;overflow-y:auto;scrollbar-width:thin;transform-origin:50% 100%;animation:bt-pop-lift ${SPRING_SHAPE.duration}ms ${SPRING_SHAPE.easing},bt-veil-in .2s ease-out} --.bt-mem-reply-head{display:flex;align-items:center;gap:8px;min-height:24px} --.bt-mem-reply-asked{flex:1;min-width:0;font-size:12px;line-height:16px;color:var(--bt-ink-3);overflow:hidden;text-overflow:ellipsis;white-space:nowrap} --.bt-mem-reply .bt-icon-btn{width:24px;height:24px;flex:none} --.bt-mem-reply-text{font-size:14px;line-height:21px;padding-right:8px;overflow-wrap:anywhere} --.bt-mem-changes{list-style:none;margin:2px 0 0;padding:0 8px 0 0;display:flex;flex-direction:column;gap:4px} --.bt-mem-changes li{display:flex;align-items:flex-start;gap:6px;font-size:13px;line-height:18px;color:var(--bt-ink-2);overflow-wrap:anywhere} --.bt-mem-changes li svg{flex:none;margin-top:1px;color:#e02135;animation:bt-check-pop ${SPRING_POP.duration}ms ${SPRING_POP.easing} .12s backwards} --.bt-mem-changes li[data-ok] svg{color:var(--bt-green)} --.bt-mem-ask{display:flex;align-items:center;gap:8px;height:52px;padding:0 8px 0 22px;border-radius:999px;border:.5px solid var(--bt-line-2);background:var(--bt-card);box-shadow:0 4px 16px rgba(0,0,0,.06);box-sizing:border-box;transition:border-color .15s ease,box-shadow .15s ease} --.bt-mem-ask:focus-within{border-color:var(--bt-ink-3);box-shadow:0 4px 20px rgba(0,0,0,.1)} --.bt-mem-input{flex:1;min-width:0;border:0;background:none;outline:none;color:var(--bt-ink);font:inherit;font-size:15px;line-height:22px;padding:0} --.bt-mem-input::placeholder{color:var(--bt-ink-3)} --.bt-mem-send{flex:none;width:36px;height:36px;border-radius:50%;border:0;background:var(--bt-ink);color:var(--bt-main);display:flex;align-items:center;justify-content:center;cursor:pointer;padding:0;transition:opacity .15s ease,${spring(SPRING_TAP, "scale")}} --.bt-mem-send:disabled{opacity:.22;cursor:default} --.bt-mem-send:not(:disabled):active{scale:.92} --.bt-mem-send>*{animation:bt-check-pop ${SPRING_POP.duration}ms ${SPRING_POP.easing}} --.bt-mem-spin{width:14px;height:14px;border-radius:50%;border:2px solid currentColor;border-right-color:transparent;box-sizing:border-box;animation:bt-mem-spin .7s linear infinite} --@keyframes bt-mem-spin{to{transform:rotate(360deg)}} --@media (max-width:640px){.bt-mem{width:100vw;height:100%;border-radius:0;border:0}.bt-mem-body{padding:18px 18px 20px}.bt-mem-foot{padding:8px 12px 12px}} --@media (prefers-reduced-motion:reduce){.bt-mem,.bt-mem-scrim,.bt-mem-summary>*,.bt-mem-empty,.bt-mem-reply,.bt-mem-skel-block span,.bt-mem-entries,.bt-mem-send>*,.bt-mem-changes li svg{animation:none}} +// src/client/motion.js +var import_react4 = require("react"); +var reducedMotion = () => window.matchMedia?.("(prefers-reduced-motion: reduce)").matches === true; @@ -4273,20 +4219,7 @@ diff --git a/client.js b/client.js + }, [value]); + return [value || kept, !value && Boolean(kept)]; +} - --.bt-thumb{position:absolute;left:0;top:0;z-index:0;pointer-events:none;transition:opacity .14s ease} --.bt-thumb[data-hidden]{opacity:0} --[data-morph]{overflow:hidden!important} --.bt-veil{animation:bt-veil-in .22s ease-out} --.bt-with-icon{display:inline-flex;align-items:center;gap:4px} --.bt-with-icon svg{width:14px;height:14px} --.bt-tag-admin{display:inline-flex;align-items:center;gap:4px} --.bt-create-inline{padding:18px 16px;border:1px solid var(--bt-line);border-radius:12px;background:var(--bt-card)} --.bt-create-inline .bt-create-card{max-width:none} --.bt-panel-view>[data-move=in]{animation:bt-page-in ${SPRING_SHAPE.duration}ms ${SPRING_SHAPE.easing} backwards,bt-veil-in .2s ease-out backwards} --.bt-panel-view>[data-move=out]{animation:bt-page-out ${SPRING_SHAPE.duration}ms ${SPRING_SHAPE.easing} backwards,bt-veil-in .2s ease-out backwards} --.bt-panel-view>:is([data-move=in],[data-move=out]):nth-child(2){animation-delay:25ms} --.bt-panel-view>:is([data-move=in],[data-move=out]):nth-child(n+3){animation-delay:50ms} ++ +// src/client/styles.js +var SHELL_CSS = ` +body{--bt-ink:#141414;--bt-ink-2:rgba(20,20,20,.6);--bt-ink-3:rgba(20,20,20,.36);--bt-bubble:#eeeeee;--bt-sidebar:#f7f7f7;--bt-main:#fcfcfc;--bt-line:rgba(20,20,20,.08);--bt-line-2:rgba(20,20,20,.15);--bt-line-weak:rgba(20,20,20,.1);--bt-hover:rgba(119,119,119,.09);--bt-active:rgba(119,119,119,.17);--bt-card:#ffffff;--bt-accent:#4d6bfe;--bt-green:#00c972;--bt-star:#ff9800;--bt-user:#070707;--bt-user-ink:#fcfcfc;--bt-tag-bg:rgba(20,20,20,.06);--bt-tag-ink:rgba(20,20,20,.56)} @@ -5087,81 +5020,34 @@ diff --git a/client.js b/client.js +.bt-mem-summary>:nth-child(5){animation-delay:.12s} +.bt-mem-summary>:nth-child(6){animation-delay:.15s} +.bt-mem-summary>:nth-child(n+7){animation-delay:.18s} -+@keyframes bt-mem-line{from{opacity:0;transform:translateY(4px)}} -+.bt-mem-skel{display:flex;flex-direction:column;gap:24px} -+.bt-mem-skel-block{display:flex;flex-direction:column;gap:10px} -+.bt-mem-skel-block span{display:block;height:12px;border-radius:6px;background:linear-gradient(90deg,var(--bt-hover) 25%,var(--bt-active) 50%,var(--bt-hover) 75%);background-size:200% 100%;animation:bt-mem-skel 1.4s ease-in-out infinite} -+.bt-mem-skel-block .bt-mem-skel-head{width:30%;height:16px;margin-bottom:2px} -+@keyframes bt-mem-skel{from{background-position:150% 0}to{background-position:-50% 0}} -+.bt-mem-skel-label{font-size:13px;line-height:18px;color:var(--bt-ink-3)} -+.bt-mem-empty{margin:auto;max-width:400px;display:flex;flex-direction:column;align-items:center;gap:8px;text-align:center;padding:24px 0;animation:bt-mem-line .45s cubic-bezier(.16,1,.3,1) both} -+.bt-mem-empty-mark{width:48px;height:48px;border-radius:50%;background:var(--bt-hover);display:flex;align-items:center;justify-content:center;color:var(--bt-ink-2);margin-bottom:4px} -+.bt-mem-empty-mark svg{width:22px;height:22px} -+.bt-mem-empty-title{font-size:15px;line-height:22px;font-weight:600} -+.bt-mem-empty-text{font-size:13px;line-height:20px;color:var(--bt-ink-2)} -+.bt-mem-failed{display:flex;align-items:center;gap:12px;padding:10px 12px;border-radius:12px;background:var(--bt-hover);font-size:13px;line-height:18px;color:var(--bt-ink-2)} -+.bt-mem-failed>span{flex:1;min-width:0;overflow-wrap:anywhere} -+.bt-mem-entries{display:flex;flex-direction:column;gap:28px;animation:bt-page-in ${SPRING_SHAPE.duration}ms ${SPRING_SHAPE.easing},bt-veil-in .22s ease-out} -+.bt-mem-section-head{display:flex;align-items:baseline;gap:8px;margin-bottom:4px} -+.bt-mem-section-head h3{margin:0;font-size:15px;line-height:22px;font-weight:600} -+.bt-mem-section-head span{font-size:12px;line-height:16px;color:var(--bt-ink-3)} -+.bt-mem-section-head .bt-mem-size{margin-left:auto;font-variant-numeric:tabular-nums;white-space:nowrap} -+.bt-mem-topic{font-size:12px;line-height:16px;color:var(--bt-ink-2);margin:14px 0 2px 4px;font-weight:500} -+.bt-mem-list{list-style:none;margin:0;padding:0;display:flex;flex-direction:column} -+.bt-mem-entry{display:flex;align-items:center;gap:10px;padding:9px 4px;border-bottom:.5px solid var(--bt-line);transition:opacity .2s ease} -+.bt-mem-entry[data-busy]{opacity:.4} -+.bt-mem-entry-copy{flex:1;min-width:0;display:flex;flex-direction:column;gap:2px} -+.bt-mem-entry-text{font-size:14px;line-height:20px;overflow-wrap:anywhere} -+.bt-mem-entry-meta{font-size:12px;line-height:16px;color:var(--bt-ink-3);font-variant-numeric:tabular-nums} -+.bt-mem-remove{flex:none;height:28px;min-width:28px;border-radius:999px;border:0;background:none;color:var(--bt-ink-3);display:inline-flex;align-items:center;justify-content:center;cursor:pointer;padding:0 6px;font:inherit;font-size:12px;opacity:0;transition:opacity .12s ease,background-color .12s ease,color .12s ease} -+.bt-mem-entry:hover .bt-mem-remove,.bt-mem-remove:focus-visible,.bt-mem-remove[data-confirm]{opacity:1} -+.bt-mem-remove:hover{background:var(--bt-hover);color:var(--bt-ink)} -+.bt-mem-remove[data-confirm]{background:rgba(224,33,53,.1);color:#e02135;padding:0 10px} -+@media (hover:none){.bt-mem-remove{opacity:1}} -+.bt-mem-none{font-size:13px;line-height:18px;color:var(--bt-ink-3);padding:8px 4px} -+.bt-mem-foot{flex:none;padding:8px 20px 20px;display:flex;flex-direction:column;gap:10px} -+.bt-mem-reply{border-radius:16px;background:var(--bt-hover);padding:8px 8px 12px 16px;display:flex;flex-direction:column;gap:6px;max-height:36vh;overflow-y:auto;scrollbar-width:thin;transform-origin:50% 100%;animation:bt-pop-lift ${SPRING_SHAPE.duration}ms ${SPRING_SHAPE.easing},bt-veil-in .2s ease-out} -+.bt-mem-reply-head{display:flex;align-items:center;gap:8px;min-height:24px} -+.bt-mem-reply-asked{flex:1;min-width:0;font-size:12px;line-height:16px;color:var(--bt-ink-3);overflow:hidden;text-overflow:ellipsis;white-space:nowrap} -+.bt-mem-reply .bt-icon-btn{width:24px;height:24px;flex:none} -+.bt-mem-reply-text{font-size:14px;line-height:21px;padding-right:8px;overflow-wrap:anywhere} -+.bt-mem-changes{list-style:none;margin:2px 0 0;padding:0 8px 0 0;display:flex;flex-direction:column;gap:4px} -+.bt-mem-changes li{display:flex;align-items:flex-start;gap:6px;font-size:13px;line-height:18px;color:var(--bt-ink-2);overflow-wrap:anywhere} -+.bt-mem-changes li svg{flex:none;margin-top:1px;color:#e02135;animation:bt-check-pop ${SPRING_POP.duration}ms ${SPRING_POP.easing} .12s backwards} -+.bt-mem-changes li[data-ok] svg{color:var(--bt-green)} -+.bt-mem-ask{display:flex;align-items:center;gap:8px;height:52px;padding:0 8px 0 22px;border-radius:999px;border:.5px solid var(--bt-line-2);background:var(--bt-card);box-shadow:0 4px 16px rgba(0,0,0,.06);box-sizing:border-box;transition:border-color .15s ease,box-shadow .15s ease} -+.bt-mem-ask:focus-within{border-color:var(--bt-ink-3);box-shadow:0 4px 20px rgba(0,0,0,.1)} -+.bt-mem-input{flex:1;min-width:0;border:0;background:none;outline:none;color:var(--bt-ink);font:inherit;font-size:15px;line-height:22px;padding:0} -+.bt-mem-input::placeholder{color:var(--bt-ink-3)} -+.bt-mem-send{flex:none;width:36px;height:36px;border-radius:50%;border:0;background:var(--bt-ink);color:var(--bt-main);display:flex;align-items:center;justify-content:center;cursor:pointer;padding:0;transition:opacity .15s ease,${spring(SPRING_TAP, "scale")}} -+.bt-mem-send:disabled{opacity:.22;cursor:default} -+.bt-mem-send:not(:disabled):active{scale:.92} -+.bt-mem-send>*{animation:bt-check-pop ${SPRING_POP.duration}ms ${SPRING_POP.easing}} -+.bt-mem-spin{width:14px;height:14px;border-radius:50%;border:2px solid currentColor;border-right-color:transparent;box-sizing:border-box;animation:bt-mem-spin .7s linear infinite} -+@keyframes bt-mem-spin{to{transform:rotate(360deg)}} -+@media (max-width:640px){.bt-mem{width:100vw;height:100%;border-radius:0;border:0}.bt-mem-body{padding:18px 18px 20px}.bt-mem-foot{padding:8px 12px 12px}} -+@media (prefers-reduced-motion:reduce){.bt-mem,.bt-mem-scrim,.bt-mem-summary>*,.bt-mem-empty,.bt-mem-reply,.bt-mem-skel-block span,.bt-mem-entries,.bt-mem-send>*,.bt-mem-changes li svg{animation:none}} -+ -+.bt-thumb{position:absolute;left:0;top:0;z-index:0;pointer-events:none;transition:opacity .14s ease} -+.bt-thumb[data-hidden]{opacity:0} -+[data-morph]{overflow:hidden!important} -+.bt-veil{animation:bt-veil-in .22s ease-out} -+.bt-with-icon{display:inline-flex;align-items:center;gap:4px} -+.bt-with-icon svg{width:14px;height:14px} -+.bt-tag-admin{display:inline-flex;align-items:center;gap:4px} -+.bt-create-inline{padding:18px 16px;border:1px solid var(--bt-line);border-radius:12px;background:var(--bt-card)} -+.bt-create-inline .bt-create-card{max-width:none} -+.bt-panel-view>[data-move=in]{animation:bt-page-in ${SPRING_SHAPE.duration}ms ${SPRING_SHAPE.easing} backwards,bt-veil-in .2s ease-out backwards} -+.bt-panel-view>[data-move=out]{animation:bt-page-out ${SPRING_SHAPE.duration}ms ${SPRING_SHAPE.easing} backwards,bt-veil-in .2s ease-out backwards} -+.bt-panel-view>:is([data-move=in],[data-move=out]):nth-child(2){animation-delay:25ms} -+.bt-panel-view>:is([data-move=in],[data-move=out]):nth-child(n+3){animation-delay:50ms} - .bt-panel-view>.bt-drawer-body[data-move=tab]{animation:bt-veil-in .2s ease-out} - .bt-drawer-name-input{animation:bt-veil-in .16s ease-out} - .bt-pop-in{display:inline-flex;animation:bt-check-pop ${SPRING_POP.duration}ms ${SPRING_POP.easing}} -@@ -2595,2914 +2595,6822 @@ body[data-ds-dark-theme] .bt-theme-chip i{background:var(--da)} - :is(.bt-soft,.bt-send,.bt-mini,.bt-tab,.bt-avedit-tab,.bt-pick,.bt-q-submit,.bt-q-btn):active:not(:disabled){scale:.96} - @media (prefers-reduced-motion:reduce){.bt-thumb{transition:none}.bt-panel-view>[data-move],.bt-drawer-name-input,.bt-veil,.bt-pop-in,.bt-token,.bt-drop,.bt-newchat,.bt-option-hint,.bt-connector-form,.bt-settings-head>*{animation:none}} - `; + @keyframes bt-mem-line{from{opacity:0;transform:translateY(4px)}} + .bt-mem-skel{display:flex;flex-direction:column;gap:24px} + .bt-mem-skel-block{display:flex;flex-direction:column;gap:10px} +@@ -2574,2935 +2574,7469 @@ body[data-ds-dark-theme] .bt-theme-chip i{background:var(--da)} + @media (max-width:640px){.bt-mem{width:100vw;height:100%;border-radius:0;border:0}.bt-mem-body{padding:18px 18px 20px}.bt-mem-foot{padding:8px 12px 12px}} + @media (prefers-reduced-motion:reduce){.bt-mem,.bt-mem-scrim,.bt-mem-summary>*,.bt-mem-empty,.bt-mem-reply,.bt-mem-skel-block span,.bt-mem-entries,.bt-mem-send>*,.bt-mem-changes li svg{animation:none}} + +-.bt-thumb{position:absolute;left:0;top:0;z-index:0;pointer-events:none;transition:opacity .14s ease} +-.bt-thumb[data-hidden]{opacity:0} +-[data-morph]{overflow:hidden!important} +-.bt-veil{animation:bt-veil-in .22s ease-out} +-.bt-with-icon{display:inline-flex;align-items:center;gap:4px} +-.bt-with-icon svg{width:14px;height:14px} +-.bt-tag-admin{display:inline-flex;align-items:center;gap:4px} +-.bt-create-inline{padding:18px 16px;border:1px solid var(--bt-line);border-radius:12px;background:var(--bt-card)} +-.bt-create-inline .bt-create-card{max-width:none} +-.bt-panel-view>[data-move=in]{animation:bt-page-in ${SPRING_SHAPE.duration}ms ${SPRING_SHAPE.easing} backwards,bt-veil-in .2s ease-out backwards} +-.bt-panel-view>[data-move=out]{animation:bt-page-out ${SPRING_SHAPE.duration}ms ${SPRING_SHAPE.easing} backwards,bt-veil-in .2s ease-out backwards} +-.bt-panel-view>:is([data-move=in],[data-move=out]):nth-child(2){animation-delay:25ms} +-.bt-panel-view>:is([data-move=in],[data-move=out]):nth-child(n+3){animation-delay:50ms} +-.bt-panel-view>.bt-drawer-body[data-move=tab]{animation:bt-veil-in .2s ease-out} +-.bt-drawer-name-input{animation:bt-veil-in .16s ease-out} +-.bt-pop-in{display:inline-flex;animation:bt-check-pop ${SPRING_POP.duration}ms ${SPRING_POP.easing}} +-:is(.bt-soft,.bt-send,.bt-icon-btn,.bt-settings-close,.bt-mini,.bt-tab,.bt-nc-send,.bt-avedit-tab,.bt-shape,.bt-pick,.bt-swatch,.bt-palette-clear){transition:background-color .12s ease,color .12s ease,border-color .12s ease,opacity .12s ease,${spring(SPRING_TAP, "scale")}} +-:is(.bt-icon-btn,.bt-settings-close,.bt-nc-send,.bt-shape,.bt-swatch,.bt-color,.bt-x,.bt-q-x,.bt-palette-clear):active:not(:disabled){scale:.9} +-:is(.bt-soft,.bt-send,.bt-mini,.bt-tab,.bt-avedit-tab,.bt-pick,.bt-q-submit,.bt-q-btn):active:not(:disabled){scale:.96} +-@media (prefers-reduced-motion:reduce){.bt-thumb{transition:none}.bt-panel-view>[data-move],.bt-drawer-name-input,.bt-veil,.bt-pop-in,.bt-token,.bt-drop,.bt-newchat,.bt-option-hint,.bt-connector-form,.bt-settings-head>*{animation:none}} +-`; -var CONVERSATION_CSS = ` -.bt-think{font-size:13px;line-height:18px;color:var(--bt-ink-2);margin:2px 0 4px;max-width:min(560px,78%)} -.bt-think summary{cursor:pointer;list-style:none;display:inline-flex;align-items:center;gap:6px} @@ -5242,6 +5128,27 @@ diff --git a/client.js b/client.js -.bt-shimmer{animation:none;background:none;color:var(--bt-ink-2)} -[data-bt-enter="new"]{animation:none} -[data-bt-enter="after-collapse"]{animation:bt-fade-in .12s ease-out} ++.bt-thumb{position:absolute;left:0;top:0;z-index:0;pointer-events:none;transition:opacity .14s ease} ++.bt-thumb[data-hidden]{opacity:0} ++[data-morph]{overflow:hidden!important} ++.bt-veil{animation:bt-veil-in .22s ease-out} ++.bt-with-icon{display:inline-flex;align-items:center;gap:4px} ++.bt-with-icon svg{width:14px;height:14px} ++.bt-tag-admin{display:inline-flex;align-items:center;gap:4px} ++.bt-create-inline{padding:18px 16px;border:1px solid var(--bt-line);border-radius:12px;background:var(--bt-card)} ++.bt-create-inline .bt-create-card{max-width:none} ++.bt-panel-view>[data-move=in]{animation:bt-page-in ${SPRING_SHAPE.duration}ms ${SPRING_SHAPE.easing} backwards,bt-veil-in .2s ease-out backwards} ++.bt-panel-view>[data-move=out]{animation:bt-page-out ${SPRING_SHAPE.duration}ms ${SPRING_SHAPE.easing} backwards,bt-veil-in .2s ease-out backwards} ++.bt-panel-view>:is([data-move=in],[data-move=out]):nth-child(2){animation-delay:25ms} ++.bt-panel-view>:is([data-move=in],[data-move=out]):nth-child(n+3){animation-delay:50ms} ++.bt-panel-view>.bt-drawer-body[data-move=tab]{animation:bt-veil-in .2s ease-out} ++.bt-drawer-name-input{animation:bt-veil-in .16s ease-out} ++.bt-pop-in{display:inline-flex;animation:bt-check-pop ${SPRING_POP.duration}ms ${SPRING_POP.easing}} ++:is(.bt-soft,.bt-send,.bt-icon-btn,.bt-settings-close,.bt-mini,.bt-tab,.bt-nc-send,.bt-avedit-tab,.bt-shape,.bt-pick,.bt-swatch,.bt-palette-clear){transition:background-color .12s ease,color .12s ease,border-color .12s ease,opacity .12s ease,${spring(SPRING_TAP, "scale")}} ++:is(.bt-icon-btn,.bt-settings-close,.bt-nc-send,.bt-shape,.bt-swatch,.bt-color,.bt-x,.bt-q-x,.bt-palette-clear):active:not(:disabled){scale:.9} ++:is(.bt-soft,.bt-send,.bt-mini,.bt-tab,.bt-avedit-tab,.bt-pick,.bt-q-submit,.bt-q-btn):active:not(:disabled){scale:.96} ++@media (prefers-reduced-motion:reduce){.bt-thumb{transition:none}.bt-panel-view>[data-move],.bt-drawer-name-input,.bt-veil,.bt-pop-in,.bt-token,.bt-drop,.bt-newchat,.bt-option-hint,.bt-connector-form,.bt-settings-head>*{animation:none}} ++`; +var CONVERSATION_CSS = ` +.bt-think{font-size:13px;line-height:18px;color:var(--bt-ink-2);margin:2px 0 4px;max-width:min(560px,78%)} +.bt-think summary{cursor:pointer;list-style:none;display:inline-flex;align-items:center;gap:6px;max-width:100%;min-width:0;white-space:nowrap} @@ -5825,9939 +5732,9791 @@ diff --git a/client.js b/client.js + ); +} + -+// src/client/questions.js ++// src/client/schedules.js +var import_react6 = require("react"); -+var keyLetter = (index) => String.fromCharCode(65 + index); -+var optionsOf = (question) => (question?.options ?? []).map((option) => typeof option === "string" ? { label: option } : option); -+var isTypingTarget = (node) => node instanceof HTMLInputElement || node instanceof HTMLTextAreaElement || node instanceof HTMLSelectElement || node instanceof HTMLElement && node.isContentEditable; -+function useOptionKeys(count, onPick, ownRef) { -+ const pick2 = (0, import_react6.useRef)(onPick); -+ pick2.current = onPick; ++var POLL_MS2 = 3e4; ++var shared = null; ++var listeners = /* @__PURE__ */ new Set(); ++function publish(view) { ++ shared = view; ++ for (const listener of listeners) listener(view); ++} ++function publishFailure(error) { ++ publish({ available: false, tasks: [], readFailure: hostText(error?.message ?? String(error)) }); ++} ++function useSchedules(actions) { ++ const [view, setView] = (0, import_react6.useState)(shared); + (0, import_react6.useEffect)(() => { -+ if (count === 0) return void 0; -+ const onKey = (event) => { -+ if (event.defaultPrevented || event.metaKey || event.ctrlKey || event.altKey || event.shiftKey || event.key.length !== 1) return; -+ if (isTypingTarget(event.target) || isTypingTarget(document.activeElement)) return; -+ if (document.querySelector('[role="dialog"], [aria-modal="true"]') !== null) return; -+ const index = event.key.toUpperCase().charCodeAt(0) - 65; -+ if (index >= 0 && index < count) { -+ event.preventDefault(); -+ pick2.current(index); -+ } else if (index === count && ownRef.current) { -+ event.preventDefault(); -+ ownRef.current.focus(); -+ } ++ listeners.add(setView); ++ let stopped = false; ++ let timer; ++ const load = () => actions.schedules?.().then((next) => { ++ if (!stopped) publish(next); ++ }).catch((error) => { ++ if (!stopped) publishFailure(error); ++ }).finally(() => { ++ if (!stopped) timer = setTimeout(load, POLL_MS2); ++ }); ++ load(); ++ return () => { ++ stopped = true; ++ clearTimeout(timer); ++ listeners.delete(setView); + }; -+ document.addEventListener("keydown", onKey, true); -+ return () => document.removeEventListener("keydown", onKey, true); -+ }, [count, ownRef]); ++ }, []); ++ return view; +} -+function QuestionView({ question, onAnswer, onDismiss, ownAnswer = false, raised = false }) { -+ const [own, setOwn] = (0, import_react6.useState)(""); -+ const [picked, setPicked] = (0, import_react6.useState)([]); -+ const ownRef = (0, import_react6.useRef)(null); -+ const titleId = (0, import_react6.useId)(); -+ const options = optionsOf(question); -+ const multi = question.multiSelect === true; -+ const custom = own.trim(); -+ const canSubmit = multi ? picked.length > 0 || custom !== "" : custom !== ""; -+ const choose = (label) => { -+ if (multi) { -+ setPicked((current) => current.includes(label) ? current.filter((item) => item !== label) : [...current, label]); -+ return; ++var schedulesReadFailure = (failure) => typeof failure === "string" && failure !== "" ? t("The scheduled tasks could not be read: {reason}", { reason: failure }) : null; ++var localZone = () => Intl.DateTimeFormat().resolvedOptions().timeZone; ++var clock2 = (time) => String(time ?? "").slice(0, 5); ++var when = (ms, options) => new Intl.DateTimeFormat(dateLocale(), options).format(ms); ++function dayAndTime(ms) { ++ const sameYear = new Date(ms).getFullYear() === (/* @__PURE__ */ new Date()).getFullYear(); ++ return when(ms, { ...sameYear ? {} : { year: "numeric" }, month: "short", day: "numeric", hour: "2-digit", minute: "2-digit" }); ++} ++function instant(value) { ++ const at = Date.parse(value); ++ return Number.isFinite(at) ? dayAndTime(at) : ""; ++} ++function weekdayNames(days) { ++ const names = days.map((day) => when(Date.UTC(2024, 0, day), { weekday: "short", timeZone: "UTC" })); ++ return names.join(dateLocale().startsWith("zh") ? "、" : ", "); ++} ++function everyText(seconds) { ++ if (seconds % 86400 === 0) return seconds === 86400 ? t("Every day") : t("Every {count} days", { count: seconds / 86400 }); ++ if (seconds % 3600 === 0) return seconds === 3600 ? t("Every hour") : t("Every {count} hours", { count: seconds / 3600 }); ++ if (seconds % 60 === 0) return t("Every {count} minutes", { count: seconds / 60 }); ++ return t("Every {count} seconds", { count: seconds }); ++} ++function timingText(task) { ++ const zone = task.timeZone && task.timeZone !== localZone() ? ` (${task.timeZone})` : ""; ++ switch (task.kind) { ++ case "after": ++ case "at": { ++ const time = instant(task.scheduledAt); ++ return time === "" ? "" : t("Once, {time}", { time }); + } -+ onAnswer({ selected: [label] }); -+ }; -+ const submit = () => { -+ if (!canSubmit) return; -+ onAnswer({ selected: multi ? picked : [], ...custom ? { custom } : {} }); -+ }; -+ useOptionKeys(options.length, (index) => choose(options[index].label), ownRef); -+ const ownLetter = options.length > 0 ? keyLetter(options.length) : void 0; -+ const field = ownAnswer ? (0, import_react6.createElement)("textarea", { -+ ref: ownRef, -+ rows: 1, -+ value: own, -+ autoComplete: "off", -+ spellCheck: false, -+ className: "bt-q-input", -+ "aria-label": t("Your own answer"), -+ placeholder: t("Type your own answer"), -+ "aria-keyshortcuts": ownLetter?.toLowerCase(), -+ onChange: (event) => setOwn(event.target.value), -+ onKeyDown: (event) => { -+ if (event.key !== "Enter" || event.shiftKey || event.nativeEvent.isComposing) return; -+ event.preventDefault(); -+ submit(); ++ case "every": ++ return everyText(task.everySeconds); ++ case "daily": ++ return `${t("Every day at {time}", { time: clock2(task.time) })}${zone}`; ++ case "weekly": { ++ const days = (task.weekdays ?? []).join(); ++ const text2 = days === "1,2,3,4,5" ? t("Every weekday at {time}", { time: clock2(task.time) }) : days === "6,7" ? t("Every weekend at {time}", { time: clock2(task.time) }) : days === "1,2,3,4,5,6,7" ? t("Every day at {time}", { time: clock2(task.time) }) : t("Every {days} at {time}", { days: weekdayNames(task.weekdays ?? []), time: clock2(task.time) }); ++ return `${text2}${zone}`; + } -+ }) : null; -+ const rows = options.map((option, optionIndex) => { -+ const selected = multi && picked.includes(option.label); -+ return (0, import_react6.createElement)( -+ "button", -+ { -+ key: option.label, -+ type: "button", -+ className: "bt-q-opt", -+ "aria-pressed": multi ? selected : void 0, -+ "aria-keyshortcuts": keyLetter(optionIndex).toLowerCase(), -+ onClick: () => choose(option.label) -+ }, -+ (0, import_react6.createElement)("span", { className: "bt-key", "aria-hidden": true }, keyLetter(optionIndex)), -+ (0, import_react6.createElement)( -+ "span", -+ { className: "bt-q-body" }, -+ (0, import_react6.createElement)("span", { className: "bt-q-label" }, option.label), -+ option.description ? (0, import_react6.createElement)("span", { className: "bt-q-desc" }, option.description) : null -+ ), -+ selected && !raised ? (0, import_react6.createElement)("span", { className: "bt-q-check", title: t("Selected") }, (0, import_react6.createElement)(CheckIcon)) : null -+ ); -+ }); -+ if (raised && field) { -+ rows.push((0, import_react6.createElement)( -+ "label", -+ { key: "own", className: "bt-q-opt bt-q-ownrow", "data-filled": custom !== "" ? "" : void 0 }, -+ ownLetter ? (0, import_react6.createElement)("span", { className: "bt-key", "aria-hidden": true }, ownLetter) : null, -+ (0, import_react6.createElement)("span", { className: "bt-q-body" }, field) -+ )); -+ } -+ let foot = null; -+ if (raised && multi) { -+ foot = (0, import_react6.createElement)( -+ "div", -+ { className: "bt-q-foot" }, -+ (0, import_react6.createElement)("button", { type: "button", className: "bt-q-btn", onClick: onDismiss }, t("Skip")), -+ (0, import_react6.createElement)("button", { type: "submit", className: "bt-q-submit", disabled: !canSubmit }, t("Submit")) -+ ); -+ } else if ((raised || multi) && canSubmit) { -+ foot = (0, import_react6.createElement)("div", { className: "bt-q-foot" }, (0, import_react6.createElement)("button", { type: "submit", className: "bt-q-submit" }, t("Submit"))); ++ case "cron": ++ return `${t("On the schedule {expression}", { expression: task.expression })}${zone}`; ++ default: ++ return ""; + } -+ return (0, import_react6.createElement)( -+ "form", -+ { -+ className: raised ? "bt-q bt-q-raised" : "bt-q", -+ "aria-labelledby": titleId, -+ onSubmit: (event) => { -+ event.preventDefault(); -+ submit(); -+ } -+ }, -+ (0, import_react6.createElement)( -+ "div", -+ { className: "bt-q-head" }, -+ (0, import_react6.createElement)( -+ "div", -+ { className: "bt-q-text" }, -+ (0, import_react6.createElement)("p", { className: "bt-q-title", id: titleId }, question.header ? `${question.header} · ` : "", question.question), -+ question.detail ? (0, import_react6.createElement)("div", { className: "bt-q-detail" }, question.detail) : null, -+ raised && multi ? (0, import_react6.createElement)("div", { className: "bt-q-detail" }, t("Choose all that apply")) : null -+ ), -+ (0, import_react6.createElement)("button", { type: "button", className: "bt-q-x", "aria-label": t("Dismiss question"), title: t("Dismiss"), onClick: onDismiss }, (0, import_react6.createElement)(CloseIcon)) -+ ), -+ rows.length > 0 ? (0, import_react6.createElement)("div", { className: "bt-q-list", role: "group" }, rows) : null, -+ field && !raised ? (0, import_react6.createElement)( -+ "div", -+ { className: "bt-q-custom" }, -+ (0, import_react6.createElement)("label", { className: "bt-q-field" }, field), -+ !multi && canSubmit ? (0, import_react6.createElement)("button", { type: "submit", className: "bt-q-submit" }, t("Submit")) : null -+ ) : null, -+ foot -+ ); +} -+function SettledQuestion({ question, answer }) { -+ const options = optionsOf(question); -+ const prompt = question?.question ?? ""; -+ const chosen = [...answer.selected ?? [], ...answer.custom ? [answer.custom] : []]; -+ if (chosen.length === 0) { -+ return (0, import_react6.createElement)( -+ "div", -+ { className: "bt-q bt-q-settled bt-q-dismissed", role: "group", "aria-label": prompt }, -+ (0, import_react6.createElement)( -+ "div", -+ { className: "bt-q-head" }, -+ (0, import_react6.createElement)("div", { className: "bt-q-text" }, (0, import_react6.createElement)("p", { className: "bt-q-title" }, prompt)), -+ (0, import_react6.createElement)("span", { className: "bt-q-badge" }, t("Dismissed")) -+ ) -+ ); ++var nextText = (task) => { ++ if (task.status === "paused") return t("Paused"); ++ if (task.status !== "active") return t("Finished"); ++ const at = instant(task.scheduledAt); ++ return at === "" ? timingText(task) : t("Next: {time}", { time: at }); ++}; ++function lastRunText(run) { ++ if (run === void 0 || !Number.isFinite(run.at)) return ""; ++ const time = dayAndTime(run.at); ++ switch (run.result) { ++ case "running": ++ return t("Running since {time}", { time }); ++ case "stopped": ++ return t("Last run {time} · Stopped", { time }); ++ case "failed": ++ return t("Last run {time} · Failed: {reason}", { time, reason: hostText(run.reason || "Unknown error") }); ++ default: ++ return t("Last run {time}", { time }); + } -+ return (0, import_react6.createElement)( -+ "div", -+ { className: "bt-q bt-q-settled", role: "group", "aria-label": prompt }, -+ prompt ? (0, import_react6.createElement)("div", { className: "bt-q-head" }, (0, import_react6.createElement)("div", { className: "bt-q-text" }, (0, import_react6.createElement)("p", { className: "bt-q-title" }, prompt))) : null, -+ (0, import_react6.createElement)("div", { className: "bt-q-list", role: "group", "aria-label": t("Your answer") }, chosen.map((label, index) => { -+ const optionIndex = options.findIndex((option) => option.label === label); -+ return (0, import_react6.createElement)( -+ "div", -+ { key: `${label}-${index}`, className: "bt-q-opt" }, -+ optionIndex >= 0 ? (0, import_react6.createElement)("span", { className: "bt-key", "aria-hidden": true }, keyLetter(optionIndex)) : null, -+ (0, import_react6.createElement)("span", { className: "bt-q-body" }, (0, import_react6.createElement)("span", { className: "bt-q-label" }, label)), -+ (0, import_react6.createElement)("span", { className: "bt-q-check", title: t("Selected") }, (0, import_react6.createElement)(CheckIcon)) -+ ); -+ })) -+ ); +} -+function QuestionCard({ matched }) { -+ const pending = matched; -+ const questions = pending.questions ?? []; -+ const [index, setIndex] = (0, import_react6.useState)(0); -+ const [answers, setAnswers] = (0, import_react6.useState)([]); -+ const question = questions[index]; -+ if (question === void 0) return null; -+ const onAnswer = (answer) => { -+ const next = [...answers, { id: question.id, ...answer }]; -+ if (index + 1 < questions.length) { -+ setAnswers(next); -+ setIndex(index + 1); ++var lockedWindow = (roster) => typeof roster?.readOnly === "string" && roster.readOnly.trim() !== ""; ++function TaskRow({ task, actions, onView, onEdit, onCopy, showBot, roster, locked }) { ++ const [confirming, setConfirming] = (0, import_react6.useState)(false); ++ const [busy, setBusy] = (0, import_react6.useState)(false); ++ const [rowError, setRowError] = (0, import_react6.useState)(""); ++ const refuse = (error) => setRowError(hostText(error?.message ?? String(error))); ++ (0, import_react6.useEffect)(() => { ++ if (!confirming) return void 0; ++ const timer = setTimeout(() => setConfirming(false), 3e3); ++ return () => clearTimeout(timer); ++ }, [confirming]); ++ const remove = () => { ++ if (!confirming) { ++ setConfirming(true); + return; + } -+ pending.answer({ answers: next }); ++ setBusy(true); ++ setRowError(""); ++ void actions.scheduleDelete(task.sessionId, task.id).then((next) => { ++ setBusy(false); ++ setRowError(""); ++ onView(next); ++ }, (error) => { ++ setBusy(false); ++ refuse(error); ++ }); + }; -+ return (0, import_react6.createElement)(QuestionView, { key: index, question, onAnswer, onDismiss: () => pending.dismiss(), ownAnswer: true, raised: true }); -+} -+function PendingQuestion({ sessionId, question, actions, inline }) { -+ const [answered, setAnswered] = (0, import_react6.useState)(null); -+ if (answered === question.id) return null; -+ const onAnswer = (answer) => { -+ setAnswered(question.id); -+ void actions.answerQuestion(sessionId, question.id, { selected: answer.selected ?? [], custom: answer.custom ?? "" }).catch(() => setAnswered(null)); ++ const toggle = () => { ++ setBusy(true); ++ setRowError(""); ++ const step = task.status === "paused" ? actions.scheduleResume : actions.schedulePause; ++ void step(task.sessionId, task.id).then((next) => { ++ setBusy(false); ++ setRowError(""); ++ onView(next); ++ }, (error) => { ++ setBusy(false); ++ refuse(error); ++ }); + }; -+ const onDismiss = () => { -+ setAnswered(question.id); -+ void actions.dismissQuestion(sessionId); ++ const retry = () => { ++ setBusy(true); ++ setRowError(""); ++ void actions.scheduleRetry(task.sessionId, task.id).then((next) => { ++ setBusy(false); ++ onView(next); ++ }, (error) => { ++ setBusy(false); ++ refuse(error); ++ }); + }; ++ const room2 = task.roomId ? roster.roomsById[task.roomId] : void 0; ++ const stoppable = task.status === "active" || task.status === "paused"; ++ const failed = task.lastRun?.result === "failed"; ++ const last = lastRunText(task.lastRun); + return (0, import_react6.createElement)( + "div", -+ { className: inline ? "bt-q-inline" : "bt-q-dock" }, -+ (0, import_react6.createElement)(QuestionView, { key: question.id, question, onAnswer, onDismiss, ownAnswer: question.allowCustom === true, raised: !inline }) ++ { className: "bt-set-row bt-task", "data-done": stoppable ? void 0 : "", "data-paused": task.status === "paused" || void 0, "data-busy": busy || void 0 }, ++ showBot ? (0, import_react6.createElement)(BotAvatar, { bot: roster.byId[task.botId], size: 28, badge: false, live: false }) : null, ++ (0, import_react6.createElement)( ++ "span", ++ { className: "bt-set-copy" }, ++ (0, import_react6.createElement)("span", { className: "bt-task-title" }, task.title), ++ (0, import_react6.createElement)("span", { className: "bt-set-hint" }, [timingText(task), room2 ? t("in {name}", { name: room2.name }) : ""].filter(Boolean).join(" · ")), ++ task.prompt && task.prompt !== task.title ? (0, import_react6.createElement)("span", { className: "bt-task-prompt" }, task.prompt) : null, ++ last ? (0, import_react6.createElement)("span", { className: "bt-task-last", "data-failed": failed || void 0 }, last) : null, ++ rowError ? (0, import_react6.createElement)("span", { className: "bt-task-last", "data-failed": "", role: "alert" }, rowError) : null ++ ), ++ (0, import_react6.createElement)("span", { className: "bt-task-next" }, nextText(task)), ++ // A locked window draws none of the writes the host would refuse (#2232). Hiding, not ++ // disabling: a disabled button still says this window can do it. ++ !locked && failed && task.status !== "paused" && actions.scheduleRetry ? (0, import_react6.createElement)("button", { type: "button", className: "bt-mini bt-task-pause", disabled: busy, title: t("Run the task once now"), onClick: retry }, t("Retry")) : null, ++ !locked && onEdit ? (0, import_react6.createElement)("button", { type: "button", className: "bt-mini bt-task-tool", disabled: busy, "aria-label": t("Edit"), title: t("Edit"), onClick: () => onEdit(task) }, (0, import_react6.createElement)(PencilIcon)) : null, ++ !locked && onCopy ? (0, import_react6.createElement)("button", { type: "button", className: "bt-mini bt-task-tool", disabled: busy, "aria-label": t("Copy to another Bot or group chat"), title: t("Copy to…"), onClick: () => onCopy(task) }, (0, import_react6.createElement)(DuplicateIcon)) : null, ++ !locked && stoppable ? (0, import_react6.createElement)("button", { type: "button", className: "bt-mini bt-task-pause", disabled: busy, onClick: toggle }, task.status === "paused" ? t("Resume") : t("Pause")) : null, ++ locked ? null : (0, import_react6.createElement)("button", { ++ type: "button", ++ className: "bt-mini bt-task-remove", ++ "data-confirm": confirming || void 0, ++ disabled: busy, ++ "aria-label": confirming ? t("Click again to delete") : t("Delete task"), ++ title: confirming ? void 0 : t("Delete task"), ++ onClick: remove ++ }, confirming ? t("Delete") : (0, import_react6.createElement)(TrashIcon)) + ); +} -+function QuestionDock({ sessionId, useRoster, useCards, useSession, actions }) { -+ const question = useRoster((value) => value.questions?.[sessionId]); -+ const drawn = useCards?.((state) => state[sessionId]?.[question?.callId] > 0) === true; -+ const loading = useSession?.(historyLoading) === true; -+ if (question === void 0 || question.callId && (drawn || loading)) return null; -+ return (0, import_react6.createElement)(PendingQuestion, { sessionId, question, actions, inline: false }); -+} -+function AnsweredCell({ node }) { -+ const data = node.data ?? {}; -+ const questions = Array.isArray(data.questions) ? data.questions : []; -+ if (questions.length === 0) { -+ return data.text ? (0, import_react6.createElement)("div", { className: "bt-urow" }, (0, import_react6.createElement)("div", { className: "bt-ububble" }, data.text)) : null; -+ } -+ const answers = Array.isArray(data.answers) ? data.answers : []; -+ return (0, import_react6.createElement)("div", { className: "bt-q-inline" }, questions.map((question) => (0, import_react6.createElement)(SettledQuestion, { -+ key: question.id, -+ question, -+ answer: answers.find((answer) => answer.id === question.id) ?? { selected: [] } -+ }))); -+} -+ -+// src/client/secret-card.js -+var import_react7 = require("react"); -+var MIN_VALUE = 4; -+var NONE = {}; -+var KeyIcon = () => (0, import_react7.createElement)( -+ "svg", -+ { width: 15, height: 15, viewBox: "0 0 20 20", fill: "none", stroke: "currentColor", strokeWidth: 1.5, strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": true }, -+ (0, import_react7.createElement)("path", { d: "M7 9.5a3.5 3.5 0 1 1 0 .01z" }), -+ (0, import_react7.createElement)("path", { d: "M10.4 10.6L17 10.6M14.5 10.6v2.4M16.6 10.6v1.8" }) -+); -+var LockIcon = () => (0, import_react7.createElement)( -+ "svg", -+ { width: 14, height: 14, viewBox: "0 0 20 20", fill: "none", stroke: "currentColor", strokeWidth: 1.5, strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": true }, -+ (0, import_react7.createElement)("path", { d: "M6.5 9V7a3.5 3.5 0 0 1 7 0v2" }), -+ (0, import_react7.createElement)("rect", { x: 4.5, y: 9, width: 11, height: 8, rx: 2 }) -+); -+var WarnIcon = () => (0, import_react7.createElement)( -+ "svg", -+ { width: 14, height: 14, viewBox: "0 0 20 20", fill: "none", stroke: "currentColor", strokeWidth: 1.5, strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": true }, -+ (0, import_react7.createElement)("path", { d: "M10 3.5l7 12.5H3z" }), -+ (0, import_react7.createElement)("path", { d: "M10 8.5v3.5M10 14.2h.01" }) -+); -+var MASK_BY_CSS = typeof CSS !== "undefined" && typeof CSS.supports === "function" && CSS.supports("-webkit-text-security", "disc"); -+var insecure = () => typeof location !== "undefined" && location.protocol === "http:" && !/^(localhost|127(?:\.\d{1,3}){3}|\[::1\])$/.test(location.hostname); -+var scopeLabel = (scope, roster) => { -+ if (scope === "all") return t("All my Bots"); -+ const names = (scope ?? []).map((id) => roster.byId[id]?.name).filter(Boolean); -+ return names.length === 0 ? t("No Bot") : names.join(t(" and ")); -+}; -+var when = (time) => Number.isFinite(time) ? new Date(time).toLocaleString(dateLocale(), { month: "short", day: "numeric", hour: "2-digit", minute: "2-digit" }) : ""; -+function MaskedInput({ value, onChange, onEnter, label, autoFocus = false }) { -+ return (0, import_react7.createElement)("input", { -+ className: "bt-secret-input", -+ type: MASK_BY_CSS ? "text" : "password", -+ "data-masked": MASK_BY_CSS ? "" : void 0, -+ value, -+ autoFocus, -+ autoComplete: MASK_BY_CSS ? "off" : "new-password", -+ autoCorrect: "off", -+ autoCapitalize: "off", -+ spellCheck: false, -+ "data-1p-ignore": "", -+ "data-lpignore": "true", -+ "data-bwignore": "", -+ "data-form-type": "other", -+ "aria-label": label, -+ placeholder: t("Paste or type the key"), -+ onChange: (event) => onChange(event.target.value), -+ onKeyDown: (event) => { -+ if (event.key !== "Enter" || event.nativeEvent.isComposing) return; -+ event.preventDefault(); -+ onEnter?.(); -+ } -+ }); ++var pad = (number) => String(number).padStart(2, "0"); ++function inAnHour() { ++ const date = new Date(Date.now() + 36e5); ++ date.setMinutes(0, 0, 0); ++ return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:00`; +} -+function ScopeSwitch({ value, onChange, bot }) { -+ const options = [["all", t("All my Bots")], ["bot", t("Only {name}", { name: bot.name })]]; -+ return (0, import_react7.createElement)( -+ "div", -+ { className: "bt-secret-scope", role: "radiogroup", "aria-label": t("Who may use it") }, -+ (0, import_react7.createElement)("span", { className: "bt-secret-glide", "data-at": value === "all" ? 0 : 1, "aria-hidden": true }), -+ options.map(([id, text2]) => (0, import_react7.createElement)("button", { -+ key: id, -+ type: "button", -+ role: "radio", -+ "aria-checked": value === id, -+ className: "bt-secret-seg", -+ onClick: () => onChange(id) -+ }, text2)) -+ ); ++var KINDS = [["at", "Once"], ["daily", "Every day"], ["weekly", "Every week"], ["every", "Repeat"]]; ++var UNITS = [["seconds", 1, "seconds"], ["minutes", 60, "minutes"], ["hours", 3600, "hours"], ["days", 86400, "days"]]; ++function localInput(ms) { ++ const date = new Date(ms); ++ return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}`; +} -+function SecretHead({ bot, title, detail, name, onClose }) { -+ return (0, import_react7.createElement)( -+ "div", -+ { className: "bt-q-head bt-secret-head" }, -+ (0, import_react7.createElement)("span", { className: "bt-secret-who" }, (0, import_react7.createElement)(BotMark, { bot, size: 28 }), (0, import_react7.createElement)("span", { className: "bt-secret-lock" }, (0, import_react7.createElement)(LockIcon))), -+ (0, import_react7.createElement)( -+ "div", -+ { className: "bt-q-text" }, -+ (0, import_react7.createElement)("p", { className: "bt-q-title" }, title), -+ (0, import_react7.createElement)("div", { className: "bt-q-detail" }, (0, import_react7.createElement)("code", { className: "bt-secret-name" }, name), detail ? ` · ${detail}` : "") -+ ), -+ onClose ? (0, import_react7.createElement)("button", { type: "button", className: "bt-q-x", "aria-label": t("Cancel the request"), title: t("Cancel"), onClick: onClose }, (0, import_react7.createElement)(CloseIcon)) : null -+ ); ++function formStart(task) { ++ const seconds = task?.kind === "every" ? Number(task.everySeconds) : 3600; ++ const [unit, size] = seconds % 86400 === 0 ? ["days", 86400] : seconds % 3600 === 0 ? ["hours", 3600] : seconds % 60 === 0 ? ["minutes", 60] : ["seconds", 1]; ++ const once = task?.kind === "at" || task?.kind === "after"; ++ return { ++ title: task?.title ?? "", ++ prompt: task?.prompt ?? "", ++ kind: once ? "at" : task?.kind ?? "daily", ++ at: once && !Number.isNaN(Date.parse(task.scheduledAt)) ? localInput(Date.parse(task.scheduledAt)) : inAnHour(), ++ time: task?.time ? clock2(task.time) : "09:00", ++ weekdays: task?.kind === "weekly" && Array.isArray(task.weekdays) ? task.weekdays : [1, 2, 3, 4, 5], ++ count: Math.max(1, Math.round(seconds / size)), ++ unit, ++ timeZone: task?.timeZone || localZone() ++ }; +} -+function SecretCard({ bot, request, roster, actions }) { -+ const [value, setValue] = (0, import_react7.useState)(""); -+ const [scope, setScope] = (0, import_react7.useState)("all"); -+ const [replacing, setReplacing] = (0, import_react7.useState)(false); -+ const [busy, setBusy] = (0, import_react7.useState)(false); -+ const [error, setError] = (0, import_react7.useState)(""); -+ const noteId = (0, import_react7.useId)(); -+ const secret = (roster.secrets ?? []).find((entry) => entry.name === request.name); -+ const run = (promise) => { ++function TaskForm({ roster, actions, start, task, onDone, onCancel }) { ++ const first = formStart(task); ++ const [target, setTarget] = (0, import_react6.useState)(task ? task.botId ?? task.roomId : start ?? ""); ++ const [title, setTitle] = (0, import_react6.useState)(first.title); ++ const [prompt, setPrompt] = (0, import_react6.useState)(first.prompt); ++ const [kind, setKind] = (0, import_react6.useState)(first.kind); ++ const [at, setAt] = (0, import_react6.useState)(first.at); ++ const [time, setTime] = (0, import_react6.useState)(first.time); ++ const [weekdays, setWeekdays] = (0, import_react6.useState)(first.weekdays); ++ const [count, setCount] = (0, import_react6.useState)(first.count); ++ const [unit, setUnit] = (0, import_react6.useState)(first.unit); ++ const [busy, setBusy] = (0, import_react6.useState)(false); ++ const [error, setError] = (0, import_react6.useState)(""); ++ const timing = () => { ++ if (kind === "cron") return { kind: "keep" }; ++ if (kind === "at") return { kind, at: new Date(at).toISOString(), timeZone: first.timeZone }; ++ if (kind === "every") return { kind, seconds: Math.round(Number(count) * UNITS.find(([id]) => id === unit)[1]) }; ++ return { kind, time, timeZone: first.timeZone, ...kind === "weekly" ? { weekdays } : {} }; ++ }; ++ const submit = (event) => { ++ event.preventDefault(); ++ if (busy || target === "") return; + setBusy(true); + setError(""); -+ return Promise.resolve(promise).then(() => setValue(""), (failure) => setError(failure?.message ?? String(failure))).finally(() => setBusy(false)); -+ }; -+ const cancel = () => run(actions.secretCancel(request.id)); -+ const long = value.trim().length >= MIN_VALUE; -+ const save = () => { -+ if (long && !busy) void run(actions.secretSet({ requestId: request.id, value, scope })); -+ }; -+ const allow = () => { -+ if (!busy) void run(actions.secretAllow(request.id)); ++ let when3; ++ try { ++ when3 = timing(); ++ } catch { ++ setError(t("Pick a date and time")); ++ setBusy(false); ++ return; ++ } ++ const saved = task ? actions.scheduleUpdate({ sessionId: task.sessionId, id: task.id, target, title, prompt, when: when3 }) : actions.scheduleCreate({ target, title, prompt, when: when3 }); ++ void saved.then(onDone, (failure) => { ++ setError(hostText(failure?.message ?? String(failure))); ++ setBusy(false); ++ }); + }; -+ const filling = request.kind === "fill" || replacing; -+ const warn = insecure() ? (0, import_react7.createElement)("div", { className: "bt-secret-warn", role: "note" }, (0, import_react7.createElement)(WarnIcon), t("This page is not on HTTPS: the key crosses the network unencrypted.")) : null; -+ return (0, import_react7.createElement)( -+ "div", -+ { className: "bt-q bt-secret", role: "group", "aria-describedby": noteId, "data-kind": request.kind }, -+ (0, import_react7.createElement)(SecretHead, { -+ bot, -+ title: request.kind === "allow" ? t("Let {bot} use {name}?", { bot: bot.name, name: request.name }) : t("{name} needs a key", { name: bot.name }), -+ detail: request.purpose, -+ name: request.name, -+ onClose: busy ? void 0 : cancel -+ }), -+ request.kind === "allow" ? (0, import_react7.createElement)("div", { className: "bt-secret-saved" }, (0, import_react7.createElement)(KeyIcon), t("Saved for {scope}", { scope: scopeLabel(secret?.scope, roster) })) : null, -+ filling ? (0, import_react7.createElement)( -+ "div", -+ { className: "bt-secret-body" }, -+ (0, import_react7.createElement)("label", { className: "bt-q-field bt-secret-field" }, (0, import_react7.createElement)(MaskedInput, { value, onChange: setValue, onEnter: save, label: t("Value of {name}", { name: request.name }), autoFocus: replacing })), -+ request.kind === "fill" ? (0, import_react7.createElement)(ScopeSwitch, { value: scope, onChange: setScope, bot }) : null -+ ) : null, -+ (0, import_react7.createElement)( -+ "div", -+ { className: "bt-secret-note", id: noteId }, -+ t("{bot} never sees the value. It reaches {bot}'s shell as ", { bot: bot.name }), -+ (0, import_react7.createElement)("code", null, `$DSH_SECRET_${request.name}`), -+ ".", -+ filling && value !== "" && !long ? t(" At least {count} characters.", { count: MIN_VALUE }) : "" -+ ), -+ warn, -+ error ? (0, import_react7.createElement)("div", { className: "bt-secret-error", role: "alert" }, error) : null, -+ (0, import_react7.createElement)( -+ "div", -+ { className: "bt-q-foot" }, -+ request.kind === "allow" && !replacing ? (0, import_react7.createElement)("button", { type: "button", className: "bt-q-btn", disabled: busy, onClick: () => setReplacing(true) }, t("Replace value")) : null, -+ (0, import_react7.createElement)("button", { type: "button", className: "bt-q-btn", disabled: busy, onClick: cancel }, t("Cancel")), -+ filling ? (0, import_react7.createElement)("button", { type: "button", className: "bt-q-submit", disabled: !long || busy, onClick: save }, busy ? t("Saving…") : t("Save")) : (0, import_react7.createElement)("button", { type: "button", className: "bt-q-submit", disabled: busy, onClick: allow }, busy ? t("Allowing…") : t("Allow")) -+ ) -+ ); -+} -+function SettledSecret({ bot, request, roster }) { -+ const canceled = request.status === "canceled"; -+ const text2 = canceled ? t("{bot} asked for {name}", { bot: bot.name, name: request.name }) : request.status === "allowed" ? t("Allowed {bot} to use {name}", { bot: bot.name, name: request.name }) : t("Saved {name}", { name: request.name }); -+ return (0, import_react7.createElement)( -+ "div", -+ { className: "bt-q bt-q-settled bt-secret bt-secret-done", role: "group", "aria-label": text2, "data-status": request.status }, -+ (0, import_react7.createElement)( ++ const toggleDay = (day) => setWeekdays((days) => days.includes(day) ? days.filter((item) => item !== day) : [...days, day].sort()); ++ return (0, import_react6.createElement)( ++ "form", ++ { className: "bt-set-card bt-task-form", onSubmit: submit }, ++ (0, import_react6.createElement)( ++ "label", ++ { className: "bt-set-row bt-bs-row" }, ++ (0, import_react6.createElement)("span", { className: "bt-set-copy" }, (0, import_react6.createElement)("span", { className: "bt-bs-label" }, t("Send to"))), ++ (0, import_react6.createElement)( ++ "select", ++ { className: "bt-select bt-task-bot", value: target, onChange: (event) => setTarget(event.target.value) }, ++ (0, import_react6.createElement)("option", { value: "" }, t("Pick a Bot or group chat")), ++ (0, import_react6.createElement)("optgroup", { label: t("Bots") }, roster.bots.map((bot) => (0, import_react6.createElement)("option", { key: bot.id, value: bot.id }, bot.name))), ++ roster.rooms.length > 0 ? (0, import_react6.createElement)("optgroup", { label: t("Group chats") }, roster.rooms.map((room2) => (0, import_react6.createElement)("option", { key: room2.id, value: room2.id }, room2.name))) : null ++ ) ++ ), ++ (0, import_react6.createElement)( ++ "label", ++ { className: "bt-set-row bt-bs-row" }, ++ (0, import_react6.createElement)("span", { className: "bt-set-copy" }, (0, import_react6.createElement)("span", { className: "bt-bs-label" }, t("Name"))), ++ (0, import_react6.createElement)("input", { className: "bt-bs-input", value: title, maxLength: 120, placeholder: t("Morning briefing"), autoFocus: true, onChange: (event) => setTitle(event.target.value) }) ++ ), ++ (0, import_react6.createElement)( ++ "label", ++ { className: "bt-set-row bt-bs-row bt-bs-stack" }, ++ (0, import_react6.createElement)("span", { className: "bt-set-copy" }, (0, import_react6.createElement)("span", { className: "bt-bs-label" }, t("What to do"))), ++ (0, import_react6.createElement)("textarea", { className: "bt-bs-input bt-bs-area", value: prompt, rows: 3, placeholder: t("Sum up what changed since yesterday and what needs me today."), onChange: (event) => setPrompt(event.target.value) }) ++ ), ++ (0, import_react6.createElement)( + "div", -+ { className: "bt-q-head" }, -+ (0, import_react7.createElement)("span", { className: "bt-secret-who" }, (0, import_react7.createElement)(BotMark, { bot, size: 22 })), -+ (0, import_react7.createElement)("div", { className: "bt-q-text" }, (0, import_react7.createElement)( -+ "p", -+ { className: "bt-q-title" }, -+ text2, -+ canceled ? null : (0, import_react7.createElement)("span", { className: "bt-secret-scope-tag" }, ` · ${scopeLabel(request.scope, roster)}`) -+ )), -+ canceled ? (0, import_react7.createElement)("span", { className: "bt-q-badge" }, t("Canceled")) : (0, import_react7.createElement)("span", { className: "bt-q-check bt-secret-check", title: t("Done") }, (0, import_react7.createElement)(CheckIcon)) ++ { className: "bt-set-row bt-bs-row bt-bs-stack" }, ++ (0, import_react6.createElement)("span", { className: "bt-set-copy" }, (0, import_react6.createElement)("span", { className: "bt-bs-label" }, t("When"))), ++ (0, import_react6.createElement)(Segmented, { label: t("When"), value: kind }, KINDS.map(([id, label]) => (0, import_react6.createElement)("button", { key: id, type: "button", "aria-pressed": kind === id, onClick: () => setKind(id) }, t(label)))), ++ (0, import_react6.createElement)( ++ "div", ++ { key: kind, className: "bt-task-when bt-veil" }, ++ kind === "cron" ? (0, import_react6.createElement)("span", { className: "bt-task-every" }, timingText(task)) : null, ++ kind === "at" ? (0, import_react6.createElement)("input", { className: "bt-input", type: "datetime-local", value: at, "aria-label": t("Date and time"), onChange: (event) => setAt(event.target.value) }) : null, ++ kind === "weekly" ? (0, import_react6.createElement)("div", { className: "bt-task-days", role: "group", "aria-label": t("Days") }, [1, 2, 3, 4, 5, 6, 7].map((day) => (0, import_react6.createElement)("button", { ++ key: day, ++ type: "button", ++ className: "bt-task-day", ++ "aria-pressed": weekdays.includes(day), ++ onClick: () => toggleDay(day) ++ }, when(Date.UTC(2024, 0, day), { weekday: "narrow", timeZone: "UTC" })))) : null, ++ kind === "daily" || kind === "weekly" ? (0, import_react6.createElement)("input", { className: "bt-input bt-task-time", type: "time", value: time, "aria-label": t("Time"), onChange: (event) => setTime(event.target.value) }) : null, ++ kind === "every" ? (0, import_react6.createElement)( ++ import_react6.Fragment, ++ null, ++ (0, import_react6.createElement)("span", { className: "bt-task-every" }, t("Every")), ++ (0, import_react6.createElement)("input", { className: "bt-input bt-task-count", type: "number", min: 1, value: count, "aria-label": t("How many"), onChange: (event) => setCount(event.target.value) }), ++ (0, import_react6.createElement)( ++ "select", ++ { className: "bt-select bt-task-unit", value: unit, "aria-label": t("Unit"), onChange: (event) => setUnit(event.target.value) }, ++ UNITS.map(([id, , label]) => (0, import_react6.createElement)("option", { key: id, value: id }, t(label))) ++ ) ++ ) : null ++ ) ++ ), ++ error ? (0, import_react6.createElement)("div", { className: "bt-set-row" }, (0, import_react6.createElement)("span", { className: "bt-danger bt-set-hint", role: "alert" }, error)) : null, ++ (0, import_react6.createElement)( ++ "div", ++ { className: "bt-set-row bt-task-foot" }, ++ (0, import_react6.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: onCancel }, t("Cancel")), ++ (0, import_react6.createElement)( ++ "button", ++ { type: "submit", className: "bt-send bt-soft-sm", disabled: busy || !target || title.trim() === "" || prompt.trim() === "" }, ++ task ? busy ? t("Saving…") : t("Save changes") : busy ? t("Creating…") : t("Create task") ++ ) + ) + ); +} -+function RequestView({ bot, request, roster, actions }) { -+ return request.status === "pending" ? (0, import_react7.createElement)(SecretCard, { key: `${request.id}:${request.kind}`, bot, request, roster, actions }) : (0, import_react7.createElement)(SettledSecret, { bot, request, roster }); -+} -+function SecretToolCell({ sessionId, callId, name, done, content, roster, actions }) { -+ const bot = roster.byId[sessionId]; -+ if (!bot) return null; -+ const request = (roster.secretRequests?.[bot.id] ?? []).find((entry) => entry.callId === callId && (entry.sessionId ?? sessionId) === sessionId); -+ if (request) return (0, import_react7.createElement)("div", { className: "bt-q-inline" }, (0, import_react7.createElement)(RequestView, { bot, request, roster, actions })); -+ if (done && /already saved/.test(contentText(content))) { -+ return (0, import_react7.createElement)("div", { className: "bt-event" }, (0, import_react7.createElement)("span", { className: "bt-secret-event" }, (0, import_react7.createElement)(KeyIcon), t("{bot} is using {name}", { bot: bot.name, name: name ?? t("a saved key") }))); -+ } -+ return null; -+} -+function SecretDock({ sessionId, useRoster, useCards, useSession, actions }) { -+ const roster = useRoster((value) => value); -+ const shown = useCards?.((state) => state[sessionId]) ?? NONE; -+ const loading = useSession?.(historyLoading) === true; -+ const bot = roster.byId[sessionId]; -+ if (!bot || currentPart(bot) !== sessionId) return null; -+ const drawn = (entry) => entry.callId && entry.sessionId === sessionId && (loading || shown[entry.callId] > 0); -+ const waiting = (roster.secretRequests?.[bot.id] ?? []).filter((entry) => entry.status === "pending" && !drawn(entry)); -+ if (waiting.length === 0) return null; -+ return (0, import_react7.createElement)("div", { className: "bt-q-dock bt-secret-dock" }, waiting.map((request) => (0, import_react7.createElement)(SecretCard, { key: `${request.id}:${request.kind}`, bot, request, roster, actions }))); ++function openPlugins(actions) { ++ actions.closeSettings(); ++ actions.selectPanel("plugins"); +} -+var scopeMode = (scope) => scope === "all" ? "all" : scope.length === 0 ? "none" : "some"; -+function ScopeEditor({ secret, roster, onSave }) { -+ const [mode, setMode] = (0, import_react7.useState)(scopeMode(secret.scope)); -+ const [picked, setPicked] = (0, import_react7.useState)(secret.scope === "all" ? [] : secret.scope); -+ const toggle = (id) => setPicked((list) => list.includes(id) ? list.filter((item) => item !== id) : [...list, id]); -+ const next = mode === "all" ? "all" : mode === "none" ? [] : picked; -+ const same2 = next === "all" ? secret.scope === "all" : secret.scope !== "all" && [...next].sort().join() === [...secret.scope].sort().join(); -+ return (0, import_react7.createElement)( ++function AutomationOff({ actions }) { ++ return (0, import_react6.createElement)( + "div", -+ { className: "bt-secret-edit-block" }, -+ (0, import_react7.createElement)("div", { className: "bt-secret-edit-label" }, t("Who may use it")), -+ (0, import_react7.createElement)( -+ "div", -+ { className: "bt-secret-radios", role: "radiogroup", "aria-label": t("Who may use {name}", { name: secret.name }) }, -+ [["all", t("All my Bots")], ["some", t("Chosen Bots")], ["none", t("No Bot")]].map(([id, text2]) => (0, import_react7.createElement)( -+ "label", -+ { key: id, className: "bt-secret-radio" }, -+ (0, import_react7.createElement)("input", { type: "radio", name: `scope-${secret.name}`, checked: mode === id, onChange: () => setMode(id) }), -+ text2 -+ )) -+ ), -+ mode === "some" ? (0, import_react7.createElement)("div", { className: "bt-secret-bots" }, roster.bots.map((bot) => (0, import_react7.createElement)("button", { -+ key: bot.id, -+ type: "button", -+ className: "bt-secret-bot", -+ "aria-pressed": picked.includes(bot.id), -+ onClick: () => toggle(bot.id) -+ }, (0, import_react7.createElement)(BotAvatar, { bot, size: 20, badge: false, live: false }), bot.name, picked.includes(bot.id) ? (0, import_react7.createElement)(CheckIcon) : null))) : null, -+ (0, import_react7.createElement)( -+ "div", -+ { className: "bt-secret-row-actions" }, -+ (0, import_react7.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", disabled: same2, onClick: () => onSave(next) }, t("Save access")) -+ ) ++ { className: "bt-set-card bt-task-off" }, ++ (0, import_react6.createElement)("span", { className: "bt-task-off-mark" }, (0, import_react6.createElement)(ClockIcon)), ++ (0, import_react6.createElement)("span", { className: "bt-task-off-title" }, t("Turn on Automation tasks in DSH")), ++ (0, import_react6.createElement)("span", { className: "bt-set-hint" }, t("Switch on Automation tasks on the Plugins page.")), ++ (0, import_react6.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: () => openPlugins(actions) }, t("Open Plugins")) + ); +} -+function ValueEditor({ secret, onSave }) { -+ const [value, setValue] = (0, import_react7.useState)(""); -+ const [replaced, setReplaced] = (0, import_react7.useState)(false); -+ const long = value.trim().length >= MIN_VALUE; -+ const save = () => { -+ if (!long) return; -+ void Promise.resolve(onSave(value)).then((ok) => { -+ if (ok) { -+ setValue(""); -+ setReplaced(true); -+ } ++function QuietHoursCard({ view, actions, locked }) { ++ const quiet = view.quietHours ?? { on: false }; ++ const [busy, setBusy] = (0, import_react6.useState)(false); ++ const [error, setError] = (0, import_react6.useState)(""); ++ const [from, setFrom] = (0, import_react6.useState)(quiet.from ?? "22:00"); ++ const [to, setTo] = (0, import_react6.useState)(quiet.to ?? "07:00"); ++ const unchecked = view.quietUnchecked === true; ++ const refresh = () => { ++ void Promise.resolve(actions.schedules?.()).then((next) => { ++ if (next) publish(next); ++ }).catch(() => { + }); + }; -+ const edit = (next) => { -+ setValue(next); -+ setReplaced(false); ++ const save = (next) => { ++ if (busy) return; ++ setBusy(true); ++ setError(""); ++ const shown = next.on ? { on: true, from: next.from ?? from, to: next.to ?? to } : { on: false }; ++ void Promise.resolve(actions.setPrefs({ quietHours: next })).then(() => { ++ publish({ ...view, quietHours: shown }); ++ refresh(); ++ }, (failure) => setError(hostText(failure?.message ?? String(failure)))).finally(() => setBusy(false)); + }; -+ return (0, import_react7.createElement)( -+ "div", -+ { className: "bt-secret-edit-block" }, -+ (0, import_react7.createElement)("div", { className: "bt-secret-edit-label" }, t("Replace the value")), -+ (0, import_react7.createElement)( ++ return (0, import_react6.createElement)( ++ "section", ++ { className: "bt-set-section" }, ++ (0, import_react6.createElement)("h3", null, t("Quiet hours")), ++ (0, import_react6.createElement)( + "div", -+ { className: "bt-secret-inline" }, -+ (0, import_react7.createElement)("label", { className: "bt-q-field bt-secret-field" }, (0, import_react7.createElement)(MaskedInput, { value, onChange: edit, onEnter: save, label: t("New value of {name}", { name: secret.name }) })), -+ (0, import_react7.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", disabled: !long, onClick: save }, t("Replace")) -+ ), -+ value !== "" && !long ? (0, import_react7.createElement)("div", { className: "bt-set-hint" }, t("At least {count} characters.", { count: MIN_VALUE })) : replaced ? (0, import_react7.createElement)("div", { className: "bt-set-hint bt-secret-ok", role: "status" }, (0, import_react7.createElement)(CheckIcon), t("Replaced. Bots get the new value from their next command.")) : null -+ ); -+} -+function SecretRow({ secret, roster, actions, run, open, onToggle }) { -+ const [confirm, setConfirm] = (0, import_react7.useState)(false); -+ (0, import_react7.useEffect)(() => { -+ if (!open) setConfirm(false); -+ }, [open]); -+ const asker = secret.requestedBy ? roster.byId[secret.requestedBy]?.name ?? t("a deleted Bot") : t("you"); -+ const allowed = secret.scope === "all" ? [] : secret.scope.map((id) => roster.byId[id]).filter(Boolean); -+ const meta = [t("Asked by {name}", { name: asker }), t("added {time}", { time: when(secret.createdAt) }), secret.lastUsedAt ? t("last used {time}", { time: when(secret.lastUsedAt) }) : t("not used yet")].join(" · "); -+ return (0, import_react7.createElement)( -+ "div", -+ { className: "bt-secret-item", "data-open": open ? "" : void 0 }, -+ (0, import_react7.createElement)( -+ "button", -+ { type: "button", className: "bt-set-row bt-secret-summary", "aria-expanded": open, onClick: onToggle }, -+ (0, import_react7.createElement)( -+ "span", -+ { className: "bt-set-copy" }, -+ (0, import_react7.createElement)("span", { className: "bt-secret-title" }, (0, import_react7.createElement)(KeyIcon), (0, import_react7.createElement)("code", { className: "bt-secret-name" }, secret.name), secret.purpose ? (0, import_react7.createElement)("span", { className: "bt-secret-purpose" }, secret.purpose) : null), -+ (0, import_react7.createElement)("span", { className: "bt-set-hint" }, meta) ++ { className: "bt-set-card" }, ++ (0, import_react6.createElement)( ++ "div", ++ { className: "bt-set-row bt-bs-row" }, ++ (0, import_react6.createElement)( ++ "span", ++ { className: "bt-set-copy" }, ++ (0, import_react6.createElement)("span", { className: "bt-bs-label" }, quiet.on ? t("On, {from}–{to}", { from: quiet.from, to: quiet.to }) : t("Off")), ++ (0, import_react6.createElement)("span", { className: "bt-set-hint" }, t("A new or edited task cannot be due inside the window. It never mutes a task that already exists.")) ++ ), ++ locked ? null : (0, import_react6.createElement)("button", { ++ type: "button", ++ className: "bt-soft bt-soft-sm", ++ disabled: busy, ++ "aria-pressed": quiet.on, ++ onClick: () => save(quiet.on ? { on: false } : { on: true, from, to }) ++ }, quiet.on ? t("Turn off") : t("Turn on")) + ), -+ (0, import_react7.createElement)( -+ "span", -+ { className: "bt-set-control" }, -+ secret.scope === "all" ? (0, import_react7.createElement)("span", { className: "bt-tag" }, t("All my Bots")) : allowed.length === 0 ? (0, import_react7.createElement)("span", { className: "bt-tag" }, t("No Bot")) : (0, import_react7.createElement)("span", { className: "bt-secret-faces", title: allowed.map((bot) => bot.name).join(", ") }, allowed.slice(0, 5).map((bot) => (0, import_react7.createElement)(BotAvatar, { key: bot.id, bot, size: 20, badge: false, live: false }))) -+ ) -+ ), -+ open ? (0, import_react7.createElement)( -+ "div", -+ { className: "bt-secret-edit" }, -+ (0, import_react7.createElement)(ScopeEditor, { key: JSON.stringify(secret.scope), secret, roster, onSave: (scope) => run(actions.secretUpdate(secret.name, scope)) }), -+ (0, import_react7.createElement)(ValueEditor, { secret, onSave: (value) => run(actions.secretSet({ name: secret.name, value })) }), -+ (0, import_react7.createElement)( ++ quiet.on && !locked ? (0, import_react6.createElement)( + "div", -+ { className: "bt-secret-row-actions" }, -+ confirm ? [ -+ (0, import_react7.createElement)("span", { key: "q", className: "bt-set-hint" }, t("Delete {name}? Bots lose it at once.", { name: secret.name })), -+ (0, import_react7.createElement)("button", { key: "no", type: "button", className: "bt-soft bt-soft-sm", onClick: () => setConfirm(false) }, t("Keep")), -+ (0, import_react7.createElement)("button", { key: "yes", type: "button", className: "bt-soft bt-soft-sm bt-secret-danger", onClick: () => run(actions.secretDelete(secret.name)) }, t("Delete")) -+ ] : (0, import_react7.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm bt-secret-danger", onClick: () => setConfirm(true) }, (0, import_react7.createElement)(TrashIcon), t("Delete")) -+ ) -+ ) : null -+ ); -+} -+function AddSecret({ actions, run, onDone }) { -+ const [name, setName] = (0, import_react7.useState)(""); -+ const [purpose, setPurpose] = (0, import_react7.useState)(""); -+ const [value, setValue] = (0, import_react7.useState)(""); -+ const ready = /^[A-Za-z][\w-]{0,63}$/.test(name.trim()) && value.trim().length >= MIN_VALUE; -+ const save = () => { -+ if (!ready) return; -+ void run(actions.secretSet({ name, purpose, value, scope: "all" })).then((ok) => { -+ if (ok) { -+ setValue(""); -+ onDone(); -+ } -+ }); -+ }; -+ return (0, import_react7.createElement)( -+ "div", -+ { className: "bt-set-row bt-set-paste bt-secret-add" }, -+ (0, import_react7.createElement)("input", { className: "bt-input", "aria-label": t("Key name"), placeholder: t("NAME, e.g. GITHUB_TOKEN"), value: name, spellCheck: false, autoComplete: "off", autoFocus: true, onChange: (event) => setName(event.target.value.toUpperCase()) }), -+ (0, import_react7.createElement)("input", { className: "bt-input", "aria-label": t("What it is for"), placeholder: t("What it is for"), value: purpose, autoComplete: "off", onChange: (event) => setPurpose(event.target.value) }), -+ (0, import_react7.createElement)("label", { className: "bt-q-field bt-secret-field" }, (0, import_react7.createElement)(MaskedInput, { value, onChange: setValue, onEnter: save, label: t("Value") })), -+ (0, import_react7.createElement)( -+ "div", -+ { className: "bt-secret-row-actions" }, -+ (0, import_react7.createElement)("span", { className: "bt-set-hint" }, t("New keys are for all your Bots; change who may use it afterwards.")), -+ (0, import_react7.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: onDone }, t("Cancel")), -+ (0, import_react7.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", disabled: !ready, onClick: save }, t("Save key")) ++ { className: "bt-set-row bt-bs-row" }, ++ (0, import_react6.createElement)("span", { className: "bt-set-copy" }, (0, import_react6.createElement)("span", { className: "bt-bs-label" }, t("Window"))), ++ (0, import_react6.createElement)( ++ "div", ++ { className: "bt-task-when" }, ++ (0, import_react6.createElement)("input", { className: "bt-input bt-task-time", type: "time", value: from, "aria-label": t("Quiet hours start"), disabled: busy, onChange: (event) => setFrom(event.target.value) }), ++ (0, import_react6.createElement)("span", { className: "bt-task-every" }, "–"), ++ (0, import_react6.createElement)("input", { className: "bt-input bt-task-time", type: "time", value: to, "aria-label": t("Quiet hours end"), disabled: busy, onChange: (event) => setTo(event.target.value) }), ++ (0, import_react6.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", disabled: busy, onClick: () => save({ on: true, from, to }) }, t("Save window")) ++ ) ++ ) : null, ++ error ? (0, import_react6.createElement)("div", { className: "bt-set-row" }, (0, import_react6.createElement)("span", { className: "bt-danger bt-set-hint", role: "alert" }, error)) : null, ++ quiet.on && unchecked ? (0, import_react6.createElement)("div", { className: "bt-set-row" }, (0, import_react6.createElement)("span", { className: "bt-set-hint" }, t("A repeat or cron task can still run inside quiet hours, because DSH decides its next run. FlyBot says so here rather than claiming a silence it cannot keep."))) : null + ) + ); +} -+function SecretsPage({ roster, actions, run, Section }) { -+ const [open, setOpen] = (0, import_react7.useState)(null); -+ const [adding, setAdding] = (0, import_react7.useState)(false); -+ const secrets = roster.secrets ?? []; ++function SchedulesPage({ roster, actions, start }) { ++ const view = useSchedules(actions); ++ const [adding, setAdding] = (0, import_react6.useState)(Boolean(start)); ++ const [editing, setEditing] = (0, import_react6.useState)(null); ++ const locked = lockedWindow(roster); ++ const refused = schedulesReadFailure(view?.readFailure); ++ if (refused !== null) return (0, import_react6.createElement)("div", { className: "bt-set-hint bt-danger", role: "status" }, refused); ++ if (view === null) return (0, import_react6.createElement)("div", { className: "bt-set-hint" }, t("Loading…")); ++ if (!view.available) return (0, import_react6.createElement)(AutomationOff, { actions }); ++ const shown = (next) => { ++ publish(next); ++ setAdding(false); ++ setEditing(null); ++ }; ++ const edit = (task) => { ++ setAdding(false); ++ setEditing(task); ++ }; ++ const byChat = [ ++ ...roster.bots.map((bot) => [bot.id, (0, import_react6.createElement)(BotAvatar, { bot, size: 18, badge: false, live: false }), bot.name, view.tasks.filter((task) => task.botId === bot.id)]), ++ ...roster.rooms.map((room2) => [room2.id, (0, import_react6.createElement)(RoomAvatar, { room: room2, roster, size: 18 }), room2.name, view.tasks.filter((task) => task.botId === void 0 && task.roomId === room2.id)]) ++ ].filter(([, , , tasks]) => tasks.length > 0); + return [ -+ (0, import_react7.createElement)( -+ "p", -+ { key: "about", className: "bt-secret-about" }, -+ t("Keys your Bots asked for. Bots never see the values: they use them as "), -+ (0, import_react7.createElement)("code", null, "$DSH_SECRET_NAME"), -+ t(" in their shell, and every value is replaced by "), -+ (0, import_react7.createElement)("code", null, "[secret:NAME]"), -+ t(" before anything goes to a model.") -+ ), -+ (0, import_react7.createElement)( -+ Section, -+ { key: "list", title: t("Secrets · {count}", { count: secrets.length }) }, -+ secrets.length === 0 && !adding ? (0, import_react7.createElement)("div", { className: "bt-set-row" }, (0, import_react7.createElement)("span", { className: "bt-set-hint" }, t("No keys yet. When a Bot needs one, it shows a card in its chat."))) : null, -+ secrets.map((secret) => (0, import_react7.createElement)(SecretRow, { key: secret.name, secret, roster, actions, run, open: open === secret.name, onToggle: () => setOpen(open === secret.name ? null : secret.name) })), -+ adding ? (0, import_react7.createElement)(AddSecret, { actions, run, onDone: () => setAdding(false) }) : null -+ ), -+ adding ? null : (0, import_react7.createElement)("div", { key: "add", className: "bt-actions" }, (0, import_react7.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: () => setAdding(true) }, t("Add a key"))) ++ (0, import_react6.createElement)(QuietHoursCard, { key: "quiet", view, actions, locked }), ++ // A locked window draws no create control and opens no form (#2232): the header already says ++ // why, and a button the host refuses is the defect this page had. ++ locked ? null : adding ? (0, import_react6.createElement)(TaskForm, { key: "form", roster, actions, start, onDone: shown, onCancel: () => setAdding(false) }) : (0, import_react6.createElement)("div", { key: "add", className: "bt-actions" }, (0, import_react6.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm bt-with-icon", onClick: () => { ++ setEditing(null); ++ setAdding(true); ++ } }, (0, import_react6.createElement)(PlusIcon), t("New task"))), ++ byChat.length === 0 && !adding ? (0, import_react6.createElement)("div", { key: "none", className: "bt-set-card" }, (0, import_react6.createElement)("div", { className: "bt-set-row" }, (0, import_react6.createElement)("span", { className: "bt-set-hint" }, view.someHidden ? t("No scheduled tasks you can see. Another member's tasks stay hidden.") : t("No scheduled tasks yet.")))) : byChat.map(([id, avatar, name, tasks]) => (0, import_react6.createElement)( ++ "section", ++ { key: id, className: "bt-set-section" }, ++ (0, import_react6.createElement)("h3", { className: "bt-task-head" }, avatar, name), ++ (0, import_react6.createElement)(TaskList, { tasks, editing, roster, actions, locked, onEdit: edit, onDone: shown, onCancel: () => setEditing(null) }) ++ )) + ]; +} -+var SECRET_CSS = ` -+.bt-secret-head{align-items:center;gap:10px} -+.bt-secret-who{position:relative;flex:none;display:inline-flex} -+.bt-secret-lock{position:absolute;right:-5px;bottom:-4px;width:16px;height:16px;border-radius:50%;background:var(--bt-main);color:var(--bt-ink-2);display:inline-flex;align-items:center;justify-content:center;box-shadow:0 0 0 1.5px var(--bt-bubble)} -+.bt-secret-lock svg{width:11px;height:11px} -+.bt-secret-name{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:12px;line-height:16px;padding:1px 6px;border-radius:5px;background:var(--bt-hover);color:var(--bt-ink)} -+.bt-secret-body{display:flex;flex-direction:column;gap:8px} -+.bt-secret-field{align-items:center;min-height:36px} -+.bt-secret-input{flex:1;width:100%;min-width:0;margin:0;padding:0;border:0;outline:none;background:transparent;color:var(--bt-ink);font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:14px;line-height:20px;letter-spacing:.04em} -+.bt-secret-input[data-masked]{-webkit-text-security:disc} -+.bt-secret-input::placeholder{color:var(--bt-ink-3);font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",system-ui,sans-serif;letter-spacing:0} -+.bt-secret-scope{position:relative;display:inline-grid;grid-template-columns:1fr 1fr;align-self:flex-start;padding:2px;border-radius:999px;background:var(--bt-hover)} -+.bt-secret-glide{position:absolute;top:2px;bottom:2px;left:2px;width:calc(50% - 2px);border-radius:999px;background:var(--bt-main);box-shadow:0 1px 3px rgba(20,20,20,.08),inset 0 0 0 .5px color-mix(in srgb,var(--bt-ink) 6%,transparent);transition:${spring(SPRING_SHAPE, "transform")}} -+.bt-secret-glide[data-at="1"]{transform:translateX(100%)} -+.bt-secret-seg{position:relative;z-index:1;height:28px;padding:0 14px;border:0;border-radius:999px;background:none;color:var(--bt-ink-2);font:inherit;font-size:13px;white-space:nowrap;cursor:pointer;transition:color .16s ease} -+.bt-secret-seg[aria-checked=true]{color:var(--bt-ink);font-weight:500} -+.bt-secret-seg:focus-visible{outline:2px solid var(--bt-accent);outline-offset:-2px} -+/* A key card asks the user to act, so the process group carrying it stays open in every -+ work-details mode (same override as .bt-group-replies in styles.js). */ -+[data-step-process]:has(.bt-secret)>div:first-child{display:none!important} -+[data-step-process]:has(.bt-secret)>[data-step-process-body]{display:block!important;content-visibility:visible!important;max-height:none!important;overflow:visible!important;mask-image:none!important;scrollbar-gutter:auto!important} -+.bt-secret-ok{display:flex;align-items:center;gap:4px;margin-top:6px;animation:bt-secret-in .22s cubic-bezier(.22,1,.36,1)} -+.bt-secret-ok svg{width:13px;height:13px;color:#16a34a} -+@keyframes bt-secret-in{from{opacity:0;transform:translateY(-2px)}} -+.bt-secret-saved{display:flex;align-items:center;gap:6px;font-size:13px;line-height:18px;color:var(--bt-ink-2)} -+.bt-secret-note{font-size:12px;line-height:17px;color:var(--bt-ink-3)} -+.bt-secret-note code,.bt-secret-about code{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:11.5px} -+.bt-secret-warn{display:flex;align-items:center;gap:6px;padding:6px 10px;border-radius:8px;font-size:12px;line-height:16px;color:#b25e09;background:color-mix(in srgb,#f59e0b 12%,transparent)} -+.bt-secret-error{font-size:12px;line-height:16px;color:#e02135} -+.bt-secret .bt-q-btn:disabled{opacity:.5;cursor:default} -+.bt-secret-done .bt-q-head{align-items:center} -+.bt-secret-done .bt-q-title{font-size:13px;line-height:18px;color:var(--bt-ink-2);font-weight:500} -+.bt-secret-scope-tag{font-weight:400;color:var(--bt-ink-3)} -+.bt-secret-check{color:var(--bt-green,#22a06b);animation:bt-secret-pop ${SPRING_POP.duration}ms ${SPRING_POP.easing} backwards} -+.bt-secret-done{animation:bt-secret-settle .32s cubic-bezier(.22,1,.36,1) backwards} -+@keyframes bt-secret-pop{from{opacity:0;transform:scale(.4)}} -+@keyframes bt-secret-settle{from{opacity:.4;transform:scale(.985)}} -+.bt-secret-event{display:inline-flex;align-items:center;gap:5px} -+.bt-secret-dock{flex-direction:column;align-items:center} -+.bt-secret-dock .bt-q{max-width:min(550px,calc(100% - 32px))} -+.bt-secret-about{margin:0 0 12px;padding:0 4px;font-size:12px;line-height:17px;color:var(--bt-ink-2)} -+.bt-secret-title{display:flex;align-items:center;gap:6px;min-width:0;color:var(--bt-ink)} -+.bt-secret-title svg{flex:none;color:var(--bt-ink-2)} -+.bt-secret-purpose{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--bt-ink-2)} -+.bt-secret-faces{display:inline-flex} -+.bt-secret-faces>*+*{margin-left:-6px} -+.bt-secret-edit{display:flex;flex-direction:column;gap:14px;padding:4px 12px 14px;animation:bt-q-in ${SPRING_SHAPE.duration}ms ${SPRING_SHAPE.easing} backwards,bt-veil-in .2s ease-out backwards} -+.bt-secret-edit-block{display:flex;flex-direction:column;gap:8px} -+.bt-secret-edit-label{font-size:12px;line-height:16px;font-weight:600;color:var(--bt-ink-2)} -+.bt-secret-radios{display:flex;gap:16px;font-size:13px} -+.bt-secret-radio{display:inline-flex;align-items:center;gap:6px;cursor:pointer} -+.bt-secret-bots{display:flex;flex-wrap:wrap;gap:6px} -+.bt-secret-bot{display:inline-flex;align-items:center;gap:6px;height:30px;padding:0 10px 0 5px;border-radius:999px;border:.5px solid var(--bt-line);background:var(--bt-card);color:var(--bt-ink-2);font:inherit;font-size:12px;cursor:pointer;transition:background-color .12s ease,color .12s ease,border-color .12s ease} -+.bt-secret-bot[aria-pressed=true]{background:var(--bt-active);color:var(--bt-ink);border-color:var(--bt-line-2)} -+.bt-secret-bot svg{width:13px;height:13px} -+.bt-secret-inline{display:flex;gap:8px;align-items:center} -+.bt-secret-row-actions{display:flex;align-items:center;justify-content:flex-end;gap:8px;flex-wrap:wrap} -+.bt-secret-row-actions .bt-set-hint{flex:1} -+.bt-secret-danger{color:#e02135;display:inline-flex;align-items:center;gap:4px} -+.bt-secret-danger svg{width:13px;height:13px} -+.bt-secret-add .bt-input{height:34px} -+@media (prefers-reduced-motion:reduce){.bt-secret-glide{transition:none}.bt-secret-check,.bt-secret-done,.bt-secret-ok,.bt-secret-edit{animation:none}} -+`; -+ -+// src/client/face-seats.js -+function faceSeats(room2, roster, { left = false } = {}) { -+ const records = (room2.presentedParticipants ?? room2.participants ?? []).filter((p) => p.kind !== "human"); -+ const seat = (p) => { -+ const local = p.remote ? void 0 : roster.byId[p.id]; -+ return { -+ key: p.handle || p.id, -+ local, -+ record: p, -+ person: local ? seatOwnerName(p.ownerName, p.handle) || cleanPeerName(roster.self?.displayName, "") : ownerNameOf(p), -+ bot: local ? local.name : remoteBotName(p, "Bot"), -+ mine: Boolean(local) -+ }; -+ }; -+ const seats = records.filter((p) => p.leftSeq === void 0).map(seat); -+ for (const id of room2.members ?? []) { -+ const bot = roster.byId[id]; -+ if (!bot || seats.some((item) => item.local?.id === id)) continue; -+ seats.push({ key: id, local: bot, record: { id }, person: cleanPeerName(roster.self?.displayName, ""), bot: bot.name, mine: true }); -+ } -+ if (!left) return seats; -+ const gone = records.filter((p) => p.leftSeq !== void 0 && !seats.some((item) => item.key === (p.handle || p.id))).map((item) => ({ ...seat(item), left: true })); -+ return [...seats, ...gone]; -+} -+function faceSeatLabel(seat) { -+ if (!seat.person) return seat.bot; -+ return `${seat.bot} (${seat.person})`; -+} -+function faceBotLabel(room2, roster, bot) { -+ if (room2?.origin !== "face-to-face" || !bot) return bot?.name; -+ const seat = faceSeats(room2, roster, { left: true }).find((item) => item.local?.id === bot.id || item.record?.id !== void 0 && item.record.id === bot.id); -+ return seat ? faceSeatLabel(seat) : bot.name; -+} -+function facePersonKey(seat) { -+ if (seat.mine) return ":you"; -+ const record = seat.record ?? {}; -+ const home = String(record.ownerHome || record.home || "").trim().toLowerCase(); -+ if (home) return `home:${home}`; -+ const parts = String(record.handle || "").trim().toLowerCase().replace(/^@+/, "").split("."); -+ if (parts.length >= 3 && parts.at(-1) === "home") return `home:${parts.slice(-2).join(".")}`; -+ return `name:${(ownerNameOf(record) || seat.person || seat.key).toLowerCase()}`; -+} -+function facePeople(room2, roster) { -+ const people2 = []; -+ for (const seat of faceSeats(room2, roster)) { -+ const key = facePersonKey(seat); -+ const known = people2.find((person) => person.key === key); -+ if (known) { -+ known.seats.push(seat); -+ continue; -+ } -+ people2.push({ key, name: seat.person || (seat.mine ? cleanPeerName(roster.self?.displayName, "") : "") || t("Someone"), mine: seat.mine, seats: [seat] }); -+ } -+ return [...people2.filter((person) => person.mine), ...people2.filter((person) => !person.mine)]; -+} -+function facePersonLabel(person) { -+ return person.mine ? t("{name} (you)", { name: person.name }) : person.name; -+} -+ -+// src/client/held-reply.js -+var import_react8 = require("react"); -+var call3 = (endpoint, payload) => call(`flybot/${endpoint}`, payload); -+var Boundary; -+var boundary = () => Boundary ??= class HeldReplyBoundary extends import_react8.Component { -+ constructor(props) { -+ super(props); -+ this.state = { failed: false }; -+ } -+ static getDerivedStateFromError() { -+ return { failed: true }; -+ } -+ componentDidCatch(error) { -+ console.error(`flybot: ${this.props.id} failed`, error); -+ } -+ render() { -+ return this.state.failed ? this.props.fallback : this.props.children; -+ } -+}; -+function useInlineEntry(policy) { -+ const slots = registry(); -+ const [, setVersion] = (0, import_react8.useState)(0); -+ (0, import_react8.useEffect)(() => slots.subscribe(() => setVersion(slots.version())), [slots]); -+ return slots.entries("flybot.approvals").find((entry) => entry.inline === true && entry.policy === policy); ++function TaskList({ tasks, editing, roster, actions, locked, onEdit, onDone, onCancel, extra = [] }) { ++ const open = !locked && editing ? tasks.find((task) => task.id === editing.id && task.sessionId === editing.sessionId) : void 0; ++ const [copying, setCopying] = (0, import_react6.useState)(null); ++ const source = copying ? tasks.find((task) => task.id === copying.id && task.sessionId === copying.sessionId) : void 0; ++ const rows = tasks.filter((task) => task !== open && task !== source).map((task) => (0, import_react6.createElement)(TaskRow, { key: task.id, task, actions, roster, locked, onView: publish, onEdit, onCopy: actions.scheduleCreate ? setCopying : void 0 })); ++ return (0, import_react6.createElement)( ++ import_react6.Fragment, ++ null, ++ open ? (0, import_react6.createElement)(TaskForm, { key: `edit-${open.id}`, roster, actions, task: open, onDone, onCancel }) : null, ++ source ? (0, import_react6.createElement)(CopyRow, { key: `copy-${source.id}`, task: source, roster, actions, onView: (next) => { ++ setCopying(null); ++ onDone(next); ++ }, onCancel: () => setCopying(null) }) : null, ++ rows.length + extra.length > 0 ? (0, import_react6.createElement)("div", { className: "bt-set-card" }, ...rows, ...extra) : null ++ ); +} -+function HeldReplyActions({ held, actions }) { -+ const custom = useInlineEntry(held?.by); -+ const [busy, setBusy] = (0, import_react8.useState)(false); -+ const [error, setError] = (0, import_react8.useState)(""); -+ const waiting = isApprovalWaiting(held?.status); -+ (0, import_react8.useEffect)(() => { -+ if (!waiting) return void 0; -+ claimApprovals(`held-reply:${held.id}`, [held.id]); -+ return () => claimApprovals(`held-reply:${held.id}`, []); -+ }, [held?.id, waiting]); -+ if (!held?.id) return null; -+ const decide = async (decision) => { ++function CopyRow({ task, roster, actions, onView, onCancel }) { ++ const others = [ ++ ...roster.bots.filter((bot) => bot.id !== task.botId).map((bot) => [bot.id, bot.name]), ++ ...roster.rooms.filter((room2) => room2.id !== task.roomId).map((room2) => [room2.id, room2.name]) ++ ]; ++ const [target, setTarget] = (0, import_react6.useState)(""); ++ const [busy, setBusy] = (0, import_react6.useState)(false); ++ const [error, setError] = (0, import_react6.useState)(""); ++ const submit = (event) => { ++ event.preventDefault(); ++ if (busy || target === "") return; + setBusy(true); + setError(""); -+ try { -+ await call3("decide", { id: held.id, decision }); -+ } catch (failure) { -+ setError(failure?.message ?? String(failure)); -+ } finally { ++ void actions.scheduleCreate({ target, copyFrom: { sessionId: task.sessionId, id: task.id } }).then(onView, (failure) => { + setBusy(false); -+ } ++ setError(hostText(failure?.message ?? String(failure))); ++ }); + }; -+ if (!waiting) { -+ const failed = held.status === "failed"; -+ return (0, import_react8.createElement)( -+ "div", -+ { className: "bt-held-note", "data-state": held.status, title: failed && held.error ? held.error : void 0 }, -+ (0, import_react8.createElement)("span", null, held.status === "approved" ? t("Sent to {to}", { to: held.toName }) : held.status === "auto" ? t("Auto-sent to {to}", { to: held.toName }) : failed ? t("Not sent: delivery failed") : t("Not sent")), -+ // #968: the terminal answer comes from one predicate, not from `status === 'failed'` here and a -+ // different field elsewhere. A failure that can never succeed draws no button on any surface. -+ failed && canRetry(held) ? (0, import_react8.createElement)("button", { type: "button", className: "bt-held-retry", disabled: busy, onClick: () => decide("retry") }, t("Retry")) : null, -+ error ? (0, import_react8.createElement)("span", { className: "bt-flybot-approval-error", role: "alert" }, error) : null ++ return (0, import_react6.createElement)( ++ "form", ++ { className: "bt-set-row bt-task-copy", onSubmit: submit }, ++ (0, import_react6.createElement)("span", { className: "bt-set-copy" }, (0, import_react6.createElement)("span", { className: "bt-bs-label" }, t("Copy “{name}” to", { name: task.title }))), ++ (0, import_react6.createElement)( ++ "select", ++ { className: "bt-select bt-task-bot", value: target, "aria-label": t("Copy to"), onChange: (event) => setTarget(event.target.value) }, ++ (0, import_react6.createElement)("option", { value: "" }, t("Pick a Bot or group chat")), ++ others.map(([id, name]) => (0, import_react6.createElement)("option", { key: id, value: id }, name)) ++ ), ++ (0, import_react6.createElement)("button", { type: "submit", className: "bt-soft bt-soft-sm", disabled: busy || target === "" }, busy ? t("Copying…") : t("Copy")), ++ (0, import_react6.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: onCancel }, t("Cancel")), ++ error ? (0, import_react6.createElement)("span", { className: "bt-danger bt-set-hint", role: "alert" }, error) : null ++ ); ++} ++function BotSchedules({ bot, roster, actions }) { ++ const view = useSchedules(actions); ++ const [editing, setEditing] = (0, import_react6.useState)(null); ++ const locked = lockedWindow(roster); ++ const refused = schedulesReadFailure(view?.readFailure); ++ if (refused !== null) { ++ return (0, import_react6.createElement)( ++ "section", ++ { className: "bt-set-section" }, ++ (0, import_react6.createElement)("h3", null, t("Scheduled tasks")), ++ (0, import_react6.createElement)("div", { className: "bt-set-card" }, (0, import_react6.createElement)( ++ "div", ++ { className: "bt-set-row bt-bs-row" }, ++ (0, import_react6.createElement)("span", { className: "bt-set-copy" }, (0, import_react6.createElement)("span", { className: "bt-set-hint bt-danger", role: "status" }, refused)) ++ )) + ); + } -+ const plain = (0, import_react8.createElement)( -+ "div", -+ { className: "bt-held-card", role: "region", "aria-label": t("Waiting for your approval") }, -+ (0, import_react8.createElement)("div", { className: "bt-held-head" }, t("Waiting for your approval")), -+ (0, import_react8.createElement)("div", { className: "bt-flybot-approval-origin" }, t("{from} → {to}", { from: held.from ?? t("A Bot"), to: held.toName })), -+ (0, import_react8.createElement)( ++ if (view === null) return null; ++ const tasks = view.available ? view.tasks.filter((task) => task.botId === bot.id) : []; ++ return (0, import_react6.createElement)( ++ "section", ++ { className: "bt-set-section" }, ++ (0, import_react6.createElement)("h3", null, t("Scheduled tasks")), ++ !view.available ? (0, import_react6.createElement)( + "div", -+ { className: "bt-flybot-approval-actions" }, -+ (0, import_react8.createElement)("button", { type: "button", disabled: busy, onClick: () => decide("reject") }, t("Reject")), -+ (0, import_react8.createElement)("button", { type: "button", className: "bt-flybot-approve", disabled: busy, onClick: () => decide("approve") }, t("Approve")) -+ ), -+ error ? (0, import_react8.createElement)("div", { className: "bt-flybot-approval-error", role: "alert" }, error) : null ++ { className: "bt-set-card" }, ++ (0, import_react6.createElement)( ++ "div", ++ { className: "bt-set-row bt-bs-row" }, ++ (0, import_react6.createElement)("span", { className: "bt-set-copy" }, (0, import_react6.createElement)("span", { className: "bt-set-hint" }, t("Switch on Automation tasks on the Plugins page."))), ++ (0, import_react6.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: () => openPlugins(actions) }, t("Open Plugins")) ++ ) ++ ) : (0, import_react6.createElement)(TaskList, { ++ tasks, ++ editing, ++ roster, ++ actions, ++ locked, ++ onEdit: locked ? void 0 : setEditing, ++ onDone: (next) => { ++ publish(next); ++ setEditing(null); ++ }, ++ onCancel: () => setEditing(null), ++ extra: locked ? [] : [(0, import_react6.createElement)("button", { key: "new", type: "button", className: "bt-set-row bt-bs-row bt-gs-add", onClick: () => actions.openSettings("schedules", bot.id) }, (0, import_react6.createElement)(PlusIcon), t("New task"))] ++ }) + ); -+ return (0, import_react8.createElement)( -+ "div", -+ { className: "bt-held-actions", "data-flybot": "held-reply-actions" }, -+ custom ? (0, import_react8.createElement)(boundary(), { id: custom.id, fallback: plain }, (0, import_react8.createElement)(custom.component, { held, decide, call: call3, actions })) : plain ++} ++function SummaryLines({ tasks, actions }) { ++ const shown = tasks.slice(0, 3); ++ const hidden = tasks.length - shown.length; ++ return (0, import_react6.createElement)( ++ import_react6.Fragment, ++ null, ++ (0, import_react6.createElement)("div", { className: "bt-section-title" }, hidden > 0 ? t("Scheduled tasks · {count}", { count: tasks.length }) : t("Scheduled tasks")), ++ (0, import_react6.createElement)( ++ "div", ++ { className: "bt-task-list" }, ++ shown.map((task) => (0, import_react6.createElement)( ++ "button", ++ { key: task.id, type: "button", className: "bt-task-line", onClick: () => actions.openSettings("schedules") }, ++ (0, import_react6.createElement)(ClockIcon), ++ (0, import_react6.createElement)("span", { className: "bt-task-line-title" }, task.title), ++ (0, import_react6.createElement)("span", { className: "bt-task-line-when" }, nextText(task)) ++ )), ++ hidden > 0 ? (0, import_react6.createElement)("button", { key: "more", type: "button", className: "bt-more", onClick: () => actions.openSettings("schedules") }, t("{count} more not shown", { count: hidden })) : null ++ ) + ); +} -+ -+// src/client/turn-reasoning.js -+function turnReasoning(steps, current) { -+ const members = steps.length ? steps : [current]; -+ if (members.find((step) => stepReasoning(step) !== "")?.step !== current.step) return ""; -+ return members.filter((step) => !thinkingLive(step)).map(stepReasoning).filter(Boolean).join("\n\n"); ++function ScheduleSummary({ bot, roster, actions }) { ++ const view = useSchedules(actions); ++ if (view === null || view.readFailure !== void 0) return null; ++ const tasks = view.available ? view.tasks.filter((task) => task.botId === bot.id) : []; ++ if (lockedWindow(roster) && tasks.length === 0) return null; ++ return (0, import_react6.createElement)( ++ "div", ++ null, ++ tasks.length === 0 ? (0, import_react6.createElement)("div", { className: "bt-section-title" }, t("Scheduled tasks")) : (0, import_react6.createElement)(SummaryLines, { tasks, actions }), ++ tasks.length === 0 ? (0, import_react6.createElement)("button", { type: "button", className: "bt-more", onClick: () => actions.openSettings("schedules", bot.id) }, view.available === false ? t("Switch on Automation tasks on the Plugins page.") : view.someHidden ? t("No scheduled tasks you can see. Add one") : t("No scheduled tasks yet. Add one")) : null ++ ); +} -+var texts = (step) => step.blocks.filter((block) => block.kind === "text" && block.text.trim() !== ""); -+var thinkingLive = (step) => step.status === "running" && texts(step).length === 0 && step.blocks.some((block) => block.kind === "reasoning"); -+var stepReasoning = (step) => step.blocks.filter((block) => block.kind === "reasoning").map((block) => block.text).join("\n").trim(); -+ -+// src/client/cells.js -+var import_react9 = require("react"); -+var import_react_dom = require("react-dom"); -+var import_dsh_client_ui_primitives = require("@deepseek-ai/dsh-client-ui-primitives"); -+var mdLabels = () => ({ code: { copyLabel: t("Copy"), copiedLabel: t("Copied") }, footnotes: t("Footnotes") }); -+function Bubbles({ texts: texts2, roster, selfId, actions, streaming, sessionId, msgKey, msgKeys, useUi, lead, mentions }) { -+ const onClick = (event) => { -+ const target = mentionTarget(event); -+ if (target === null) return; -+ event.preventDefault(); -+ event.stopPropagation(); -+ actions.openSession(target); -+ }; -+ const reactions = useUi ? useUi((state) => msgKey || msgKeys ? msgKeys ? state.reactions ?? null : state.reactions?.[msgKey] ?? null : null) : null; -+ const reactKey = (index) => msgKeys ? msgKeys[index] : msgKey; -+ const reactIndex = (index) => msgKeys ? 0 : index; -+ const shown = (texts2 ?? []).filter((text2) => String(text2 ?? "").trim() !== ""); -+ if (shown.length === 0) return null; -+ return (0, import_react9.createElement)( -+ "div", -+ { className: "bt-astack", "data-lead": lead ? "" : void 0, onClickCapture: onClick }, -+ shown.map((text2, index) => { -+ const key = reactKey(index); -+ const picked = key ? (msgKeys ? reactions?.[key] : reactions)?.[reactIndex(index)] ?? [] : []; -+ const last = index === shown.length - 1; -+ const live = streaming && last; -+ return (0, import_react9.createElement)( -+ "div", -+ { key: index, className: "bt-msg" }, -+ (0, import_react9.createElement)( -+ "div", -+ { className: "bt-msg-line" }, -+ lead && last ? lead : null, -+ (0, import_react9.createElement)( -+ "div", -+ { className: "bt-bubble" }, -+ (0, import_react9.createElement)(import_dsh_client_ui_primitives.MarkdownText, { text: linkMentions(text2, roster.bots, selfId), streaming: live, labels: mdLabels(), variant: "compact", fileMentions: mentions }) -+ ), -+ live ? null : (0, import_react9.createElement)(MessageTools, { text: text2, name: roster.byId[selfId]?.name ?? t("Bot"), sessionId, react: key ? (emoji) => actions.toggleReaction(key, reactIndex(index), emoji) : void 0, actions }) -+ ), -+ picked.length ? (0, import_react9.createElement)("div", { className: "bt-reacts" }, picked.map((emoji) => (0, import_react9.createElement)("button", { -+ key: emoji, -+ type: "button", -+ className: "bt-react", -+ "aria-label": t("Remove reaction {emoji}", { emoji }), -+ onClick: () => actions.toggleReaction(key, reactIndex(index), emoji) -+ }, emoji))) : null -+ ); -+ }) -+ ); -+} -+var WAITING_OTHERS_NOTICE = "Waiting for the owner's approval"; -+var REACTIONS = ["👍", "❤️", "😂", "😮", "😢", "🙏"]; -+function leaveNoticeCoveredByClose(room2, text2) { -+ if (room2?.origin !== "face-to-face" || room2.face?.status !== "closed") return false; -+ const match = String(text2 ?? "").trim().match(/^(.+) left(?: the room)?$/); -+ if (!match) return false; -+ const who = match[1]; -+ for (const participant of room2.participants ?? []) { -+ if (participant?.remote !== true || participant.leftSeq === void 0) continue; -+ const name = String(participant.ownerName || participant.displayName || participant.name || "").trim(); -+ if (name && (who === name || who.endsWith(` (${name})`))) return true; -+ } -+ return false; ++function RoomSchedules({ room: room2, actions }) { ++ const view = useSchedules(actions); ++ if (view === null || view.readFailure !== void 0 || room2.origin === "face-to-face") return null; ++ const tasks = view.available ? view.tasks.filter((task) => task.botId === void 0 && task.roomId === room2.id) : []; ++ if (tasks.length === 0) return null; ++ return (0, import_react6.createElement)("div", null, (0, import_react6.createElement)(SummaryLines, { tasks, actions })); +} -+function useDismiss(open, close, refs) { -+ (0, import_react9.useEffect)(() => { -+ if (!open) return void 0; -+ const onDown = (event) => { -+ if (refs.some((ref) => ref.current?.contains(event.target))) return; -+ close(); -+ }; ++var SCHEDULE_CSS = ` ++.bt-task{gap:10px} ++.bt-task-copy{flex-wrap:wrap;gap:8px} ++.bt-task-copy .bt-task-bot{flex:1;min-width:140px;max-width:100%} ++.bt-task[data-done]{opacity:.6} ++.bt-task[data-busy]{opacity:.4} ++.bt-task-title{font-size:13px;line-height:18px;font-weight:500;overflow-wrap:anywhere} ++.bt-task-prompt{font-size:12px;line-height:16px;color:var(--bt-ink-2);display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden} ++.bt-task-next{flex:none;font-size:12px;line-height:16px;color:var(--bt-ink-3);font-variant-numeric:tabular-nums;white-space:nowrap} ++.bt-task-remove{display:inline-flex;align-items:center;justify-content:center;min-width:26px;height:26px;color:var(--bt-ink-3)} ++.bt-task-remove svg{width:14px;height:14px} ++.bt-task-tool{display:inline-flex;align-items:center;justify-content:center;min-width:26px;height:26px;color:var(--bt-ink-3)} ++.bt-task-tool svg{width:14px;height:14px} ++.bt-task-pause{height:26px;color:var(--bt-ink-2)} ++.bt-task-last{font-size:12px;line-height:16px;color:var(--bt-ink-3);overflow-wrap:anywhere} ++.bt-task-last[data-failed]{color:#c21d2e} ++.bt-task[data-paused] .bt-task-title,.bt-task[data-paused] .bt-task-prompt{color:var(--bt-ink-3)} ++.bt-task-remove[data-confirm]{background:rgba(224,33,53,.1);color:#e02135} ++.bt-task-head{display:flex;align-items:center;gap:6px} ++.bt-task-form{animation:bt-q-in .4s cubic-bezier(.22,1,.36,1),bt-veil-in .2s ease-out} ++.bt-task-bot{width:auto;min-width:160px;max-width:58%} ++.bt-task-when{display:flex;align-items:center;flex-wrap:wrap;gap:8px} ++.bt-task-when .bt-input{width:auto;height:32px} ++.bt-task-time{min-width:110px} ++.bt-task-count{width:72px!important} ++.bt-task-unit{width:auto;height:32px} ++.bt-task-every{font-size:13px;color:var(--bt-ink-2)} ++.bt-task-days{display:flex;gap:4px} ++.bt-task-day{width:30px;height:30px;border-radius:50%;border:1px solid var(--bt-line);background:none;color:var(--bt-ink-2);font:inherit;font-size:12px;cursor:pointer;transition:background-color .12s ease,color .12s ease,border-color .12s ease} ++.bt-task-day[aria-pressed=true]{background:var(--bt-user);border-color:var(--bt-user);color:var(--bt-user-ink)} ++.bt-task-foot{justify-content:flex-end;gap:8px} ++.bt-task-foot .bt-send{height:28px;font-size:12px} ++.bt-task-off{align-items:center;gap:8px;padding:28px 20px;text-align:center} ++.bt-task-off>*+*{border-top:0} ++.bt-task-off-mark{width:44px;height:44px;border-radius:50%;background:var(--bt-hover);display:flex;align-items:center;justify-content:center;color:var(--bt-ink-2)} ++.bt-task-off-mark svg{width:20px;height:20px} ++.bt-task-off-title{font-size:14px;line-height:20px;font-weight:600} ++.bt-task-off .bt-set-hint{max-width:380px} ++.bt-task-list{display:flex;flex-direction:column;gap:2px} ++.bt-task-line{display:flex;align-items:center;gap:8px;width:100%;min-width:0;padding:6px 8px;border:0;border-radius:8px;background:none;color:var(--bt-ink);font:inherit;font-size:13px;text-align:left;cursor:pointer;transition:background-color .12s ease} ++.bt-task-line:hover{background:var(--bt-hover)} ++.bt-task-line svg{flex:none;color:var(--bt-ink-3)} ++.bt-task-line-title{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} ++.bt-task-line-when{flex:none;font-size:12px;color:var(--bt-ink-3)} ++@media (prefers-reduced-motion:reduce){.bt-task-form{animation:none}} ++`; ++ ++// src/client/questions.js ++var import_react7 = require("react"); ++var keyLetter = (index) => String.fromCharCode(65 + index); ++var optionsOf = (question) => (question?.options ?? []).map((option) => typeof option === "string" ? { label: option } : option); ++var isTypingTarget = (node) => node instanceof HTMLInputElement || node instanceof HTMLTextAreaElement || node instanceof HTMLSelectElement || node instanceof HTMLElement && node.isContentEditable; ++function useOptionKeys(count, onPick, ownRef) { ++ const pick2 = (0, import_react7.useRef)(onPick); ++ pick2.current = onPick; ++ (0, import_react7.useEffect)(() => { ++ if (count === 0) return void 0; + const onKey = (event) => { -+ if (event.key === "Escape") { -+ event.stopPropagation(); -+ close(); ++ if (event.defaultPrevented || event.metaKey || event.ctrlKey || event.altKey || event.shiftKey || event.key.length !== 1) return; ++ if (isTypingTarget(event.target) || isTypingTarget(document.activeElement)) return; ++ if (document.querySelector('[role="dialog"], [aria-modal="true"]') !== null) return; ++ const index = event.key.toUpperCase().charCodeAt(0) - 65; ++ if (index >= 0 && index < count) { ++ event.preventDefault(); ++ pick2.current(index); ++ } else if (index === count && ownRef.current) { ++ event.preventDefault(); ++ ownRef.current.focus(); + } + }; -+ window.addEventListener("mousedown", onDown); -+ window.addEventListener("keydown", onKey, true); -+ return () => { -+ window.removeEventListener("mousedown", onDown); -+ window.removeEventListener("keydown", onKey, true); -+ }; -+ }, [open]); ++ document.addEventListener("keydown", onKey, true); ++ return () => document.removeEventListener("keydown", onKey, true); ++ }, [count, ownRef]); +} -+function MessageTools({ text: text2, name, sessionId, react, actions }) { -+ const [panel, setPanel] = (0, import_react9.useState)(null); -+ const [place, setPlace] = (0, import_react9.useState)(null); -+ const [copied, setCopied] = (0, import_react9.useState)(false); -+ const tools = (0, import_react9.useRef)(null); -+ const pop = (0, import_react9.useRef)(null); -+ const close = () => setPanel(null); -+ useDismiss(panel !== null, close, [tools, pop]); -+ const open = (kind, event) => { -+ if (panel === kind) { -+ close(); ++function QuestionView({ question, onAnswer, onDismiss, ownAnswer = false, raised = false, locked = false }) { ++ const [own, setOwn] = (0, import_react7.useState)(""); ++ const [picked, setPicked] = (0, import_react7.useState)([]); ++ const ownRef = (0, import_react7.useRef)(null); ++ const titleId = (0, import_react7.useId)(); ++ const options = optionsOf(question); ++ const multi = question.multiSelect === true; ++ const custom = own.trim(); ++ const canSubmit = multi ? picked.length > 0 || custom !== "" : custom !== ""; ++ const choose = (label) => { ++ if (multi) { ++ setPicked((current) => current.includes(label) ? current.filter((item) => item !== label) : [...current, label]); + return; + } -+ const rect = event.currentTarget.getBoundingClientRect(); -+ const below = rect.bottom + 220 < window.innerHeight; -+ setPlace(kind === "react" ? { left: rect.left + rect.width / 2, top: rect.top - 6, "--bt-origin": "bottom center" } : { left: Math.min(rect.left, window.innerWidth - 208), ...below ? { top: rect.bottom + 4 } : { bottom: window.innerHeight - rect.top + 4, "--bt-origin": "bottom left" } }); -+ setPanel(kind); -+ }; -+ const copy2 = () => { -+ void navigator.clipboard?.writeText(text2).then(() => { -+ setCopied(true); -+ setTimeout(() => setCopied(false), 1e3); -+ }); ++ onAnswer({ selected: [label] }); + }; -+ const download2 = () => { -+ const url = URL.createObjectURL(new Blob([text2], { type: "text/markdown" })); -+ const link = Object.assign(document.createElement("a"), { href: url, download: `${name}-message.md` }); -+ link.click(); -+ setTimeout(() => URL.revokeObjectURL(url), 1e3); ++ const submit = () => { ++ if (!canSubmit) return; ++ onAnswer({ selected: multi ? picked : [], ...custom ? { custom } : {} }); + }; -+ const item = (label, Icon, run) => (0, import_react9.createElement)("button", { key: label, type: "button", role: "menuitem", onClick: () => { -+ close(); -+ run(); -+ } }, (0, import_react9.createElement)(Icon), label); -+ return (0, import_react9.createElement)( -+ "div", -+ { ref: tools, className: "bt-tools", "data-open": panel !== null || void 0 }, -+ react ? (0, import_react9.createElement)("button", { type: "button", className: "bt-tool", "aria-label": t("Add reaction"), title: t("Add reaction"), "aria-expanded": panel === "react", onClick: (event) => open("react", event) }, (0, import_react9.createElement)(SmileIcon)) : null, -+ sessionId ? (0, import_react9.createElement)("button", { type: "button", className: "bt-tool", "aria-label": t("Reply"), title: t("Reply"), onClick: () => actions.quote(sessionId, text2) }, (0, import_react9.createElement)(ReplyIcon)) : null, -+ (0, import_react9.createElement)("button", { type: "button", className: "bt-tool", "aria-label": t("More message actions"), title: t("More"), "aria-haspopup": "menu", "aria-expanded": panel === "more", onClick: (event) => open("more", event) }, copied ? (0, import_react9.createElement)(CheckIcon) : (0, import_react9.createElement)(DotsIcon)), -+ panel === "react" ? (0, import_react_dom.createPortal)((0, import_react9.createElement)( ++ useOptionKeys(locked ? 0 : options.length, (index) => choose(options[index].label), ownRef); ++ const ownLetter = options.length > 0 ? keyLetter(options.length) : void 0; ++ const field = ownAnswer && !locked ? (0, import_react7.createElement)("textarea", { ++ ref: ownRef, ++ rows: 1, ++ value: own, ++ autoComplete: "off", ++ spellCheck: false, ++ className: "bt-q-input", ++ "aria-label": t("Your own answer"), ++ placeholder: t("Type your own answer"), ++ "aria-keyshortcuts": ownLetter?.toLowerCase(), ++ onChange: (event) => setOwn(event.target.value), ++ onKeyDown: (event) => { ++ if (event.key !== "Enter" || event.shiftKey || event.nativeEvent.isComposing) return; ++ event.preventDefault(); ++ submit(); ++ } ++ }) : null; ++ const rows = options.map((option, optionIndex) => { ++ const selected = multi && picked.includes(option.label); ++ const body = [ ++ (0, import_react7.createElement)("span", { className: "bt-key", "aria-hidden": true }, keyLetter(optionIndex)), ++ (0, import_react7.createElement)( ++ "span", ++ { className: "bt-q-body" }, ++ (0, import_react7.createElement)("span", { className: "bt-q-label" }, option.label), ++ option.description ? (0, import_react7.createElement)("span", { className: "bt-q-desc" }, option.description) : null ++ ), ++ selected && !raised ? (0, import_react7.createElement)("span", { className: "bt-q-check", title: t("Selected") }, (0, import_react7.createElement)(CheckIcon)) : null ++ ]; ++ return locked ? (0, import_react7.createElement)("div", { key: option.label, className: "bt-q-opt" }, ...body) : (0, import_react7.createElement)("button", { ++ key: option.label, ++ type: "button", ++ className: "bt-q-opt", ++ "aria-pressed": multi ? selected : void 0, ++ "aria-keyshortcuts": keyLetter(optionIndex).toLowerCase(), ++ onClick: () => choose(option.label) ++ }, ...body); ++ }); ++ if (raised && field) { ++ rows.push((0, import_react7.createElement)( ++ "label", ++ { key: "own", className: "bt-q-opt bt-q-ownrow", "data-filled": custom !== "" ? "" : void 0 }, ++ ownLetter ? (0, import_react7.createElement)("span", { className: "bt-key", "aria-hidden": true }, ownLetter) : null, ++ (0, import_react7.createElement)("span", { className: "bt-q-body" }, field) ++ )); ++ } ++ let foot = null; ++ if (!locked && raised && multi) { ++ foot = (0, import_react7.createElement)( + "div", -+ { ref: pop, className: "bt-react-strip", role: "menu", "aria-label": t("Reactions"), style: place }, -+ REACTIONS.map((emoji) => (0, import_react9.createElement)("button", { key: emoji, type: "button", role: "menuitem", "aria-label": emoji, onClick: () => { -+ close(); -+ react(emoji); -+ } }, emoji)) -+ ), document.body) : null, -+ panel === "more" ? (0, import_react_dom.createPortal)((0, import_react9.createElement)( ++ { className: "bt-q-foot" }, ++ (0, import_react7.createElement)("button", { type: "button", className: "bt-q-btn", onClick: onDismiss }, t("Skip")), ++ (0, import_react7.createElement)("button", { type: "submit", className: "bt-q-submit", disabled: !canSubmit }, t("Submit")) ++ ); ++ } else if (!locked && (raised || multi) && canSubmit) { ++ foot = (0, import_react7.createElement)("div", { className: "bt-q-foot" }, (0, import_react7.createElement)("button", { type: "submit", className: "bt-q-submit" }, t("Submit"))); ++ } ++ return (0, import_react7.createElement)( ++ locked ? "div" : "form", ++ { ++ className: raised ? "bt-q bt-q-raised" : "bt-q", ++ "aria-labelledby": titleId, ++ ...locked ? {} : { onSubmit: (event) => { ++ event.preventDefault(); ++ submit(); ++ } } ++ }, ++ (0, import_react7.createElement)( + "div", -+ { ref: pop, className: "bt-menu", role: "menu", "aria-label": t("Message actions"), style: place }, -+ item(t("Copy"), CopyIcon, copy2), -+ item(t("Download"), DownloadIcon, download2) -+ ), document.body) : null ++ { className: "bt-q-head" }, ++ (0, import_react7.createElement)( ++ "div", ++ { className: "bt-q-text" }, ++ (0, import_react7.createElement)("p", { className: "bt-q-title", id: titleId }, question.header ? `${question.header} · ` : "", question.question), ++ question.detail ? (0, import_react7.createElement)("div", { className: "bt-q-detail" }, question.detail) : null, ++ raised && multi ? (0, import_react7.createElement)("div", { className: "bt-q-detail" }, t("Choose all that apply")) : null ++ ), ++ locked ? null : (0, import_react7.createElement)("button", { type: "button", className: "bt-q-x", "aria-label": t("Dismiss question"), title: t("Dismiss"), onClick: onDismiss }, (0, import_react7.createElement)(CloseIcon)) ++ ), ++ rows.length > 0 ? (0, import_react7.createElement)("div", { className: "bt-q-list", role: "group" }, rows) : null, ++ field && !raised ? (0, import_react7.createElement)( ++ "div", ++ { className: "bt-q-custom" }, ++ (0, import_react7.createElement)("label", { className: "bt-q-field" }, field), ++ !multi && canSubmit ? (0, import_react7.createElement)("button", { type: "submit", className: "bt-q-submit" }, t("Submit")) : null ++ ) : null, ++ foot + ); +} -+var PEEK_MAX = 160; -+var CUT = "…"; -+var MARKUP = [/^\s{0,3}(?:`{3,}|~{3,})/, /^\s{0,3}([-*_])(?:\s*\1){2,}\s*$/]; -+var isMarkup = (line2) => MARKUP.some((pattern) => pattern.test(line2)); -+var graphemes = (line2) => typeof Intl !== "undefined" && typeof Intl.Segmenter === "function" ? [...new Intl.Segmenter().segment(line2)].map((part) => part.segment) : [...line2]; -+var clip = (line2) => { -+ if (line2.length <= PEEK_MAX) return line2; -+ let kept = ""; -+ for (const part of graphemes(line2)) { -+ if (kept.length + part.length > PEEK_MAX - CUT.length) break; -+ kept += part; ++function SettledQuestion({ question, answer }) { ++ const options = optionsOf(question); ++ const prompt = question?.question ?? ""; ++ const chosen = [...answer.selected ?? [], ...answer.custom ? [answer.custom] : []]; ++ if (chosen.length === 0) { ++ return (0, import_react7.createElement)( ++ "div", ++ { className: "bt-q bt-q-settled bt-q-dismissed", role: "group", "aria-label": prompt }, ++ (0, import_react7.createElement)( ++ "div", ++ { className: "bt-q-head" }, ++ (0, import_react7.createElement)("div", { className: "bt-q-text" }, (0, import_react7.createElement)("p", { className: "bt-q-title" }, prompt)), ++ (0, import_react7.createElement)("span", { className: "bt-q-badge" }, t("Dismissed")) ++ ) ++ ); + } -+ return kept + CUT; -+}; -+var onlyMarkup = (lines) => lines.length === 1 ? t("{n} line of reasoning", { n: lines.length }) : t("{n} lines of reasoning", { n: lines.length }); -+var peekLine = (text2) => { -+ const lines = text2.split("\n").map((part) => part.replace(/\s+/g, " ").trim()).filter((part) => part !== ""); -+ const prose = lines.find((line2) => !isMarkup(line2)); -+ return prose === void 0 ? onlyMarkup(lines) : clip(prose); -+}; -+function ThinkingRow({ text: text2 }) { -+ if (typeof text2 !== "string" || text2.trim() === "") return null; -+ const peek = peekLine(text2); -+ return (0, import_react9.createElement)( -+ "details", -+ { className: "bt-think" }, -+ (0, import_react9.createElement)( -+ "summary", -+ null, -+ (0, import_react9.createElement)("span", { className: "bt-think-caret", "aria-hidden": true }, "›"), -+ t("Thought it through"), -+ peek ? (0, import_react9.createElement)("span", { className: "bt-think-peek" }, peek) : null -+ ), -+ (0, import_react9.createElement)("div", { className: "bt-think-text" }, text2) ++ return (0, import_react7.createElement)( ++ "div", ++ { className: "bt-q bt-q-settled", role: "group", "aria-label": prompt }, ++ prompt ? (0, import_react7.createElement)("div", { className: "bt-q-head" }, (0, import_react7.createElement)("div", { className: "bt-q-text" }, (0, import_react7.createElement)("p", { className: "bt-q-title" }, prompt))) : null, ++ (0, import_react7.createElement)("div", { className: "bt-q-list", role: "group", "aria-label": t("Your answer") }, chosen.map((label, index) => { ++ const optionIndex = options.findIndex((option) => option.label === label); ++ return (0, import_react7.createElement)( ++ "div", ++ { key: `${label}-${index}`, className: "bt-q-opt" }, ++ optionIndex >= 0 ? (0, import_react7.createElement)("span", { className: "bt-key", "aria-hidden": true }, keyLetter(optionIndex)) : null, ++ (0, import_react7.createElement)("span", { className: "bt-q-body" }, (0, import_react7.createElement)("span", { className: "bt-q-label" }, label)), ++ (0, import_react7.createElement)("span", { className: "bt-q-check", title: t("Selected") }, (0, import_react7.createElement)(CheckIcon)) ++ ); ++ })) + ); +} -+function useFileMentions({ node, openFile, fileMentions, useTurnData }) { -+ const tail = useTurnData?.("turn-tail"); -+ const location2 = node.location; -+ const turn = location2?.kind === "turn" || location2?.kind === "step" ? location2.turn : void 0; -+ const seq = node.data.finalNode?.seq; -+ return (0, import_react9.useMemo)(() => { -+ if (typeof fileMentions !== "function" || turn?.status !== "closed" || seq === void 0) return void 0; -+ return tail?.closing?.finalNode.seq === seq ? fileMentions({ turn, seq, openFile }) : void 0; -+ }, [fileMentions, openFile, seq, tail, turn]); -+} -+function useClosingText(node, useTurnData) { -+ const tail = useTurnData?.("turn-tail"); -+ const seq = node.data.finalNode?.seq; -+ if (!tail?.closing || seq === void 0) return true; -+ return tail.closing.finalNode?.seq === seq; -+} -+function friendLead(bot, texts2) { -+ if (bot?.remote === void 0 || texts2.length === 0) return void 0; -+ if (texts2.every((text2) => /^Not sent:/.test(text2.trim()))) return void 0; -+ return (0, import_react9.createElement)("span", { className: "bt-lead", title: bot.name, "data-flybot": "friend-lead" }, (0, import_react9.createElement)(BotMark, { bot, size: 22 })); -+} -+function AssistantCell({ node, groupPart, sessionId, openFile, fileMentions, useTurnData, useRoster, useUi, useTurnSteps, useTurnContext, actions }) { -+ const roster = useRoster((value) => value); -+ const data = node.data; -+ const steps = useTurnSteps(String(data.turn)); -+ const contexts = useTurnContext(String(data.turn)); -+ const mentions = useFileMentions({ node, openFile, fileMentions, useTurnData }); -+ const held = groupPart === "reasoning" ? void 0 : heldReplyOf(roster.byId[sessionId], sessionId, data.turn); -+ const closing = useClosingText(node, useTurnData); -+ const hidden = Boolean(roster.roomsById[sessionId]) || isHiddenTurn(roster, sessionId, data.turn); -+ const person = typedBy(roster, sessionId, data.turn); -+ const texts2 = groupPart === "reasoning" ? [] : data.blocks.filter((block) => block.kind === "text" && block.text.trim() !== "").map((block, index) => index === 0 ? stripTrace(block.text) : block.text).filter((text2) => text2.trim() !== ""); -+ const running = data.status === "running"; -+ useLiveNote(actions, sessionId, `text:${node.key ?? data.seq}`, !hidden && running && texts2.length > 0 ? { text: true } : null); -+ if (hidden) return null; -+ const reasoning = groupPart === "response" ? "" : turnReasoning(steps, data); -+ const relays = relayPart(data, groupPart) ? relaysBefore(contexts, steps, data) : []; -+ if (texts2.length === 0 && !reasoning && relays.length === 0) return null; -+ const selfId = roster.byId[sessionId]?.id ?? sessionId; -+ const bubbles = texts2.length ? person ? (0, import_react9.createElement)(PersonMessage, { name: person, texts: texts2, roster, selfId, actions, sessionId, msgKey: `${sessionId}:${data.turn}:${node.key ?? data.seq ?? ""}`, useUi }) : (0, import_react9.createElement)(Bubbles, { texts: texts2, roster, selfId, actions, streaming: running, sessionId, msgKey: `${sessionId}:${data.turn}:${node.key ?? data.seq ?? ""}`, useUi, mentions, lead: friendLead(roster.byId[sessionId], texts2) }) : null; -+ return (0, import_react9.createElement)( -+ import_react9.Fragment, -+ null, -+ relays.map((relay) => { -+ const source = relay.source; -+ const sender = roster.byId[source.senderSessionId] ?? { id: source.senderSessionId, name: source.senderName ?? t("a Bot"), color: "gray" }; -+ return (0, import_react9.createElement)(EventLine, { -+ key: relay.seq, -+ prefix: source.role === "stopped" ? t("Stopped by") : source.role === "reply" ? t("Reply from") : t("Message from"), -+ bots: [sender], -+ onOpen: () => { -+ if (sender.id) actions.openExchange(selfId, sender.id); -+ }, -+ whole: true -+ }); -+ }), -+ reasoning ? (0, import_react9.createElement)(ThinkingRow, { text: reasoning }) : null, -+ held && bubbles ? (0, import_react9.createElement)(HeldReply, { held, roster, sessionId, actions, closing }, bubbles) : bubbles -+ ); ++function QuestionCard({ matched }) { ++ const pending = matched; ++ const questions = pending.questions ?? []; ++ const [index, setIndex] = (0, import_react7.useState)(0); ++ const [answers, setAnswers] = (0, import_react7.useState)([]); ++ const question = questions[index]; ++ if (question === void 0) return null; ++ const onAnswer = (answer) => { ++ const next = [...answers, { id: question.id, ...answer }]; ++ if (index + 1 < questions.length) { ++ setAnswers(next); ++ setIndex(index + 1); ++ return; ++ } ++ pending.answer({ answers: next }); ++ }; ++ return (0, import_react7.createElement)(QuestionView, { key: index, question, onAnswer, onDismiss: () => pending.dismiss(), ownAnswer: true, raised: true }); +} -+function HeldReply({ held, roster, sessionId, actions, closing, children }) { -+ const bot = roster.byId[sessionId]; -+ const chat = (roster.bots ?? []).find((item) => item.remote?.handle === held.to); -+ const toName = cleanPeerName(held.toOwner) || (chat ? friendBotOwner(chat) : ownerNameOf({ handle: held.to })) || held.to || t("a friend"); -+ return (0, import_react9.createElement)( ++function PendingQuestion({ sessionId, question, actions, roster, inline }) { ++ const [answered, setAnswered] = (0, import_react7.useState)(null); ++ if (answered === question.id) return null; ++ const locked = lockedWindow(roster); ++ const onAnswer = (answer) => { ++ setAnswered(question.id); ++ void actions.answerQuestion(sessionId, question.id, { selected: answer.selected ?? [], custom: answer.custom ?? "" }).catch(() => setAnswered(null)); ++ }; ++ const onDismiss = () => { ++ setAnswered(question.id); ++ void actions.dismissQuestion(sessionId); ++ }; ++ return (0, import_react7.createElement)( + "div", -+ { className: "bt-held-reply", "data-state": held.status, "data-held": isApprovalWaiting(held.status) ? "" : void 0 }, -+ children, -+ closing ? (0, import_react9.createElement)(HeldReplyActions, { held: { ...held, toName, from: bot?.name }, actions }) : null ++ { className: inline ? "bt-q-inline" : "bt-q-dock" }, ++ ...locked ? [(0, import_react7.createElement)("div", { className: "bt-hint bt-read-only", role: "status" }, hostText(roster.readOnly))] : [], ++ (0, import_react7.createElement)(QuestionView, { key: question.id, question, onAnswer, onDismiss, ownAnswer: question.allowCustom === true, raised: !inline, locked }) + ); +} -+function PersonMessage({ name, texts: texts2, roster, selfId, actions, sessionId, msgKey, useUi }) { -+ if (!(texts2 ?? []).some((text2) => String(text2 ?? "").trim() !== "")) return null; -+ const lead = (0, import_react9.createElement)("span", { className: "bt-lead", title: name }, (0, import_react9.createElement)(PersonMark, { name })); -+ return (0, import_react9.createElement)( -+ "div", -+ { className: "bt-group-msg bt-person-msg", "data-person": name }, -+ (0, import_react9.createElement)("span", { className: "bt-group-name" }, name), -+ (0, import_react9.createElement)(Bubbles, { texts: texts2, roster, selfId, actions, sessionId, msgKey, useUi, lead }) -+ ); ++function QuestionDock({ sessionId, useRoster, useCards, useSession, actions }) { ++ const roster = useRoster((value) => value); ++ const question = roster.questions?.[sessionId]; ++ const drawn = useCards?.((state) => state[sessionId]?.[question?.callId] > 0) === true; ++ const loading = useSession?.(historyLoading) === true; ++ if (question === void 0 || question.callId && (drawn || loading)) return null; ++ return (0, import_react7.createElement)(PendingQuestion, { sessionId, question, actions, roster, inline: false }); +} -+function EventLine({ prefix, bots, joiner = t("and"), onOpen, whole = false }) { -+ if (whole && bots.length === 1) { -+ return (0, import_react9.createElement)( -+ "div", -+ { className: "bt-event" }, -+ (0, import_react9.createElement)( -+ "button", -+ { type: "button", className: "bt-event-whole", title: bots[0].name, onClick: () => onOpen?.(bots[0]) }, -+ (0, import_react9.createElement)("span", null, prefix), -+ (0, import_react9.createElement)(BotMark, { bot: bots[0], size: 16 }), -+ (0, import_react9.createElement)("span", { className: "bt-event-name" }, bots[0].name) -+ ) -+ ); ++function AnsweredCell({ node }) { ++ const data = node.data ?? {}; ++ const questions = Array.isArray(data.questions) ? data.questions : []; ++ if (questions.length === 0) { ++ return data.text ? (0, import_react7.createElement)("div", { className: "bt-urow" }, (0, import_react7.createElement)("div", { className: "bt-ububble" }, data.text)) : null; + } -+ const parts = []; -+ parts.push((0, import_react9.createElement)("span", { key: "p" }, prefix)); -+ bots.forEach((bot, index) => { -+ if (index > 0) parts.push((0, import_react9.createElement)("span", { key: `j${index}` }, joiner)); -+ parts.push((0, import_react9.createElement)( -+ "button", -+ { key: bot.id ?? index, type: "button", onClick: () => onOpen?.(bot) }, -+ (0, import_react9.createElement)(BotMark, { bot, size: 16 }), -+ bot.name -+ )); -+ }); -+ return (0, import_react9.createElement)("div", { className: "bt-event" }, parts); ++ const answers = Array.isArray(data.answers) ? data.answers : []; ++ return (0, import_react7.createElement)("div", { className: "bt-q-inline" }, questions.map((question) => (0, import_react7.createElement)(SettledQuestion, { ++ key: question.id, ++ question, ++ answer: answers.find((answer) => answer.id === question.id) ?? { selected: [] } ++ }))); +} -+var heldForm = (text2) => String(text2 ?? "").replace(/\s+/g, " ").trim().replace(/^(to (the )?face[ -]?room|to the room)\s*:\s*/i, "").trim(); -+var sameName = (a, b) => String(a ?? "").replace(/\s+/g, " ").trim().toLowerCase() === String(b ?? "").replace(/\s+/g, " ").trim().toLowerCase(); -+function isHeldLine(room2, text2, bot) { -+ if (room2?.origin !== "face-to-face") return false; -+ const line2 = heldForm(text2); -+ if (!line2) return false; -+ return (room2.pendingApprovals ?? []).some((item) => { -+ if (item?.kind !== "face_room_message") return false; -+ if (bot !== void 0 && String(item.from ?? "").trim() !== "" && !sameName(item.from, bot.name)) return false; -+ const held = heldForm(item.text); -+ return held !== "" && (held === line2 || held.length >= 799 && line2.startsWith(held)); ++ ++// src/client/secret-card.js ++var import_react8 = require("react"); ++var MIN_VALUE = 4; ++var NONE = {}; ++var KeyIcon = () => (0, import_react8.createElement)( ++ "svg", ++ { width: 15, height: 15, viewBox: "0 0 20 20", fill: "none", stroke: "currentColor", strokeWidth: 1.5, strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": true }, ++ (0, import_react8.createElement)("path", { d: "M7 9.5a3.5 3.5 0 1 1 0 .01z" }), ++ (0, import_react8.createElement)("path", { d: "M10.4 10.6L17 10.6M14.5 10.6v2.4M16.6 10.6v1.8" }) ++); ++var LockIcon = () => (0, import_react8.createElement)( ++ "svg", ++ { width: 14, height: 14, viewBox: "0 0 20 20", fill: "none", stroke: "currentColor", strokeWidth: 1.5, strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": true }, ++ (0, import_react8.createElement)("path", { d: "M6.5 9V7a3.5 3.5 0 0 1 7 0v2" }), ++ (0, import_react8.createElement)("rect", { x: 4.5, y: 9, width: 11, height: 8, rx: 2 }) ++); ++var WarnIcon = () => (0, import_react8.createElement)( ++ "svg", ++ { width: 14, height: 14, viewBox: "0 0 20 20", fill: "none", stroke: "currentColor", strokeWidth: 1.5, strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": true }, ++ (0, import_react8.createElement)("path", { d: "M10 3.5l7 12.5H3z" }), ++ (0, import_react8.createElement)("path", { d: "M10 8.5v3.5M10 14.2h.01" }) ++); ++var MASK_BY_CSS = typeof CSS !== "undefined" && typeof CSS.supports === "function" && CSS.supports("-webkit-text-security", "disc"); ++var insecure = () => typeof location !== "undefined" && location.protocol === "http:" && !/^(localhost|127(?:\.\d{1,3}){3}|\[::1\])$/.test(location.hostname); ++var scopeLabel = (scope, roster) => { ++ if (scope === "all") return t("All my Bots"); ++ const names = (scope ?? []).map((id) => roster.byId[id]?.name).filter(Boolean); ++ return names.length === 0 ? t("No Bot") : names.join(t(" and ")); ++}; ++var when2 = (time) => Number.isFinite(time) ? new Date(time).toLocaleString(dateLocale(), { month: "short", day: "numeric", hour: "2-digit", minute: "2-digit" }) : ""; ++function MaskedInput({ value, onChange, onEnter, label, autoFocus = false }) { ++ return (0, import_react8.createElement)("input", { ++ className: "bt-secret-input", ++ type: MASK_BY_CSS ? "text" : "password", ++ "data-masked": MASK_BY_CSS ? "" : void 0, ++ value, ++ autoFocus, ++ autoComplete: MASK_BY_CSS ? "off" : "new-password", ++ autoCorrect: "off", ++ autoCapitalize: "off", ++ spellCheck: false, ++ "data-1p-ignore": "", ++ "data-lpignore": "true", ++ "data-bwignore": "", ++ "data-form-type": "other", ++ "aria-label": label, ++ placeholder: t("Paste or type the key"), ++ onChange: (event) => onChange(event.target.value), ++ onKeyDown: (event) => { ++ if (event.key !== "Enter" || event.nativeEvent.isComposing) return; ++ event.preventDefault(); ++ onEnter?.(); ++ } + }); +} -+var HELD_SENDING_MS = 6e4; -+function heldLineState(room2, { seq, botId, text: text2 } = {}, now = Date.now()) { -+ if (room2?.origin !== "face-to-face" || !Array.isArray(room2.heldLines)) return void 0; -+ const said = String(text2 ?? "").trim(); -+ const entry = room2.heldLines.filter((item) => seq !== void 0 && item?.seq !== void 0 ? item.seq === seq : item?.botId === botId && String(item?.text ?? "").trim() === said).at(-1); -+ if (entry === void 0) return void 0; -+ if (entry.status === "rejected") return "rejected"; -+ if (entry.status === "sending" && now - (Number(entry.at) || 0) > HELD_SENDING_MS) return void 0; -+ if (entry.status !== "pending" && entry.status !== "sending") return void 0; -+ return room2.face?.status === "left" || room2.face?.status === "closed" ? "rejected" : "held"; ++function ScopeSwitch({ value, onChange, bot }) { ++ const options = [["all", t("All my Bots")], ["bot", t("Only {name}", { name: bot.name })]]; ++ return (0, import_react8.createElement)( ++ "div", ++ { className: "bt-secret-scope", role: "radiogroup", "aria-label": t("Who may use it") }, ++ (0, import_react8.createElement)("span", { className: "bt-secret-glide", "data-at": value === "all" ? 0 : 1, "aria-hidden": true }), ++ options.map(([id, text2]) => (0, import_react8.createElement)("button", { ++ key: id, ++ type: "button", ++ role: "radio", ++ "aria-checked": value === id, ++ className: "bt-secret-seg", ++ onClick: () => onChange(id) ++ }, text2)) ++ ); +} -+function GroupMessage({ bot, text: text2, room: room2, roster, actions, msgKey, useUi, seq }) { -+ if (String(text2 ?? "").trim() === "") return null; -+ const held = heldLineState(room2, { seq, botId: bot.id, text: text2 }); -+ if (held === "rejected") { -+ return (0, import_react9.createElement)( ++function SecretHead({ bot, title, detail, name, onClose }) { ++ return (0, import_react8.createElement)( ++ "div", ++ { className: "bt-q-head bt-secret-head" }, ++ (0, import_react8.createElement)("span", { className: "bt-secret-who" }, (0, import_react8.createElement)(BotMark, { bot, size: 28 }), (0, import_react8.createElement)("span", { className: "bt-secret-lock" }, (0, import_react8.createElement)(LockIcon))), ++ (0, import_react8.createElement)( + "div", -+ { className: "bt-event bt-not-sent", "data-flybot": "held-not-sent", role: "status" }, -+ (0, import_react9.createElement)(BotMark, { bot, size: 16 }), -+ (0, import_react9.createElement)("span", null, t("{name} · Not sent", { name: bot.name })) -+ ); -+ } -+ if (held === "held" || isHeldLine(room2, text2, bot)) return null; -+ const lead = (0, import_react9.createElement)( -+ "button", -+ { type: "button", className: "bt-lead", "aria-label": t("Open {name}'s chat", { name: bot.name }), onClick: () => actions.openSession(bot.id) }, -+ (0, import_react9.createElement)(BotMark, { bot, size: 22 }) ++ { className: "bt-q-text" }, ++ (0, import_react8.createElement)("p", { className: "bt-q-title" }, title), ++ (0, import_react8.createElement)("div", { className: "bt-q-detail" }, (0, import_react8.createElement)("code", { className: "bt-secret-name" }, name), detail ? ` · ${detail}` : "") ++ ), ++ onClose ? (0, import_react8.createElement)("button", { type: "button", className: "bt-q-x", "aria-label": t("Cancel the request"), title: t("Cancel"), onClick: onClose }, (0, import_react8.createElement)(CloseIcon)) : null + ); -+ const name = (0, import_react9.createElement)( -+ "span", -+ { className: "bt-group-name", style: { color: inkText(colorOf(bot)) } }, -+ faceBotLabel(room2, roster, bot) ?? bot.name, -+ room2?.admin === bot.id ? (0, import_react9.createElement)("span", { className: "bt-group-role" }, t("Admin")) : null ++} ++function SecretCard({ bot, request, roster, actions }) { ++ const [value, setValue] = (0, import_react8.useState)(""); ++ const [scope, setScope] = (0, import_react8.useState)("all"); ++ const [replacing, setReplacing] = (0, import_react8.useState)(false); ++ const [busy, setBusy] = (0, import_react8.useState)(false); ++ const [error, setError] = (0, import_react8.useState)(""); ++ const noteId = (0, import_react8.useId)(); ++ const secret = (roster.secrets ?? []).find((entry) => entry.name === request.name); ++ const run = (promise) => { ++ setBusy(true); ++ setError(""); ++ return Promise.resolve(promise).then(() => setValue(""), (failure) => setError(failure?.message ?? String(failure))).finally(() => setBusy(false)); ++ }; ++ const cancel = () => run(actions.secretCancel(request.id)); ++ const long = value.trim().length >= MIN_VALUE; ++ const save = () => { ++ if (long && !busy) void run(actions.secretSet({ requestId: request.id, value, scope })); ++ }; ++ const allow = () => { ++ if (!busy) void run(actions.secretAllow(request.id)); ++ }; ++ const filling = request.kind === "fill" || replacing; ++ const warn = insecure() ? (0, import_react8.createElement)("div", { className: "bt-secret-warn", role: "note" }, (0, import_react8.createElement)(WarnIcon), t("This page is not on HTTPS: the key crosses the network unencrypted.")) : null; ++ return (0, import_react8.createElement)( ++ "div", ++ { className: "bt-q bt-secret", role: "group", "aria-describedby": noteId, "data-kind": request.kind }, ++ (0, import_react8.createElement)(SecretHead, { ++ bot, ++ title: request.kind === "allow" ? t("Let {bot} use {name}?", { bot: bot.name, name: request.name }) : t("{name} needs a key", { name: bot.name }), ++ detail: request.purpose, ++ name: request.name, ++ onClose: busy ? void 0 : cancel ++ }), ++ request.kind === "allow" ? (0, import_react8.createElement)("div", { className: "bt-secret-saved" }, (0, import_react8.createElement)(KeyIcon), t("Saved for {scope}", { scope: scopeLabel(secret?.scope, roster) })) : null, ++ filling ? (0, import_react8.createElement)( ++ "div", ++ { className: "bt-secret-body" }, ++ (0, import_react8.createElement)("label", { className: "bt-q-field bt-secret-field" }, (0, import_react8.createElement)(MaskedInput, { value, onChange: setValue, onEnter: save, label: t("Value of {name}", { name: request.name }), autoFocus: replacing })), ++ request.kind === "fill" ? (0, import_react8.createElement)(ScopeSwitch, { value: scope, onChange: setScope, bot }) : null ++ ) : null, ++ (0, import_react8.createElement)( ++ "div", ++ { className: "bt-secret-note", id: noteId }, ++ t("{bot} never sees the value. It reaches {bot}'s shell as ", { bot: bot.name }), ++ (0, import_react8.createElement)("code", null, `$DSH_SECRET_${request.name}`), ++ ".", ++ filling && value !== "" && !long ? t(" At least {count} characters.", { count: MIN_VALUE }) : "" ++ ), ++ warn, ++ error ? (0, import_react8.createElement)("div", { className: "bt-secret-error", role: "alert" }, error) : null, ++ (0, import_react8.createElement)( ++ "div", ++ { className: "bt-q-foot" }, ++ request.kind === "allow" && !replacing ? (0, import_react8.createElement)("button", { type: "button", className: "bt-q-btn", disabled: busy, onClick: () => setReplacing(true) }, t("Replace value")) : null, ++ (0, import_react8.createElement)("button", { type: "button", className: "bt-q-btn", disabled: busy, onClick: cancel }, t("Cancel")), ++ filling ? (0, import_react8.createElement)("button", { type: "button", className: "bt-q-submit", disabled: !long || busy, onClick: save }, busy ? t("Saving…") : t("Save")) : (0, import_react8.createElement)("button", { type: "button", className: "bt-q-submit", disabled: busy, onClick: allow }, busy ? t("Allowing…") : t("Allow")) ++ ) + ); -+ return (0, import_react9.createElement)( ++} ++function SettledSecret({ bot, request, roster }) { ++ const canceled = request.status === "canceled"; ++ const text2 = canceled ? t("{bot} asked for {name}", { bot: bot.name, name: request.name }) : request.status === "allowed" ? t("Allowed {bot} to use {name}", { bot: bot.name, name: request.name }) : t("Saved {name}", { name: request.name }); ++ return (0, import_react8.createElement)( + "div", -+ { className: "bt-group-msg" }, -+ name, -+ (0, import_react9.createElement)(Bubbles, { texts: [text2], roster, selfId: bot.id, actions, sessionId: room2?.id, msgKey, useUi, lead }) ++ { className: "bt-q bt-q-settled bt-secret bt-secret-done", role: "group", "aria-label": text2, "data-status": request.status }, ++ (0, import_react8.createElement)( ++ "div", ++ { className: "bt-q-head" }, ++ (0, import_react8.createElement)("span", { className: "bt-secret-who" }, (0, import_react8.createElement)(BotMark, { bot, size: 22 })), ++ (0, import_react8.createElement)("div", { className: "bt-q-text" }, (0, import_react8.createElement)( ++ "p", ++ { className: "bt-q-title" }, ++ text2, ++ canceled ? null : (0, import_react8.createElement)("span", { className: "bt-secret-scope-tag" }, ` · ${scopeLabel(request.scope, roster)}`) ++ )), ++ canceled ? (0, import_react8.createElement)("span", { className: "bt-q-badge" }, t("Canceled")) : (0, import_react8.createElement)("span", { className: "bt-q-check bt-secret-check", title: t("Done") }, (0, import_react8.createElement)(CheckIcon)) ++ ) + ); +} -+function remoteSpeaker(source, roster, sessionId) { -+ const handle = source.remote.handle; -+ const member = (roster.remoteBots ?? []).find((bot2) => bot2.handle === handle); -+ const chat = (roster.bots ?? []).find((bot2) => bot2.remote?.handle === handle); -+ const room2 = roster.roomsById?.[sessionId]; -+ const seat = faceSeatLook(room2, handle); -+ const faceSeat = room2?.origin === "face-to-face" ? faceSeats(room2, roster, { left: true }).find((item) => !item.mine && normRemoteHandle(item.record?.handle) === normRemoteHandle(handle)) : void 0; -+ const name = faceSeat ? faceSeatLabel(faceSeat) : remoteBotTitle({ -+ displayName: chat?.name ?? member?.name ?? source.senderName ?? handle, -+ ownerName: member?.homeName || member?.owner || chat?.remote?.homeName || chat?.remote?.owner, -+ handle, -+ isChief: member?.isChief ?? chat?.remote?.isChief -+ }); -+ const bot = seat ? lookBotForRemote({ handle, displayName: name, ...seat }, roster) : chat ?? lookBotForRemote({ -+ handle, -+ displayName: name, -+ color: member?.color, -+ avatar: member?.avatar ?? (member?.shape ? { shape: member.shape } : void 0) -+ }, roster); -+ const carrier = cleanCarrier(chat?.name ?? member?.name); -+ return { bot, member, name, handle, carrier, chatId: chat ? currentPart(chat) : void 0 }; ++function RequestView({ bot, request, roster, actions }) { ++ return request.status === "pending" ? (0, import_react8.createElement)(SecretCard, { key: `${request.id}:${request.kind}`, bot, request, roster, actions }) : (0, import_react8.createElement)(SettledSecret, { bot, request, roster }); +} -+function faceSeatLook(room2, handle) { -+ if (room2?.origin !== "face-to-face" || !handle) return void 0; -+ const seats = (room2.participants ?? []).filter((p) => p?.remote && (p.handle === handle || p.id === handle)); -+ const seat = seats.find((p) => p.leftSeq === void 0) ?? seats.at(-1); -+ const look = {}; -+ if (typeof seat?.color === "string" && seat.color) look.color = seat.color; -+ if (seat?.avatar && typeof seat.avatar === "object") look.avatar = seat.avatar; -+ return Object.keys(look).length ? look : void 0; ++function SecretToolCell({ sessionId, callId, name, done, content, roster, actions }) { ++ const bot = roster.byId[sessionId]; ++ if (!bot) return null; ++ const request = (roster.secretRequests?.[bot.id] ?? []).find((entry) => entry.callId === callId && (entry.sessionId ?? sessionId) === sessionId); ++ if (request) return (0, import_react8.createElement)("div", { className: "bt-q-inline" }, (0, import_react8.createElement)(RequestView, { bot, request, roster, actions })); ++ if (done && /already saved/.test(contentText(content))) { ++ return (0, import_react8.createElement)("div", { className: "bt-event" }, (0, import_react8.createElement)("span", { className: "bt-secret-event" }, (0, import_react8.createElement)(KeyIcon), t("{bot} is using {name}", { bot: bot.name, name: name ?? t("a saved key") }))); ++ } ++ return null; +} -+var cleanCarrier = (value) => typeof value === "string" && value.trim() && !/[@()]/.test(value) ? value.trim() : ""; -+function RemoteMessage({ source, text: text2, roster, actions, sessionId, msgKey, useUi }) { -+ const room2 = roster.roomsById?.[sessionId]; -+ const face = room2?.origin === "face-to-face"; -+ const { bot, member, name, handle, carrier, chatId } = remoteSpeaker(source, roster, sessionId); -+ const files = Array.isArray(source.attachments) ? source.attachments : []; -+ const words = stripTrace(text2); -+ if (String(words ?? "").trim() === "" && files.length === 0) return null; -+ const person = typeof source.remote.typedBy === "string" ? source.remote.typedBy : ""; -+ if (person) { -+ return (0, import_react9.createElement)( ++function SecretDock({ sessionId, useRoster, useCards, useSession, actions }) { ++ const roster = useRoster((value) => value); ++ const shown = useCards?.((state) => state[sessionId]) ?? NONE; ++ const loading = useSession?.(historyLoading) === true; ++ const bot = roster.byId[sessionId]; ++ if (!bot || currentPart(bot) !== sessionId) return null; ++ const drawn = (entry) => entry.callId && entry.sessionId === sessionId && (loading || shown[entry.callId] > 0); ++ const waiting = (roster.secretRequests?.[bot.id] ?? []).filter((entry) => entry.status === "pending" && !drawn(entry)); ++ if (waiting.length === 0) return null; ++ return (0, import_react8.createElement)("div", { className: "bt-q-dock bt-secret-dock" }, waiting.map((request) => (0, import_react8.createElement)(SecretCard, { key: `${request.id}:${request.kind}`, bot, request, roster, actions }))); ++} ++var scopeMode = (scope) => scope === "all" ? "all" : scope.length === 0 ? "none" : "some"; ++function ScopeEditor({ secret, roster, onSave }) { ++ const [mode, setMode] = (0, import_react8.useState)(scopeMode(secret.scope)); ++ const [picked, setPicked] = (0, import_react8.useState)(secret.scope === "all" ? [] : secret.scope); ++ const toggle = (id) => setPicked((list) => list.includes(id) ? list.filter((item) => item !== id) : [...list, id]); ++ const next = mode === "all" ? "all" : mode === "none" ? [] : picked; ++ const same2 = next === "all" ? secret.scope === "all" : secret.scope !== "all" && [...next].sort().join() === [...secret.scope].sort().join(); ++ return (0, import_react8.createElement)( ++ "div", ++ { className: "bt-secret-edit-block" }, ++ (0, import_react8.createElement)("div", { className: "bt-secret-edit-label" }, t("Who may use it")), ++ (0, import_react8.createElement)( + "div", -+ { className: "bt-group-msg bt-person-msg bt-remote-msg", "data-person": person, "data-remote": handle }, -+ // One short header: the person, and the Bot that carried the line only when it is known (#275). -+ (0, import_react9.createElement)( ++ { className: "bt-secret-radios", role: "radiogroup", "aria-label": t("Who may use {name}", { name: secret.name }) }, ++ [["all", t("All my Bots")], ["some", t("Chosen Bots")], ["none", t("No Bot")]].map(([id, text2]) => (0, import_react8.createElement)( ++ "label", ++ { key: id, className: "bt-secret-radio" }, ++ (0, import_react8.createElement)("input", { type: "radio", name: `scope-${secret.name}`, checked: mode === id, onChange: () => setMode(id) }), ++ text2 ++ )) ++ ), ++ mode === "some" ? (0, import_react8.createElement)("div", { className: "bt-secret-bots" }, roster.bots.map((bot) => (0, import_react8.createElement)("button", { ++ key: bot.id, ++ type: "button", ++ className: "bt-secret-bot", ++ "aria-pressed": picked.includes(bot.id), ++ onClick: () => toggle(bot.id) ++ }, (0, import_react8.createElement)(BotAvatar, { bot, size: 20, badge: false, live: false }), bot.name, picked.includes(bot.id) ? (0, import_react8.createElement)(CheckIcon) : null))) : null, ++ (0, import_react8.createElement)( ++ "div", ++ { className: "bt-secret-row-actions" }, ++ (0, import_react8.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", disabled: same2, onClick: () => onSave(next) }, t("Save access")) ++ ) ++ ); ++} ++function ValueEditor({ secret, onSave }) { ++ const [value, setValue] = (0, import_react8.useState)(""); ++ const [replaced, setReplaced] = (0, import_react8.useState)(false); ++ const long = value.trim().length >= MIN_VALUE; ++ const save = () => { ++ if (!long) return; ++ void Promise.resolve(onSave(value)).then((ok) => { ++ if (ok) { ++ setValue(""); ++ setReplaced(true); ++ } ++ }); ++ }; ++ const edit = (next) => { ++ setValue(next); ++ setReplaced(false); ++ }; ++ return (0, import_react8.createElement)( ++ "div", ++ { className: "bt-secret-edit-block" }, ++ (0, import_react8.createElement)("div", { className: "bt-secret-edit-label" }, t("Replace the value")), ++ (0, import_react8.createElement)( ++ "div", ++ { className: "bt-secret-inline" }, ++ (0, import_react8.createElement)("label", { className: "bt-q-field bt-secret-field" }, (0, import_react8.createElement)(MaskedInput, { value, onChange: edit, onEnter: save, label: t("New value of {name}", { name: secret.name }) })), ++ (0, import_react8.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", disabled: !long, onClick: save }, t("Replace")) ++ ), ++ value !== "" && !long ? (0, import_react8.createElement)("div", { className: "bt-set-hint" }, t("At least {count} characters.", { count: MIN_VALUE })) : replaced ? (0, import_react8.createElement)("div", { className: "bt-set-hint bt-secret-ok", role: "status" }, (0, import_react8.createElement)(CheckIcon), t("Replaced. Bots get the new value from their next command.")) : null ++ ); ++} ++function SecretRow({ secret, roster, actions, run, open, onToggle }) { ++ const [confirm, setConfirm] = (0, import_react8.useState)(false); ++ (0, import_react8.useEffect)(() => { ++ if (!open) setConfirm(false); ++ }, [open]); ++ const asker = secret.requestedBy ? roster.byId[secret.requestedBy]?.name ?? t("a deleted Bot") : t("you"); ++ const allowed = secret.scope === "all" ? [] : secret.scope.map((id) => roster.byId[id]).filter(Boolean); ++ const meta = [t("Asked by {name}", { name: asker }), t("added {time}", { time: when2(secret.createdAt) }), secret.lastUsedAt ? t("last used {time}", { time: when2(secret.lastUsedAt) }) : t("not used yet")].join(" · "); ++ return (0, import_react8.createElement)( ++ "div", ++ { className: "bt-secret-item", "data-open": open ? "" : void 0 }, ++ (0, import_react8.createElement)( ++ "button", ++ { type: "button", className: "bt-set-row bt-secret-summary", "aria-expanded": open, onClick: onToggle }, ++ (0, import_react8.createElement)( + "span", -+ { className: "bt-group-name", title: handle }, -+ person, -+ carrier ? (0, import_react9.createElement)("span", { className: "bt-remote-handle" }, t("via {name}", { name: carrier })) : null ++ { className: "bt-set-copy" }, ++ (0, import_react8.createElement)("span", { className: "bt-secret-title" }, (0, import_react8.createElement)(KeyIcon), (0, import_react8.createElement)("code", { className: "bt-secret-name" }, secret.name), secret.purpose ? (0, import_react8.createElement)("span", { className: "bt-secret-purpose" }, secret.purpose) : null), ++ (0, import_react8.createElement)("span", { className: "bt-set-hint" }, meta) + ), -+ String(words ?? "").trim() === "" ? null : (0, import_react9.createElement)(Bubbles, { texts: [words], roster, selfId: bot.id, actions, sessionId, msgKey, useUi, lead: (0, import_react9.createElement)("span", { className: "bt-lead", title: person }, (0, import_react9.createElement)(PersonMark, { name: person })) }), -+ files.length ? (0, import_react9.createElement)(RemoteFiles, { files, plugin: member?.plugin ?? "social", botId: roster.byId[sessionId]?.id ?? sessionId }) : null -+ ); -+ } -+ const mark = (0, import_react9.createElement)(BotMark, { bot, size: 22 }); -+ const lead = chatId ? (0, import_react9.createElement)("button", { type: "button", className: "bt-lead", title: handle, "aria-label": t("Open {name}'s chat", { name }), onClick: () => actions.openSession(chatId) }, mark) : (0, import_react9.createElement)("span", { className: "bt-lead", title: handle }, mark); -+ return (0, import_react9.createElement)( -+ "div", -+ { className: "bt-group-msg bt-remote-msg", "data-remote": handle }, -+ // A face room's header is the one name alone; the handle is on hover only (#457). -+ face ? (0, import_react9.createElement)("span", { className: "bt-group-name", style: { color: inkText(colorOf(bot)) }, title: handle }, name) : (0, import_react9.createElement)( -+ "span", -+ { className: "bt-group-name", style: { color: inkText(colorOf(bot)) } }, -+ name, -+ (0, import_react9.createElement)("span", { className: "bt-remote-handle", title: source.remote.home ?? handle, "aria-label": t("Friend's Bot") }, (0, import_react9.createElement)(GlobeMark), handle) ++ (0, import_react8.createElement)( ++ "span", ++ { className: "bt-set-control" }, ++ secret.scope === "all" ? (0, import_react8.createElement)("span", { className: "bt-tag" }, t("All my Bots")) : allowed.length === 0 ? (0, import_react8.createElement)("span", { className: "bt-tag" }, t("No Bot")) : (0, import_react8.createElement)("span", { className: "bt-secret-faces", title: allowed.map((bot) => bot.name).join(", ") }, allowed.slice(0, 5).map((bot) => (0, import_react8.createElement)(BotAvatar, { key: bot.id, bot, size: 20, badge: false, live: false }))) ++ ) + ), -+ String(words ?? "").trim() === "" ? null : (0, import_react9.createElement)(Bubbles, { texts: [words], roster, selfId: bot.id, actions, sessionId, msgKey, useUi, lead }), -+ files.length ? (0, import_react9.createElement)(RemoteFiles, { files, plugin: member?.plugin ?? "social", botId: roster.byId[sessionId]?.id ?? sessionId }) : null -+ ); - } --@keyframes bt-fade-in{from{opacity:0}to{opacity:1}} --@keyframes bt-fade-out{from{opacity:1}to{opacity:0}} --[data-composer-card]{display:flex!important;flex-direction:row;flex-wrap:wrap;align-items:center;gap:0!important;min-height:44px;border-radius:22px!important;padding:7.5px!important;box-sizing:border-box;background:var(--bt-main)!important;border:.5px solid var(--bt-line-2)!important;box-shadow:0 2px 8px -1px rgba(0,0,0,.05),0 1px 2px rgba(0,0,0,.03),0 0 0 1px rgba(228,228,228,.04)!important;transition:border-color .15s ease,background-color .15s ease,${spring(SPRING_SHAPE, "border-radius")}!important} --[data-composer-card]:hover,[data-composer-card]:focus-within{border-color:color-mix(in srgb,var(--bt-ink) 30%,transparent)!important} --[data-composer-card]:has(> [data-slot="conversation.input.attachments"] *),[data-composer-card]:has([data-composer-input] br + *),[data-composer-card]:has([data-composer-input] > :nth-child(2)){border-radius:18px!important} --[class*="_root"]:has(> [data-composer-card]){padding:0 11px 13px 16px!important} --[class*="_root"]:has(> [data-composer-card])>[data-composer-card]{max-width:min(992px,100%)!important} --[data-composer-card]>[class*="_row"]{display:contents!important} --[data-composer-card] [class*="_tools"]{order:1;flex:none;align-self:flex-end;margin:0} --[data-composer-card]>[data-input-scroll]{order:2;flex:1 1 0;min-width:0;padding:0 8px!important;margin:0!important} --[data-composer-card] [class*="_trailing"]{order:3;flex:none;align-self:flex-end;margin:0} --[data-composer-card]>[data-slot="conversation.input.attachments"]{order:0;flex-basis:100%} --[data-composer-card] [data-composer-input]{min-height:24px;padding:2px 0!important} --[data-composer-card] [data-composer-placeholder]{left:0!important;top:2px!important} --[data-composer-placeholder]{font-size:0!important;line-height:0!important} --[data-composer-placeholder]::after{content:var(--bt-placeholder,"Message");display:block;font-size:14px;line-height:24px;color:color-mix(in srgb,var(--bt-ink) 36%,transparent)} --/* The team's "+" (ComposerPlus) takes the seat of the shell's command-menu button. */ --[data-composer-card] button[class*="_add"]{display:none!important} --.bt-plus{flex:none;width:28px;height:28px;padding:0;border:0;border-radius:50%;display:inline-flex;align-items:center;justify-content:center;background:color-mix(in srgb,var(--bt-ink) 5%,transparent);box-shadow:inset 0 0 0 .5px var(--bt-line);color:var(--bt-ink-2);cursor:pointer;transition:background-color .15s ease,color .15s ease,${spring(SPRING_TAP, "scale")}} --.bt-plus:hover,.bt-plus[aria-expanded=true]{background:color-mix(in srgb,var(--bt-ink) 10%,transparent);color:var(--bt-ink)} --.bt-plus:active{scale:.9} --.bt-plus svg{width:14px;height:14px;transition:${spring(SPRING_POP, "rotate")}} --.bt-plus[aria-expanded=true] svg{rotate:45deg} --.bt-plus-menu{min-width:180px} --[data-composer-card] [class*="_tools"]{gap:0!important} --[data-composer-card] [class*="_modes"]:empty{display:none!important} --[data-composer-card] [class*="_activity"]:not(:has([data-slot] > *)){display:none!important} --[data-composer-card] [class*="_trailing"]{gap:8px} --[data-composer-card] [class*="_standardControls"]{display:flex;align-items:center;gap:8px} --[data-composer-card] button[class*="_primary"]{width:28px!important;height:28px!important;background:var(--bt-accent)!important;color:var(--bt-accent-ink,#fff)!important;border-radius:50%!important;transition:opacity .15s ease,${spring(SPRING_TAP, "scale")};animation:bt-icon-swap ${SPRING_POP.duration}ms ${SPRING_POP.easing}} --[data-composer-card] button[class*="_primary"]:hover:not(:disabled){opacity:.9} --[data-composer-card] button[class*="_primary"]:active:not(:disabled){scale:.9} --@keyframes bt-icon-swap{from{opacity:0;transform:scale(.5) rotate(-30deg)}} --[data-composer-card] button[class*="_primary"] svg{width:14px;height:14px} --[data-composer-card] button[class*="_primary"]:disabled{display:none!important} --[data-composer-dock]{display:none!important} --.bt-mic,.bt-voice{flex:none;width:28px;height:28px;border-radius:50%;border:0;padding:0;display:inline-grid;place-items:center;cursor:pointer;transition:background-color .15s ease,color .15s ease,${spring(SPRING_TAP, "scale")}} --.bt-mic{background:color-mix(in srgb,var(--bt-ink) 5%,transparent);box-shadow:inset 0 0 0 .5px var(--bt-line);color:var(--bt-ink-2)} --.bt-mic:hover{background:color-mix(in srgb,var(--bt-ink) 10%,transparent);color:var(--bt-ink)} --.bt-mic[aria-pressed=true]{background:#ff3e51;color:#fff;animation:bt-mic-pulse 1.4s ease-in-out infinite} --@keyframes bt-mic-pulse{0%,100%{box-shadow:0 0 0 0 rgba(255,62,81,.35)}50%{box-shadow:0 0 0 5px rgba(255,62,81,0)}} --.bt-voice{display:none;background:var(--bt-accent);color:var(--bt-accent-ink,#fff);animation:bt-icon-swap ${SPRING_POP.duration}ms ${SPRING_POP.easing}} --.bt-voice:hover{opacity:.9} --.bt-mic:active,.bt-voice:active{scale:.9} --[data-composer-card]:has(button[class*="_primary"]:disabled) .bt-voice{display:inline-grid} --body[data-bt-room] .bt-voice{display:none!important} --body[data-bt-room] [data-composer-card]:has(button[class*="_primary"]:disabled) .bt-mic:not([aria-pressed=true]){background:var(--bt-accent);color:var(--bt-accent-ink,#fff)} --.bt-mic-note{position:absolute;right:12px;bottom:calc(100% + 8px);padding:5px 10px;border-radius:8px;background:var(--bt-user);color:var(--bt-user-ink);font-size:12px;line-height:16px;white-space:nowrap;pointer-events:none;animation:bt-rise .16s ease-out} --[data-chain-overlay-fallback="conversation.composer"]:has(~ .bt-q){display:block!important;order:2} --.bt-q{order:1} --div:has(> nav [class*="_marks"]){display:none!important} --/* Every user-side bubble (sent, steering mid-turn, not yet admitted, question replies) -- takes its surface and its text from the one --bt-user pair: a skin that repaints -- the host's bubble surface alone would otherwise leave the text unreadable. */ --[class*="_userRow"] [class*="_bubble"],[data-chat-flow-kind=question-reply] [class*="_bubble"],[data-chat-flow-kind=command-input] [class*="_bubble"]{background:var(--bt-user)!important;color:var(--bt-user-ink)!important;border-radius:18px!important;padding:7px 12px!important;font-size:var(--dsh-content-font-size,14px)!important;line-height:calc(var(--dsh-content-font-size,14px) + 8px)!important} --[class*="_userRow"] [class*="_bubble"] [class*="_markdown"],[class*="_userRow"] [class*="_bubble"] a{color:inherit} --/* Sent messages get the hover toolbar below; steering and unsent bubbles keep none. */ --[class*="_userRow"] [class*="_actions"]{display:none!important} --[data-chat-flow-kind=user] [class*="_userRow"]{flex-direction:row-reverse;align-items:center;justify-content:flex-start} --[data-chat-flow-kind=user] [class*="_actions"]{display:flex!important;height:24px!important;gap:2px!important;opacity:0!important;visibility:hidden;transition:opacity .12s ease,visibility .12s!important;flex:none} --[data-chat-flow-kind=user] [class*="_userRow"]:hover [class*="_actions"],[data-chat-flow-kind=user] [class*="_userRow"]:focus-within [class*="_actions"]{opacity:1!important;visibility:visible} --[data-chat-flow-kind=user] [class*="_actions"] [class*="_timeStart"]{display:none} --[data-chat-flow-kind=user] [class*="_actions"] button{width:24px;height:24px;border-radius:6px;color:var(--bt-ink-2)} --[data-chat-flow-kind=user] [class*="_actions"] button:hover{background:var(--bt-hover);color:var(--bt-ink)} --[data-chat-flow-kind=user] [class*="_actions"] svg{width:15px;height:15px} --.bt-q-inline{display:flex;margin:4px 0 6px} --[data-chat-flow-kind=assistant-step]+[data-step-process]:has(.bt-q-inline){margin-top:0!important} --[data-chat-flow-kind=question-reply]:has(.bt-q-inline),[data-chat-group-key]:has([data-chat-flow-kind=question-reply] .bt-q-inline),[data-chat-group-key]:has(.bt-q-gone){margin-top:0!important} --.bt-q-inline .bt-q{margin:0;max-width:min(550px,86%);order:0} --.bt-q-dock{width:100%;display:flex;justify-content:center} --.bt-q-dock .bt-q{margin:0 auto 8px} --[class*="_toBottomSlot"]{justify-content:center!important;padding-right:0!important} --[data-conversation-scroll] [class*="_toBottomSlot"]{bottom:calc(var(--dsh-composer-height,152px) + 8px)!important} --[class*="_toBottomSlot"]>button{width:36px!important;height:36px!important;margin-top:-36px!important;background:var(--bt-main)!important;color:var(--bt-ink)!important;box-shadow:0 0 0 .5px var(--bt-line-2),0 2px 8px -2px rgba(0,0,0,.12)!important;animation:bt-tobottom-in ${SPRING_TAP.duration}ms ${SPRING_TAP.easing},bt-veil-in .16s ease-out;transition:background-color .12s ease,${spring(SPRING_TAP, "scale")}} --[class*="_toBottomSlot"]>button:active{scale:.9} --[class*="_toBottomSlot"]>button:hover{background:color-mix(in srgb,var(--bt-ink) 4%,var(--bt-main))!important} --[class*="_toBottomSlot"]>button svg{display:none} --[class*="_toBottomSlot"]>button::before{content:"";width:20px;height:20px;background:currentColor;-webkit-mask:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20' fill='none' stroke='black' stroke-width='1.6' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M10 4v12M5 11l5 5 5-5'/%3E%3C/svg%3E") center/contain no-repeat;mask:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20' fill='none' stroke='black' stroke-width='1.6' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M10 4v12M5 11l5 5 5-5'/%3E%3C/svg%3E") center/contain no-repeat} --@keyframes bt-tobottom-in{from{translate:0 8px;scale:.8}} --[data-bt-news] [class*="_toBottomSlot"]>button{visibility:hidden!important;pointer-events:none!important} --.bt-news{position:fixed;z-index:30;transform:translateX(-50%);display:inline-flex;align-items:center;overflow:hidden;border-radius:999px;background:#0c64c1;color:#fcfcfc;box-shadow:inset 0 0 0 1px rgba(20,20,20,.05),0 1px 3px 0 rgba(0,0,0,.12);font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",system-ui,sans-serif;animation:bt-news-in ${SPRING_TAP.duration}ms ${SPRING_TAP.easing},bt-veil-in .16s ease-out} --@keyframes bt-news-in{from{translate:0 8px;scale:.9}} --.bt-news[data-direction=up]{animation-name:bt-news-in-up,bt-veil-in} --@keyframes bt-news-in-up{from{translate:0 -8px;scale:.9}} --.bt-news-jump{display:inline-flex;align-items:center;gap:2px;margin:0;padding:4px 26px 4px 4px;border:0;border-radius:0;background:transparent;color:inherit;font:inherit;font-size:13px;line-height:18px;white-space:nowrap;cursor:pointer;outline:none} --.bt-news-ico{flex:none;display:inline-flex;align-items:center;justify-content:center;width:20px;height:20px} --.bt-news-ico svg{width:16px;height:16px} --.bt-news-x{position:absolute;right:4px;top:50%;z-index:1;transform:translateY(-50%);width:20px;height:20px;padding:0;border:0;border-radius:999px;background:transparent;color:inherit;opacity:.75;display:inline-flex;align-items:center;justify-content:center;cursor:pointer} --.bt-news-x svg{width:12px;height:12px} --.bt-news-x:hover{opacity:1;background:rgba(20,20,20,.5)} --@media (prefers-reduced-motion:reduce){.bt-news,[class*="_toBottomSlot"]>button{animation:none}} --.bt-new-sep{display:flex;align-items:center;gap:8px;margin:14px 0 8px;padding:2px 0;font-size:11px;line-height:14px;font-weight:500;letter-spacing:.02em;text-transform:uppercase;color:#0c64c1;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",system-ui,sans-serif} --.bt-new-sep::before,.bt-new-sep::after{content:"";flex:1;height:1px;background:color-mix(in srgb,#0c64c1 20%,transparent)} --.bt-open-new{display:flex;flex-direction:column} --.bt-open-new .bt-new-sep{margin:18px 0 0} --.bt-open-new .bt-time-sep{height:16px;margin:32px 0 2px} --.bt-earlier{display:flex;flex-direction:column;gap:6px} --.bt-earlier-top{display:flex;justify-content:center;align-items:center;height:34px;flex:none} --.bt-earlier-top .bt-soft{height:28px;font-size:12px;padding:0 12px} --.bt-urow{display:flex;justify-content:flex-end} --.bt-ububble{max-width:min(560px,78%);background:var(--bt-user);color:var(--bt-user-ink);border-radius:18px;padding:7px 12px;font-size:14px;line-height:20px;box-sizing:border-box;overflow-wrap:anywhere} --.bt-ububble [class*="_markdown"],.bt-ububble a{color:inherit!important} --.bt-ububble p{margin:0;line-height:22px} --.bt-uimages{display:block;font-size:12px;opacity:.7} --.bt-earlier .bt-event{margin:4px 0} --.bt-part-line{display:flex;align-items:center;gap:10px;margin:10px 0 6px;font-size:11px;line-height:14px;color:var(--bt-ink-3)} --.bt-part-line::before,.bt-part-line::after{content:"";flex:1;height:1px;background:var(--bt-line)} --`; --function installStyles(text, tag) { -- const style = document.createElement("style"); -- style.dataset.dshBot = tag; -- style.textContent = text; -- document.head.appendChild(style); -- return style; -+function FirstContact({ source, roster, sessionId }) { -+ const person = typeof source.remote.typedBy === "string" ? source.remote.typedBy : ""; -+ const who = person || remoteSpeaker(source, roster).name; -+ const bot = roster.byId[sessionId]?.name ?? t("your Bot"); -+ return (0, import_react9.createElement)("div", { className: "bt-event", "data-first-contact": source.remote.handle }, (0, import_react9.createElement)("span", null, t("{name} is talking to {bot} through Bot Social", { name: who, bot }))); - } --var FRAGMENT_SELECTOR = /\[class\*="(_[\w-]+)"\]/g; --var CLASS_TOKEN = /\.((?:\\.|[\w-])+)/g; --function sheetClassNames(sheet) { -- const names = []; -- const visit = (rules) => { -- for (const rule of rules) { -- if (rule.selectorText) for (const match of rule.selectorText.matchAll(CLASS_TOKEN)) names.push(match[1]); -- if (rule.cssRules) visit(rule.cssRules); -+function GlobeMark() { -+ return (0, import_react9.createElement)( -+ "svg", -+ { width: 10, height: 10, viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: 2, strokeLinecap: "round", "aria-hidden": true }, -+ (0, import_react9.createElement)("circle", { cx: 12, cy: 12, r: 9 }), -+ (0, import_react9.createElement)("path", { d: "M3 12h18M12 3a14 14 0 0 1 0 18M12 3a14 14 0 0 0 0 18" }) ++ open ? (0, import_react8.createElement)( ++ "div", ++ { className: "bt-secret-edit" }, ++ (0, import_react8.createElement)(ScopeEditor, { key: JSON.stringify(secret.scope), secret, roster, onSave: (scope) => run(actions.secretUpdate(secret.name, scope)) }), ++ (0, import_react8.createElement)(ValueEditor, { secret, onSave: (value) => run(actions.secretSet({ name: secret.name, value })) }), ++ (0, import_react8.createElement)( ++ "div", ++ { className: "bt-secret-row-actions" }, ++ confirm ? [ ++ (0, import_react8.createElement)("span", { key: "q", className: "bt-set-hint" }, t("Delete {name}? Bots lose it at once.", { name: secret.name })), ++ (0, import_react8.createElement)("button", { key: "no", type: "button", className: "bt-soft bt-soft-sm", onClick: () => setConfirm(false) }, t("Keep")), ++ (0, import_react8.createElement)("button", { key: "yes", type: "button", className: "bt-soft bt-soft-sm bt-secret-danger", onClick: () => run(actions.secretDelete(secret.name)) }, t("Delete")) ++ ] : (0, import_react8.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm bt-secret-danger", onClick: () => setConfirm(true) }, (0, import_react8.createElement)(TrashIcon), t("Delete")) ++ ) ++ ) : null + ); +} -+function RemoteFiles({ files, plugin, botId }) { -+ const [busy, setBusy] = (0, import_react9.useState)(""); -+ const [error, setError] = (0, import_react9.useState)(""); -+ const download2 = async (file) => { -+ setBusy(file.id); -+ setError(""); -+ try { -+ const got = await call(`flybot/${plugin}/attachment`, { id: file.id, botId }); -+ const value = got?.value ?? got; -+ const bytes = Uint8Array.from(atob(value.content_base64), (c) => c.charCodeAt(0)); -+ const url = URL.createObjectURL(new Blob([bytes], { type: "application/octet-stream" })); -+ const link = Object.assign(document.createElement("a"), { href: url, download: value.name ?? file.name }); -+ link.click(); -+ setTimeout(() => URL.revokeObjectURL(url), 1e4); -+ } catch (reason) { -+ setError(String(reason?.message ?? reason)); -+ } finally { -+ setBusy(""); -+ } ++function AddSecret({ actions, run, onDone }) { ++ const [name, setName] = (0, import_react8.useState)(""); ++ const [purpose, setPurpose] = (0, import_react8.useState)(""); ++ const [value, setValue] = (0, import_react8.useState)(""); ++ const ready = /^[A-Za-z][\w-]{0,63}$/.test(name.trim()) && value.trim().length >= MIN_VALUE; ++ const save = () => { ++ if (!ready) return; ++ void run(actions.secretSet({ name, purpose, value, scope: "all" })).then((ok) => { ++ if (ok) { ++ setValue(""); ++ onDone(); ++ } ++ }); + }; -+ return (0, import_react9.createElement)( ++ return (0, import_react8.createElement)( + "div", -+ { className: "bt-file-chips" }, -+ files.map((file) => (0, import_react9.createElement)( -+ "button", -+ { key: file.id, type: "button", className: "bt-file-chip", disabled: busy === file.id, title: `Download ${file.name} (untrusted file from a remote Bot)`, onClick: () => download2(file) }, -+ `📎 ${file.name} · ${fileSize(file.size)}` -+ )), -+ error ? (0, import_react9.createElement)("span", { className: "bt-file-error", role: "alert" }, error) : null ++ { className: "bt-set-row bt-set-paste bt-secret-add" }, ++ (0, import_react8.createElement)("input", { className: "bt-input", "aria-label": t("Key name"), placeholder: t("NAME, e.g. GITHUB_TOKEN"), value: name, spellCheck: false, autoComplete: "off", autoFocus: true, onChange: (event) => setName(event.target.value.toUpperCase()) }), ++ (0, import_react8.createElement)("input", { className: "bt-input", "aria-label": t("What it is for"), placeholder: t("What it is for"), value: purpose, autoComplete: "off", onChange: (event) => setPurpose(event.target.value) }), ++ (0, import_react8.createElement)("label", { className: "bt-q-field bt-secret-field" }, (0, import_react8.createElement)(MaskedInput, { value, onChange: setValue, onEnter: save, label: t("Value") })), ++ (0, import_react8.createElement)( ++ "div", ++ { className: "bt-secret-row-actions" }, ++ (0, import_react8.createElement)("span", { className: "bt-set-hint" }, t("New keys are for all your Bots; change who may use it afterwards.")), ++ (0, import_react8.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: onDone }, t("Cancel")), ++ (0, import_react8.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", disabled: !ready, onClick: save }, t("Save key")) ++ ) + ); +} -+function GroupEvent({ prefix, room: room2, name, roster, actions }) { -+ return (0, import_react9.createElement)( -+ "div", -+ { className: "bt-event" }, -+ (0, import_react9.createElement)("span", null, prefix), -+ room2 ? (0, import_react9.createElement)("button", { type: "button", onClick: () => actions.openSession(room2.id) }, (0, import_react9.createElement)(RoomAvatar, { room: room2, roster, size: 16 }), room2.name) : name ? (0, import_react9.createElement)("span", null, name) : null -+ ); ++function SecretsPage({ roster, actions, run, Section }) { ++ const [open, setOpen] = (0, import_react8.useState)(null); ++ const [adding, setAdding] = (0, import_react8.useState)(false); ++ const secrets = roster.secrets ?? []; ++ return [ ++ (0, import_react8.createElement)( ++ "p", ++ { key: "about", className: "bt-secret-about" }, ++ t("Keys your Bots asked for. Bots never see the values: they use them as "), ++ (0, import_react8.createElement)("code", null, "$DSH_SECRET_NAME"), ++ t(" in their shell, and every value is replaced by "), ++ (0, import_react8.createElement)("code", null, "[secret:NAME]"), ++ t(" before anything goes to a model.") ++ ), ++ (0, import_react8.createElement)( ++ Section, ++ { key: "list", title: t("Secrets · {count}", { count: secrets.length }) }, ++ secrets.length === 0 && !adding ? (0, import_react8.createElement)("div", { className: "bt-set-row" }, (0, import_react8.createElement)("span", { className: "bt-set-hint" }, t("No keys yet. When a Bot needs one, it shows a card in its chat."))) : null, ++ secrets.map((secret) => (0, import_react8.createElement)(SecretRow, { key: secret.name, secret, roster, actions, run, open: open === secret.name, onToggle: () => setOpen(open === secret.name ? null : secret.name) })), ++ adding ? (0, import_react8.createElement)(AddSecret, { actions, run, onDone: () => setAdding(false) }) : null ++ ), ++ adding ? null : (0, import_react8.createElement)("div", { key: "add", className: "bt-actions" }, (0, import_react8.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: () => setAdding(true) }, t("Add a key"))) ++ ]; +} -+function MemoryEvent({ sessionId, scope, result, roster, actions, findBot }) { -+ if (!/^(Saved to|Updated in|Removed from) /.test(result)) return null; -+ const self = roster.byId[sessionId]; -+ const team = /^(team|团队|共享)$/i.test(String(scope ?? "").trim()); -+ const other = team || !scope ? void 0 : findBot(scope); -+ const target = other && other.id !== self?.id ? other : self; -+ if (!target) return null; -+ const label = team ? t("Team memory updated") : target === self ? t("Memory updated") : t("Updated {name}'s memory", { name: target.name }); -+ return (0, import_react9.createElement)( -+ "div", -+ { className: "bt-event" }, -+ (0, import_react9.createElement)("button", { type: "button", title: result, onClick: () => actions.openMemory(target.id) }, (0, import_react9.createElement)(MemoryIcon), label) -+ ); ++var SECRET_CSS = ` ++.bt-secret-head{align-items:center;gap:10px} ++.bt-secret-who{position:relative;flex:none;display:inline-flex} ++.bt-secret-lock{position:absolute;right:-5px;bottom:-4px;width:16px;height:16px;border-radius:50%;background:var(--bt-main);color:var(--bt-ink-2);display:inline-flex;align-items:center;justify-content:center;box-shadow:0 0 0 1.5px var(--bt-bubble)} ++.bt-secret-lock svg{width:11px;height:11px} ++.bt-secret-name{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:12px;line-height:16px;padding:1px 6px;border-radius:5px;background:var(--bt-hover);color:var(--bt-ink)} ++.bt-secret-body{display:flex;flex-direction:column;gap:8px} ++.bt-secret-field{align-items:center;min-height:36px} ++.bt-secret-input{flex:1;width:100%;min-width:0;margin:0;padding:0;border:0;outline:none;background:transparent;color:var(--bt-ink);font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:14px;line-height:20px;letter-spacing:.04em} ++.bt-secret-input[data-masked]{-webkit-text-security:disc} ++.bt-secret-input::placeholder{color:var(--bt-ink-3);font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",system-ui,sans-serif;letter-spacing:0} ++.bt-secret-scope{position:relative;display:inline-grid;grid-template-columns:1fr 1fr;align-self:flex-start;padding:2px;border-radius:999px;background:var(--bt-hover)} ++.bt-secret-glide{position:absolute;top:2px;bottom:2px;left:2px;width:calc(50% - 2px);border-radius:999px;background:var(--bt-main);box-shadow:0 1px 3px rgba(20,20,20,.08),inset 0 0 0 .5px color-mix(in srgb,var(--bt-ink) 6%,transparent);transition:${spring(SPRING_SHAPE, "transform")}} ++.bt-secret-glide[data-at="1"]{transform:translateX(100%)} ++.bt-secret-seg{position:relative;z-index:1;height:28px;padding:0 14px;border:0;border-radius:999px;background:none;color:var(--bt-ink-2);font:inherit;font-size:13px;white-space:nowrap;cursor:pointer;transition:color .16s ease} ++.bt-secret-seg[aria-checked=true]{color:var(--bt-ink);font-weight:500} ++.bt-secret-seg:focus-visible{outline:2px solid var(--bt-accent);outline-offset:-2px} ++/* A key card asks the user to act, so the process group carrying it stays open in every ++ work-details mode (same override as .bt-group-replies in styles.js). */ ++[data-step-process]:has(.bt-secret)>div:first-child{display:none!important} ++[data-step-process]:has(.bt-secret)>[data-step-process-body]{display:block!important;content-visibility:visible!important;max-height:none!important;overflow:visible!important;mask-image:none!important;scrollbar-gutter:auto!important} ++.bt-secret-ok{display:flex;align-items:center;gap:4px;margin-top:6px;animation:bt-secret-in .22s cubic-bezier(.22,1,.36,1)} ++.bt-secret-ok svg{width:13px;height:13px;color:#16a34a} ++@keyframes bt-secret-in{from{opacity:0;transform:translateY(-2px)}} ++.bt-secret-saved{display:flex;align-items:center;gap:6px;font-size:13px;line-height:18px;color:var(--bt-ink-2)} ++.bt-secret-note{font-size:12px;line-height:17px;color:var(--bt-ink-3)} ++.bt-secret-note code,.bt-secret-about code{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:11.5px} ++.bt-secret-warn{display:flex;align-items:center;gap:6px;padding:6px 10px;border-radius:8px;font-size:12px;line-height:16px;color:#b25e09;background:color-mix(in srgb,#f59e0b 12%,transparent)} ++.bt-secret-error{font-size:12px;line-height:16px;color:#e02135} ++.bt-secret .bt-q-btn:disabled{opacity:.5;cursor:default} ++.bt-secret-done .bt-q-head{align-items:center} ++.bt-secret-done .bt-q-title{font-size:13px;line-height:18px;color:var(--bt-ink-2);font-weight:500} ++.bt-secret-scope-tag{font-weight:400;color:var(--bt-ink-3)} ++.bt-secret-check{color:var(--bt-green,#22a06b);animation:bt-secret-pop ${SPRING_POP.duration}ms ${SPRING_POP.easing} backwards} ++.bt-secret-done{animation:bt-secret-settle .32s cubic-bezier(.22,1,.36,1) backwards} ++@keyframes bt-secret-pop{from{opacity:0;transform:scale(.4)}} ++@keyframes bt-secret-settle{from{opacity:.4;transform:scale(.985)}} ++.bt-secret-event{display:inline-flex;align-items:center;gap:5px} ++.bt-secret-dock{flex-direction:column;align-items:center} ++.bt-secret-dock .bt-q{max-width:min(550px,calc(100% - 32px))} ++.bt-secret-about{margin:0 0 12px;padding:0 4px;font-size:12px;line-height:17px;color:var(--bt-ink-2)} ++.bt-secret-title{display:flex;align-items:center;gap:6px;min-width:0;color:var(--bt-ink)} ++.bt-secret-title svg{flex:none;color:var(--bt-ink-2)} ++.bt-secret-purpose{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--bt-ink-2)} ++.bt-secret-faces{display:inline-flex} ++.bt-secret-faces>*+*{margin-left:-6px} ++.bt-secret-edit{display:flex;flex-direction:column;gap:14px;padding:4px 12px 14px;animation:bt-q-in ${SPRING_SHAPE.duration}ms ${SPRING_SHAPE.easing} backwards,bt-veil-in .2s ease-out backwards} ++.bt-secret-edit-block{display:flex;flex-direction:column;gap:8px} ++.bt-secret-edit-label{font-size:12px;line-height:16px;font-weight:600;color:var(--bt-ink-2)} ++.bt-secret-radios{display:flex;gap:16px;font-size:13px} ++.bt-secret-radio{display:inline-flex;align-items:center;gap:6px;cursor:pointer} ++.bt-secret-bots{display:flex;flex-wrap:wrap;gap:6px} ++.bt-secret-bot{display:inline-flex;align-items:center;gap:6px;height:30px;padding:0 10px 0 5px;border-radius:999px;border:.5px solid var(--bt-line);background:var(--bt-card);color:var(--bt-ink-2);font:inherit;font-size:12px;cursor:pointer;transition:background-color .12s ease,color .12s ease,border-color .12s ease} ++.bt-secret-bot[aria-pressed=true]{background:var(--bt-active);color:var(--bt-ink);border-color:var(--bt-line-2)} ++.bt-secret-bot svg{width:13px;height:13px} ++.bt-secret-inline{display:flex;gap:8px;align-items:center} ++.bt-secret-row-actions{display:flex;align-items:center;justify-content:flex-end;gap:8px;flex-wrap:wrap} ++.bt-secret-row-actions .bt-set-hint{flex:1} ++.bt-secret-danger{color:#e02135;display:inline-flex;align-items:center;gap:4px} ++.bt-secret-danger svg{width:13px;height:13px} ++.bt-secret-add .bt-input{height:34px} ++@media (prefers-reduced-motion:reduce){.bt-secret-glide{transition:none}.bt-secret-check,.bt-secret-done,.bt-secret-ok,.bt-secret-edit{animation:none}} ++`; ++ ++// src/client/face-seats.js ++function faceSeats(room2, roster, { left = false } = {}) { ++ const records = (room2.presentedParticipants ?? room2.participants ?? []).filter((p) => p.kind !== "human"); ++ const seat = (p) => { ++ const local = p.remote ? void 0 : roster.byId[p.id]; ++ return { ++ key: p.handle || p.id, ++ local, ++ record: p, ++ person: local ? seatOwnerName(p.ownerName, p.handle) || cleanPeerName(roster.self?.displayName, "") : ownerNameOf(p), ++ bot: local ? local.name : remoteBotName(p, "Bot"), ++ mine: Boolean(local) ++ }; ++ }; ++ const seats = records.filter((p) => p.leftSeq === void 0).map(seat); ++ for (const id of room2.members ?? []) { ++ const bot = roster.byId[id]; ++ if (!bot || seats.some((item) => item.local?.id === id)) continue; ++ seats.push({ key: id, local: bot, record: { id }, person: cleanPeerName(roster.self?.displayName, ""), bot: bot.name, mine: true }); ++ } ++ if (!left) return seats; ++ const gone = records.filter((p) => p.leftSeq !== void 0 && !seats.some((item) => item.key === (p.handle || p.id))).map((item) => ({ ...seat(item), left: true })); ++ return [...seats, ...gone]; +} -+function PartLine({ time }) { -+ return (0, import_react9.createElement)("div", { className: "bt-part-line", role: "separator" }, Number.isFinite(time) ? (0, import_react9.createElement)("span", null, dayLabel(time)) : null); ++function faceSeatLabel(seat) { ++ if (!seat.person) return seat.bot; ++ return `${seat.bot} (${seat.person})`; +} -+var scrollParent = (node) => { -+ for (let at = node.parentElement; at; at = at.parentElement) { -+ if (/(auto|scroll)/.test(getComputedStyle(at).overflowY)) return at; ++function faceBotLabel(room2, roster, bot) { ++ if (room2?.origin !== "face-to-face" || !bot) return bot?.name; ++ const seat = faceSeats(room2, roster, { left: true }).find((item) => item.local?.id === bot.id || item.record?.id !== void 0 && item.record.id === bot.id); ++ return seat ? faceSeatLabel(seat) : bot.name; ++} ++function facePersonKey(seat) { ++ if (seat.mine) return ":you"; ++ const record = seat.record ?? {}; ++ const home = String(record.ownerHome || record.home || "").trim().toLowerCase(); ++ if (home) return `home:${home}`; ++ const parts = String(record.handle || "").trim().toLowerCase().replace(/^@+/, "").split("."); ++ if (parts.length >= 3 && parts.at(-1) === "home") return `home:${parts.slice(-2).join(".")}`; ++ return `name:${(ownerNameOf(record) || seat.person || seat.key).toLowerCase()}`; ++} ++function facePeople(room2, roster) { ++ const people2 = []; ++ for (const seat of faceSeats(room2, roster)) { ++ const key = facePersonKey(seat); ++ const known = people2.find((person) => person.key === key); ++ if (known) { ++ known.seats.push(seat); ++ continue; ++ } ++ people2.push({ key, name: seat.person || (seat.mine ? cleanPeerName(roster.self?.displayName, "") : "") || t("Someone"), mine: seat.mine, seats: [seat] }); ++ } ++ return [...people2.filter((person) => person.mine), ...people2.filter((person) => !person.mine)]; ++} ++function facePersonLabel(person) { ++ return person.mine ? t("{name} (you)", { name: person.name }) : person.name; ++} ++ ++// src/client/held-reply.js ++var import_react9 = require("react"); ++var call3 = (endpoint, payload) => call(`flybot/${endpoint}`, payload); ++var Boundary; ++var boundary = () => Boundary ??= class HeldReplyBoundary extends import_react9.Component { ++ constructor(props) { ++ super(props); ++ this.state = { failed: false }; ++ } ++ static getDerivedStateFromError() { ++ return { failed: true }; ++ } ++ componentDidCatch(error) { ++ console.error(`flybot: ${this.props.id} failed`, error); ++ } ++ render() { ++ return this.state.failed ? this.props.fallback : this.props.children; + } -+ return null; +}; -+function EarlierParts({ sessionId, roster, actions, useUi }) { -+ const [view, setView] = (0, import_react9.useState)({ items: [], cursor: void 0, loading: true, failed: false, startedAt: null }); -+ const top = (0, import_react9.useRef)(null); -+ const busy = (0, import_react9.useRef)(false); -+ const alive = (0, import_react9.useRef)(true); -+ const restore = (0, import_react9.useRef)(null); -+ const load = (cursor) => { -+ if (busy.current) return; -+ busy.current = true; -+ setView((current) => ({ ...current, loading: true, failed: false })); -+ actions.earlier(sessionId, cursor).then((page) => { -+ busy.current = false; -+ if (!alive.current) return; -+ const scroller = top.current && scrollParent(top.current); -+ if (scroller) restore.current = { scroller, fromEnd: scroller.scrollHeight - scroller.scrollTop }; -+ setView((current) => ({ items: [...page.items, ...current.items], cursor: page.cursor, loading: false, failed: false, startedAt: page.startedAt ?? current.startedAt })); -+ }, () => { -+ busy.current = false; -+ if (alive.current) setView((current) => ({ ...current, loading: false, failed: true })); -+ }); -+ }; -+ (0, import_react9.useEffect)(() => { -+ alive.current = true; -+ load(void 0); -+ return () => { -+ alive.current = false; -+ }; -+ }, [sessionId]); -+ (0, import_react9.useLayoutEffect)(() => { -+ const pending = restore.current; -+ restore.current = null; -+ if (pending) pending.scroller.scrollTop = pending.scroller.scrollHeight - pending.fromEnd; -+ }, [view.items]); ++function useInlineEntry(policy) { ++ const slots = registry(); ++ const [, setVersion] = (0, import_react9.useState)(0); ++ (0, import_react9.useEffect)(() => slots.subscribe(() => setVersion(slots.version())), [slots]); ++ return slots.entries("flybot.approvals").find((entry) => entry.inline === true && entry.policy === policy); ++} ++function HeldReplyActions({ held, actions }) { ++ const custom = useInlineEntry(held?.by); ++ const [busy, setBusy] = (0, import_react9.useState)(false); ++ const [error, setError] = (0, import_react9.useState)(""); ++ const waiting = isApprovalWaiting(held?.status); + (0, import_react9.useEffect)(() => { -+ const node = top.current; -+ if (!node || view.loading || view.failed || !view.cursor || typeof IntersectionObserver !== "function") return void 0; -+ const observer = new IntersectionObserver((entries2) => { -+ if (entries2.some((entry) => entry.isIntersecting)) load(view.cursor); -+ }, { root: scrollParent(node), rootMargin: "600px 0px 0px 0px" }); -+ observer.observe(node); -+ return () => observer.disconnect(); -+ }, [view.cursor, view.loading, view.failed]); -+ const selfId = roster.byId[sessionId]?.id; -+ const keyOf = (item) => `${item.sessionId ?? sessionId}:${item.turn ?? ""}:${item.seq ?? item.time ?? ""}`; -+ const rows = []; -+ for (const item of view.items) { -+ if (item.kind === "bot") { -+ if (String(item.text ?? "").trim() === "") continue; -+ const last = rows.at(-1); -+ if (last?.kind === "bot") { -+ last.texts.push(item.text); -+ last.msgKeys.push(keyOf(item)); -+ } else rows.push({ kind: "bot", texts: [item.text], msgKeys: [keyOf(item)] }); -+ continue; ++ if (!waiting) return void 0; ++ claimApprovals(`held-reply:${held.id}`, [held.id]); ++ return () => claimApprovals(`held-reply:${held.id}`, []); ++ }, [held?.id, waiting]); ++ if (!held?.id) return null; ++ const decide = async (decision) => { ++ setBusy(true); ++ setError(""); ++ try { ++ await call3("decide", { id: held.id, decision }); ++ } catch (failure) { ++ setError(failure?.message ?? String(failure)); ++ } finally { ++ setBusy(false); + } -+ rows.push(item); ++ }; ++ if (!waiting) { ++ const failed = held.status === "failed"; ++ return (0, import_react9.createElement)( ++ "div", ++ { className: "bt-held-note", "data-state": held.status, title: failed && held.error ? held.error : void 0 }, ++ (0, import_react9.createElement)("span", null, held.status === "approved" ? t("Sent to {to}", { to: held.toName }) : held.status === "auto" ? t("Auto-sent to {to}", { to: held.toName }) : failed ? t("Not sent: delivery failed") : t("Not sent")), ++ // #968: the terminal answer comes from one predicate, not from `status === 'failed'` here and a ++ // different field elsewhere. A failure that can never succeed draws no button on any surface. ++ failed && canRetry(held) ? (0, import_react9.createElement)("button", { type: "button", className: "bt-held-retry", disabled: busy, onClick: () => decide("retry") }, t("Retry")) : null, ++ error ? (0, import_react9.createElement)("span", { className: "bt-flybot-approval-error", role: "alert" }, error) : null ++ ); + } -+ return (0, import_react9.createElement)( ++ const plain = (0, import_react9.createElement)( + "div", -+ { className: "bt-earlier", role: "feed", "aria-label": t("Earlier messages"), "aria-busy": view.loading }, ++ { className: "bt-held-card", role: "region", "aria-label": t("Waiting for your approval") }, ++ (0, import_react9.createElement)("div", { className: "bt-held-head" }, t("Waiting for your approval")), ++ (0, import_react9.createElement)("div", { className: "bt-flybot-approval-origin" }, t("{from} → {to}", { from: held.from ?? t("A Bot"), to: held.toName })), + (0, import_react9.createElement)( + "div", -+ { ref: top, className: "bt-earlier-top" }, -+ view.loading ? (0, import_react9.createElement)(Shimmer, null, t("Loading earlier messages…")) : view.failed ? (0, import_react9.createElement)("button", { type: "button", className: "bt-soft", onClick: () => load(view.cursor) }, t("Couldn’t load earlier messages · Retry")) : view.cursor ? (0, import_react9.createElement)("button", { type: "button", className: "bt-soft", onClick: () => load(view.cursor) }, t("Load earlier")) : null ++ { className: "bt-flybot-approval-actions" }, ++ (0, import_react9.createElement)("button", { type: "button", disabled: busy, onClick: () => decide("reject") }, t("Reject")), ++ (0, import_react9.createElement)("button", { type: "button", className: "bt-flybot-approve", disabled: busy, onClick: () => decide("approve") }, t("Approve")) + ), -+ rows.map((row, index) => { -+ const key = rows.length - index; -+ switch (row.kind) { -+ case "divider": -+ return (0, import_react9.createElement)(PartLine, { key, time: row.time }); -+ case "user": -+ return (0, import_react9.createElement)("div", { key, className: "bt-urow" }, (0, import_react9.createElement)( -+ "div", -+ { className: "bt-ububble" }, -+ row.text ? (0, import_react9.createElement)(import_dsh_client_ui_primitives.MarkdownText, { text: row.text, labels: mdLabels(), variant: "compact" }) : null, -+ row.images ? (0, import_react9.createElement)("span", { className: "bt-uimages" }, row.images === 1 ? t("1 image") : t("{count} images", { count: row.images })) : null -+ )); -+ case "answer": -+ return (0, import_react9.createElement)(AnsweredCell, { key, node: { data: row } }); -+ case "bot": -+ return (0, import_react9.createElement)(Bubbles, { key, texts: row.texts, msgKeys: row.msgKeys, roster, selfId, actions, sessionId, useUi }); -+ case "event": { -+ if (row.role === "report") return (0, import_react9.createElement)(GroupEvent, { key, prefix: t("Replies from"), room: roster.roomsById[row.roomId], name: t("a group chat"), roster, actions }); -+ const other = roster.byId[row.botId] ?? { id: row.botId, name: row.name ?? t("a Bot"), color: "gray" }; -+ const prefix = { stopped: t("Stopped by"), reply: t("Reply from") }[row.role] ?? t("Message from"); -+ return (0, import_react9.createElement)(EventLine, { key, prefix, bots: [other], onOpen: () => { -+ if (selfId && other.id) actions.openExchange(selfId, other.id); -+ }, whole: true }); -+ } -+ default: -+ return null; -+ } -+ }), -+ (0, import_react9.createElement)(PartLine, { time: view.startedAt }) ++ error ? (0, import_react9.createElement)("div", { className: "bt-flybot-approval-error", role: "alert" }, error) : null ++ ); ++ return (0, import_react9.createElement)( ++ "div", ++ { className: "bt-held-actions", "data-flybot": "held-reply-actions" }, ++ custom ? (0, import_react9.createElement)(boundary(), { id: custom.id, fallback: plain }, (0, import_react9.createElement)(custom.component, { held, decide, call: call3, actions })) : plain + ); +} -+function TriggerCell({ node, sessionId, useRoster, useSessions, useTurnClock, useUi, actions }) { -+ const roster = useRoster((value) => value); -+ const data = node.data; -+ const source = data.source !== null && typeof data.source === "object" ? data.source : {}; -+ const selfId = roster.byId[sessionId]?.id ?? sessionId; -+ const deferred = source.kind === "bot" && source.deferred === true; -+ const relayed = isRelayContext(data); -+ const turn = node.location?.turn?.turn ?? data.turn; -+ (0, import_react9.useLayoutEffect)(() => { -+ if (relayed) actions.noteTurnPeer(sessionId, turn, source.senderSessionId); -+ }, [relayed, sessionId, turn, source.senderSessionId]); -+ const order = useSessions((list) => { -+ const outline = list.projectionsBySession?.[sessionId]?.values?.turnOutline ?? list.byId[sessionId]?.projectionValues?.turnOutline; -+ return Array.isArray(outline) ? outline.map((entry) => entry.turn).join(",") : ""; -+ }); -+ const peers = useTurnClock((state) => { -+ if (!relayed) return ""; -+ const turns = state[sessionId] ?? {}; -+ return Object.keys(turns).filter((key) => turns[key].peer !== void 0).map((key) => `${key}:${turns[key].peer}`).join(","); -+ }); -+ const visit = useUi((state) => state.visits?.[sessionId]); -+ const hiddenTurns = roster.exchangeTurns[sessionId]; -+ const unreadOpening = useTurnClock((state) => { -+ const first = openingTurn(state[sessionId], order, hiddenTurns); -+ return first?.turn === turn && unseenSince(visit, first.start) ? clockLabel(first.start) : ""; -+ }); -+ const body = triggerBody(); -+ return unreadOpening ? (0, import_react9.createElement)(import_react9.Fragment, null, (0, import_react9.createElement)("div", { className: "bt-open-new" }, (0, import_react9.createElement)(NewDivider), (0, import_react9.createElement)("div", { className: "bt-time-sep", role: "separator" }, unreadOpening)), body) : body; -+ function triggerBody() { -+ if (source.form === "remote-chat") return null; -+ if (source.shown === true) return null; -+ if (source.remote?.handle) { -+ const message = (0, import_react9.createElement)(RemoteMessage, { source, text: remoteMessageText(stripHeader(contentText(data.content))), roster, actions, sessionId, msgKey: `${sessionId}:${data.turn}:remote`, useUi }); -+ return source.remote.firstContact === true ? (0, import_react9.createElement)(import_react9.Fragment, null, (0, import_react9.createElement)(FirstContact, { source, roster, sessionId }), message) : message; - } -- }; -- try { -- visit(sheet.cssRules); -- } catch { -+ if (source.kind === "bot") { -+ if (source.handoff === true) return roster.roomsById[sessionId] ? null : (0, import_react9.createElement)(EarlierParts, { sessionId, roster, actions, useUi }); -+ if (source.role === "room" || source.role === "kickoff") return null; -+ if (source.role === "mention") return (0, import_react9.createElement)("div", { className: "bt-event" }, (0, import_react9.createElement)("span", null, contentText(data.content))); -+ if (source.role === "secret" || deferred) return null; -+ if (source.role === "post") { -+ if (source.failed !== void 0 || source.resumed === true || source.stopped === true) return null; -+ if (source.notice === true || source.senderSessionId === "face-notice") return null; -+ const room2 = roster.roomsById[sessionId]; -+ const poster = roster.byId[source.senderSessionId] ?? { id: source.senderSessionId, name: source.senderName ?? t("a Bot"), color: "gray" }; -+ return (0, import_react9.createElement)(GroupMessage, { bot: poster, text: stripHeader(contentText(data.content)), room: room2, roster, actions, msgKey: `${sessionId}:${data.turn}:post`, useUi }); -+ } -+ if (source.role === "report") { -+ return (0, import_react9.createElement)(GroupEvent, { prefix: t("Replies from"), room: roster.roomsById[source.roomId], name: t("a group chat"), roster, actions }); -+ } -+ if (source.role === "resume" || source.resumed === true) return (0, import_react9.createElement)("div", { className: "bt-event" }, t("Picked up its work after a restart")); -+ if (source.role === "retry" || source.retried === true) return (0, import_react9.createElement)("div", { className: "bt-event" }, t("Asked to make the tool call it wrote as text")); -+ const run = relayed ? commRunOf( -+ order.split(",").filter(Boolean).map(Number), -+ Object.fromEntries(peers.split(",").filter(Boolean).map((pair) => [Number(pair.slice(0, pair.indexOf(":"))), pair.slice(pair.indexOf(":") + 1)])), -+ turn, -+ hiddenTurns -+ ) : null; -+ if (run === "folded") return null; -+ const sender = roster.byId[source.senderSessionId] ?? { id: source.senderSessionId, name: source.senderName ?? t("a Bot"), color: "gray" }; -+ const count = run?.count ?? (isHiddenTurn(roster, sessionId, turn) ? 2 : 1); -+ const bots = [sender]; -+ for (const peer of (run?.peerIds ?? []).map((id) => roster.byId[id]).filter(Boolean)) { -+ if (!bots.some((bot) => bot.id === peer.id)) bots.push(peer); -+ } -+ return (0, import_react9.createElement)(EventLine, { -+ prefix: source.role === "stopped" ? t("Stopped by") : count > 1 ? t("{count} messages with", { count }) : t("Message from"), -+ bots, -+ onOpen: (bot) => actions.openExchange(selfId, bot.id), -+ whole: true -+ }); -+ } -+ if (source.kind === "schedule") { -+ const prompts = reminderPrompts(contentText(data.content)); -+ return (0, import_react9.createElement)("div", { className: "bt-event" }, prompts.length > 0 ? t("Scheduled task: {text}", { text: prompts.join(" · ").slice(0, 80) }) : t("Routine started")); -+ } -+ if (source.kind === "user-question-reply") return null; -+ return (0, import_react9.createElement)("div", { className: "bt-event" }, contentText(data.content).slice(0, 80) || t("Update")); - } -- return names; - } --function compileFragmentSelectors(text, names) { -- const compiled = /* @__PURE__ */ new Map(); -- return text.replace(FRAGMENT_SELECTOR, (whole, fragment) => { -- if (!compiled.has(fragment)) { -- const exact = []; -- for (const name of names) if (name.includes(fragment)) exact.push(`.${name}`); -- compiled.set(fragment, exact.length ? `:is(${exact.join(",")})` : whole); -- } -- return compiled.get(fragment); -+function NewDivider() { -+ return (0, import_react9.createElement)("div", { className: "bt-new-sep", role: "separator", "aria-label": t("New messages") }, (0, import_react9.createElement)("span", null, t("New"))); ++ ++// src/client/turn-reasoning.js ++function turnReasoning(steps, current) { ++ const members = steps.length ? steps : [current]; ++ if (members.find((step) => stepReasoning(step) !== "")?.step !== current.step) return ""; ++ return members.filter((step) => !thinkingLive(step)).map(stepReasoning).filter(Boolean).join("\n\n"); +} -+function TurnProcessCell({ node, sessionId, turnProcess, useRoster, useUi, useTurnClock, actions }) { -+ const open = turnProcess === void 0 || !turnProcess.foldable || turnProcess.open; -+ (0, import_react9.useEffect)(() => { -+ if (!open) turnProcess.setOpen(true); -+ }, [open, turnProcess]); -+ const turn = node?.location?.turn?.turn ?? node?.data?.turn; -+ const start = node?.location?.turn?.start?.time; -+ const hidden = useRoster((roster) => isHiddenTurn(roster, sessionId, turn)); -+ (0, import_react9.useEffect)(() => { -+ actions.noteTurnTime(sessionId, turn, "first", start); -+ }, [sessionId, turn, start]); -+ const visit = useUi((state) => state.visits?.[sessionId]); -+ const fresh = useTurnClock((state) => { -+ const own = state[sessionId]?.[turn]; -+ return !hidden && typeof visit?.seen === "number" && own?.first !== void 0 && own.first <= visit.seen && own.end !== void 0 && own.end > visit.seen && own.end <= visit.entered; - }); -+ return fresh ? (0, import_react9.createElement)(NewDivider) : null; - } --function installCompiledStyles(text, tag) { -- const style = installStyles("", tag); -- const cache = /* @__PURE__ */ new WeakMap(); -- const render = () => { -- const names = /* @__PURE__ */ new Set(); -- for (const sheet of document.styleSheets) { -- if (sheet.ownerNode?.dataset?.dshBot) continue; -- let list = cache.get(sheet); -- if (!list) { -- list = sheetClassNames(sheet); -- cache.set(sheet, list); -- } -- for (const name of list) names.add(name); -- } -- const next = compileFragmentSelectors(text, names); -- if (style.textContent !== next) style.textContent = next; -- }; -- render(); -- let timer; -- const schedule = () => { -- clearTimeout(timer); -- timer = setTimeout(render, 120); -- }; -- const onLoad = (event) => { -- if (event.target instanceof HTMLLinkElement) schedule(); -- }; -- const observer = new MutationObserver(schedule); -- observer.observe(document.head, { childList: true }); -- document.addEventListener("load", onLoad, true); -- return () => { -- observer.disconnect(); -- document.removeEventListener("load", onLoad, true); -- clearTimeout(timer); -- style.remove(); -- }; -+function Nothing() { -+ return null; - } -- --// src/client/mark.js --var import_react2 = require("react"); --var STATES = ["idle", "thinking", "searching", "working", "sending", "orbit", "alert", "done"]; --var botState = (running, activity, alert) => running ? STATES.includes(activity) ? activity : "working" : alert ? "alert" : "idle"; --var toolState = (name) => /^(message_bot|stop_bot|create_bot|update_bot|create_group|update_group|delete_group|post_to_group)$/.test(name) ? "sending" : /search|fetch|browse|read|grep|glob|list|recall/i.test(name) ? "searching" : "working"; --function useGaze(ref, enabled) { -- (0, import_react2.useEffect)(() => { -- if (!enabled) return void 0; -- let frame = 0; -- let point = null; -- const place = () => { -- frame = 0; -- const node = ref.current; -- if (!node || !point) return; -- const box = node.getBoundingClientRect(); -- const clamp = (value) => Math.max(-1, Math.min(1, value)); -- node.style.setProperty("--bt-gx", clamp((point.x - box.left - box.width / 2) / 160).toFixed(2)); -- node.style.setProperty("--bt-gy", clamp((point.y - box.top - box.height / 2) / 160).toFixed(2)); -- }; -- const onMove = (event) => { -- point = { x: event.clientX, y: event.clientY }; -- if (!frame) frame = requestAnimationFrame(place); -- }; -- const onLeave = () => { -- point = null; -- ref.current?.style.setProperty("--bt-gx", "0"); -- ref.current?.style.setProperty("--bt-gy", "0"); -- }; -- window.addEventListener("pointermove", onMove, { passive: true }); -- document.documentElement.addEventListener("pointerleave", onLeave); -- return () => { -- cancelAnimationFrame(frame); -- window.removeEventListener("pointermove", onMove); -- document.documentElement.removeEventListener("pointerleave", onLeave); -- }; -- }, [enabled]); -+var HIDDEN_REMOVED_TOOLS = /* @__PURE__ */ new Set(["ask_user_question"]); -+var isConnectorTool = (name) => typeof name === "string" && /^mcp__(github|composio)__/.test(name); -+var SILENT_REGISTRATION_TOOLS = /* @__PURE__ */ new Set([ -+ "list_bots", -+ "message_bot", -+ "stop_bot", -+ "create_bot", -+ "update_bot", -+ "archive_bot", -+ "list_models", -+ "bot_schedules", -+ "create_group", -+ "update_group", -+ "delete_group", -+ "post_to_group", -+ "ask_user", -+ "track_tasks", -+ "request_secret", -+ "list_secrets", -+ "group_relay", -+ "read_own_chat", -+ "read_group_chat", -+ "remember", -+ "forget", -+ "recall", -+ "read_browser", -+ "list_capabilities", -+ "save_capability", -+ "delete_capability", -+ "attach_capability", -+ "detach_capability", -+ "track_links", -+ "project_state", -+ "project_digest", -+ "meeting" -+]); -+var isSilentRegistration = (name) => typeof name === "string" && SILENT_REGISTRATION_TOOLS.has(name); -+var isHiddenNotice = (block) => block?.type === "tool-removal" && (HIDDEN_REMOVED_TOOLS.has(block.toolName) || isConnectorTool(block.toolName) || isPluginTool(block.toolName) || isSilentRegistration(block.toolName)) || block?.type === "tool-addition" && (isConnectorTool(block.toolName) || isPluginTool(block.toolName) || isSilentRegistration(block.toolName)); -+function isHiddenToolRemoval(node) { -+ const content = node?.data?.content; -+ if (!Array.isArray(content) || content.length === 0) return false; -+ return content.every(isHiddenNotice); -+} -+function ContextCell({ node, useRoster }) { -+ useRoster?.((roster) => (roster.pluginTools ?? []).join("\n")); -+ if (isHiddenToolRemoval(node)) return null; -+ const content = Array.isArray(node?.data?.content) ? node.data.content : []; -+ const parts = []; -+ for (const block of content) { -+ if (isHiddenNotice(block)) continue; -+ if (block?.type === "tool-removal" && block.toolName) parts.push(`Tool removed: ${block.toolName}`); -+ else if (block?.type === "tool-addition" && block.toolName) parts.push(`Tool added: ${block.toolName}`); -+ } -+ if (parts.length === 0) return null; -+ return (0, import_react9.createElement)("div", { className: "bt-event" }, parts.join(" · ")); - } --var FIDGETS = ["hop", "sway", "stretch", "look"]; --function BotMark({ bot, look: given, size = 36, state = "idle", live = false, gaze = false, pokeable = false, label }) { -- const look = given ?? lookOf(bot); -- const ref = (0, import_react2.useRef)(null); -- const maskId = (0, import_react2.useRef)(""); -- if (maskId.current === "") maskId.current = `bt-mark-${nextMarkSerial()}`; -- const [poked, setPoked] = (0, import_react2.useState)(false); -- useGaze(ref, gaze); -- const common = { -- ref, -- className: poked ? "bt-mark bt-mark-poke" : "bt-mark", -- "data-shape": look.image ? "image" : shapeId(look.shape), -- "data-state": STATES.includes(state) ? state : "idle", -- "data-live": live ? "" : void 0, -- "data-fidget": live ? FIDGETS[fnv1a(`${bot?.id ?? ""}:fidget`) % FIDGETS.length] : void 0, -- "data-gaze": gaze ? "" : void 0, -- "data-poke": pokeable ? "" : void 0, -- style: { width: size, height: size, ...live ? { "--bt-delay": `${-(fnv1a(String(bot?.id ?? "")) % 5e3) / 1e3}s`, "--bt-fidget-delay": `${-(fnv1a(`${bot?.id ?? ""}:when`) % 9e3) / 1e3}s` } : {} }, -- role: label ? "img" : void 0, -- "aria-label": label, -- "aria-hidden": label ? void 0 : true, -- onClick: pokeable ? () => setPoked(true) : void 0, -- onAnimationEnd: poked ? (event) => { -- if (/^bt-poke/.test(event.animationName)) setPoked(false); -- } : void 0 -- }; -- if (look.image) return (0, import_react2.createElement)("span", common, (0, import_react2.createElement)("img", { className: "bt-mark-img", src: look.image, alt: "", draggable: false })); -- const box = shapeOf(look.shape).size; -- return (0, import_react2.createElement)("span", common, (0, import_react2.createElement)("svg", { viewBox: `0 0 ${box} ${box}`, dangerouslySetInnerHTML: { __html: characterMarkup(look.shape, look.color).replaceAll(MASK_ID, maskId.current) } })); -+var SEPARATOR_GAP_MS = 30 * 60 * 1e3; -+function clockLabel(time, now = Date.now()) { -+ const date = new Date(time); -+ const midnight = (value) => new Date(value.getFullYear(), value.getMonth(), value.getDate()).getTime(); -+ const days = Math.round((midnight(new Date(now)) - midnight(date)) / 864e5); -+ const clock4 = date.toLocaleTimeString(dateLocale(), { hour: "numeric", minute: "2-digit" }); -+ if (days === 0) return t("Today {time}", { time: clock4 }); -+ if (days === 1) return t("Yesterday {time}", { time: clock4 }); -+ const sameYear = date.getFullYear() === new Date(now).getFullYear(); -+ return `${date.toLocaleDateString(dateLocale(), { weekday: "short", month: "short", day: "numeric", ...sameYear ? {} : { year: "numeric" } })} ${clock4}`; -+} -+function nextTurnStart(turns, turn) { -+ let next; -+ for (const key in turns) { -+ const candidate = Number(key); -+ if (candidate > turn && turns[key].first !== void 0 && (next === void 0 || candidate < next)) next = candidate; -+ } -+ return next === void 0 ? void 0 : turns[next].first; -+} -+function openingTurn(turns, firstTurns, hiddenTurns) { -+ if (!turns || !firstTurns) return null; -+ const start = nextTurnStart(turns, -Infinity); -+ if (start === void 0) return null; -+ const turn = Number(Object.keys(turns).filter((key) => turns[key].first === start)[0]); -+ const listed = firstTurns.split(",").map(Number); -+ const earlier = listed.filter((entry) => entry < turn); -+ return listed.includes(turn) && earlier.every((entry) => hiddenTurns?.includes(entry)) ? { turn, start } : null; ++var texts = (step) => step.blocks.filter((block) => block.kind === "text" && block.text.trim() !== ""); ++var thinkingLive = (step) => step.status === "running" && texts(step).length === 0 && step.blocks.some((block) => block.kind === "reasoning"); ++var stepReasoning = (step) => step.blocks.filter((block) => block.kind === "reasoning").map((block) => block.text).join("\n").trim(); ++ ++// src/client/cells.js ++var import_react10 = require("react"); ++var import_react_dom = require("react-dom"); ++var import_dsh_client_ui_primitives = require("@deepseek-ai/dsh-client-ui-primitives"); ++var mdLabels = () => ({ code: { copyLabel: t("Copy"), copiedLabel: t("Copied") }, footnotes: t("Footnotes") }); ++function Bubbles({ texts: texts2, roster, selfId, actions, streaming, sessionId, msgKey, msgKeys, useUi, lead, mentions }) { ++ const onClick = (event) => { ++ const target = mentionTarget(event); ++ if (target === null) return; ++ event.preventDefault(); ++ event.stopPropagation(); ++ actions.openSession(target); ++ }; ++ const reactions = useUi ? useUi((state) => msgKey || msgKeys ? msgKeys ? state.reactions ?? null : state.reactions?.[msgKey] ?? null : null) : null; ++ const reactKey = (index) => msgKeys ? msgKeys[index] : msgKey; ++ const reactIndex = (index) => msgKeys ? 0 : index; ++ const shown = (texts2 ?? []).filter((text2) => String(text2 ?? "").trim() !== ""); ++ if (shown.length === 0) return null; ++ return (0, import_react10.createElement)( ++ "div", ++ { className: "bt-astack", "data-lead": lead ? "" : void 0, onClickCapture: onClick }, ++ shown.map((text2, index) => { ++ const key = reactKey(index); ++ const picked = key ? (msgKeys ? reactions?.[key] : reactions)?.[reactIndex(index)] ?? [] : []; ++ const last = index === shown.length - 1; ++ const live = streaming && last; ++ return (0, import_react10.createElement)( ++ "div", ++ { key: index, className: "bt-msg" }, ++ (0, import_react10.createElement)( ++ "div", ++ { className: "bt-msg-line" }, ++ lead && last ? lead : null, ++ (0, import_react10.createElement)( ++ "div", ++ { className: "bt-bubble" }, ++ (0, import_react10.createElement)(import_dsh_client_ui_primitives.MarkdownText, { text: linkMentions(text2, roster.bots, selfId), streaming: live, labels: mdLabels(), variant: "compact", fileMentions: mentions }) ++ ), ++ live ? null : (0, import_react10.createElement)(MessageTools, { text: text2, name: roster.byId[selfId]?.name ?? t("Bot"), sessionId, react: key ? (emoji) => actions.toggleReaction(key, reactIndex(index), emoji) : void 0, actions }) ++ ), ++ picked.length ? (0, import_react10.createElement)("div", { className: "bt-reacts" }, picked.map((emoji) => (0, import_react10.createElement)("button", { ++ key: emoji, ++ type: "button", ++ className: "bt-react", ++ "aria-label": t("Remove reaction {emoji}", { emoji }), ++ onClick: () => actions.toggleReaction(key, reactIndex(index), emoji) ++ }, emoji))) : null ++ ); ++ }) ++ ); +} -+var unseenSince = (visit, time) => typeof visit?.seen === "number" && time > visit.seen && time <= visit.entered; -+function movedOnSince(visit, turns, hiddenTurns) { -+ if (typeof visit?.entered !== "number") return false; -+ for (const key in turns ?? {}) { -+ if (hiddenTurns?.includes(Number(key))) continue; -+ if (turns[key].first !== void 0 && turns[key].first > visit.entered) return true; ++var WAITING_OTHERS_NOTICE = "Waiting for the owner's approval"; ++var REACTIONS = ["👍", "❤️", "😂", "😮", "😢", "🙏"]; ++function leaveNoticeCoveredByClose(room2, text2) { ++ if (room2?.origin !== "face-to-face" || room2.face?.status !== "closed") return false; ++ const match = String(text2 ?? "").trim().match(/^(.+) left(?: the room)?$/); ++ if (!match) return false; ++ const who = match[1]; ++ for (const participant of room2.participants ?? []) { ++ if (participant?.remote !== true || participant.leftSeq === void 0) continue; ++ const name = String(participant.ownerName || participant.displayName || participant.name || "").trim(); ++ if (name && (who === name || who.endsWith(` (${name})`))) return true; + } + return false; +} -+var settledVisit = (visit) => typeof visit?.seen === "number" && visit.seen < visit.entered ? { ...visit, seen: visit.entered } : visit; -+function TurnTailCell({ turn: location2, sessionId, useTurnClock, useUi, actions }) { -+ const turn = location2.turn; -+ const time = location2.end.time; -+ (0, import_react9.useEffect)(() => { -+ actions.noteTurnTime(sessionId, turn, "end", time); -+ }, [sessionId, turn, time]); -+ const label = useTurnClock((state) => { -+ const start = nextTurnStart(state[sessionId] ?? {}, turn); -+ return start !== void 0 && start - time >= SEPARATOR_GAP_MS ? clockLabel(start) : ""; -+ }); -+ const visit = useUi((state) => state.visits?.[sessionId]); -+ const fresh = useTurnClock((state) => { -+ if (typeof visit?.seen !== "number" || time > visit.seen) return false; -+ const start = nextTurnStart(state[sessionId] ?? {}, turn); -+ return start !== void 0 && start > visit.seen && start <= visit.entered; -+ }); -+ if (!label && !fresh) return null; -+ return (0, import_react9.createElement)( -+ import_react9.Fragment, -+ null, -+ label ? (0, import_react9.createElement)("div", { className: "bt-time-sep", role: "separator" }, label) : null, -+ fresh ? (0, import_react9.createElement)(NewDivider) : null -+ ); -+} -+function Shimmer({ children }) { -+ return (0, import_react9.createElement)("span", { className: "bt-shimmer" }, children); -+} -+var PHRASES = { -+ thinking: ["Thinking about it", "Weighing the options", "Working out a plan", "Sorting out the details"], -+ working: ["On it", "Busy with it", "Moving ahead", "Taking care of it", "Making headway"] -+}; -+var TOOL_PHRASES = [ -+ [/web_?search|search_?web/i, "Searching online"], -+ [/fetch|browse|browser|web/i, "Reading a page"], -+ [/shell|bash|pwsh|powershell|exec|command|terminal/i, "Running shell commands"], -+ [/^(read|view|cat)\b|read_/i, "Opening a file"], -+ [/grep|glob|list_dir|^ls$|find|search/i, "Looking through files"], -+ [/write|edit|patch|apply|replace/i, "Writing changes"], -+ [/image|photo|draw/i, "Working on an image"] -+]; -+var GROUP_PHRASES = { create_group: "Creating a group", update_group: "Updating the group", delete_group: "Deleting the group", post_to_group: "Posting in the group" }; -+function toolActivity(name, arg, findBot, self) { -+ switch (name) { -+ case "ask_user": -+ return null; -+ case "request_secret": -+ return null; -+ case "list_secrets": -+ return { label: t("Checking the keys"), state: "searching" }; -+ case "ask_user_question": -+ return { label: t("Waiting for your answer"), state: "alert" }; -+ case "group_relay": -+ return { relay: true, state: "orbit" }; -+ case "message_bot": { -+ const target = findBot(arg("to")); -+ return { label: target ? t("Asking {name}", { name: target.name }) : t("Asking another Bot"), state: "sending" }; -+ } -+ case "stop_bot": { -+ const target = findBot(arg("bot")); -+ return { label: target ? t("Stopping {name}", { name: target.name }) : t("Stopping another Bot"), state: "sending" }; -+ } -+ case "create_bot": -+ return { label: t("Creating {name}", { name: arg("name") ?? t("a Bot") }), state: "sending" }; -+ case "update_bot": { -+ const target = findBot(arg("name")) ?? findBot(arg("bot")) ?? (arg("bot") ? void 0 : self); -+ return { label: t("Updating {name}", { name: target?.name ?? t("a Bot") }), state: "sending" }; -+ } -+ case "list_bots": -+ return { label: t("Checking the team"), state: "searching" }; -+ case "read_group_chat": -+ return { label: t("Reading {name}", { name: arg("group") ?? t("a group chat") }), state: "searching" }; -+ case "read_own_chat": -+ return { label: t("Looking back through the chat"), state: "searching" }; -+ case "remember": -+ return { label: t("Saving to memory"), state: "working" }; -+ case "forget": -+ return { label: t("Updating memory"), state: "working" }; -+ case "recall": -+ return { label: t("Checking memory"), state: "searching" }; -+ default: -+ if (GROUP_PHRASES[name]) return { label: t(GROUP_PHRASES[name]), state: "sending" }; -+ const phrase = TOOL_PHRASES.find(([pattern]) => pattern.test(name))?.[1]; -+ return { label: phrase ? t(phrase) : null, state: toolState(name) }; -+ } - } --function StarIcon({ size = 8 }) { -- return (0, import_react2.createElement)( -- "svg", -- { width: size, height: size, viewBox: "0 0 10 10", "aria-hidden": true }, -- (0, import_react2.createElement)("path", { d: "M5 .6l1.3 2.8 3 .3-2.3 2 .7 3L5 7.2 2.3 8.7l.7-3L.7 3.7l3-.3z", fill: "#fff" }) -- ); -+function workPhrase(tool) { -+ if (!tool) return t("Thinking about it"); -+ return toolActivity(tool, () => void 0, () => void 0)?.label ?? t("Using {tool}", { tool }); - } --function AdminStar() { -- return (0, import_react2.createElement)("span", { className: "bt-admin-star", role: "img", "aria-label": t("Admin") }, (0, import_react2.createElement)(StarIcon, { size: 7 })); -+function workText(tool, step) { -+ return step ? t("{action}: {detail}", { action: workPhrase(tool), detail: step }) : `${workPhrase(tool)}…`; - } --function MainStar() { -- return (0, import_react2.createElement)( -- "svg", -- { viewBox: "0 0 20 20", "aria-hidden": true }, -- (0, import_react2.createElement)("path", { d: "M10 2.4l2.29 5.04 5.51.63-4.09 3.74 1.11 5.42L10 14.5l-4.82 2.73 1.11-5.42L2.2 8.07l5.51-.63z" }) -- ); -+function useLiveNote(actions, sessionId, id, entry) { -+ const key = entry ? JSON.stringify(entry) : ""; -+ (0, import_react9.useEffect)(() => { -+ if (!key) return void 0; -+ actions.noteLive(sessionId, id, JSON.parse(key)); -+ return () => actions.noteLive(sessionId, id, null); -+ }, [sessionId, id, key]); - } --function useSettled(state) { -- const prev = (0, import_react2.useRef)(state); -- const [settled, setSettled] = (0, import_react2.useState)(false); -- (0, import_react2.useEffect)(() => { -- const was = prev.current; -- prev.current = state; -- if (state !== "idle") { -- setSettled(false); -- return void 0; -+function GroupReplies({ sessionId, callId, done, content, roster, actions, useUi }) { -+ const [live, setLive] = (0, import_react9.useState)({ replies: [] }); -+ (0, import_react9.useEffect)(() => { -+ if (done) return void 0; -+ let stop = false; -+ let timer; -+ let seen = ""; -+ const tick = async () => { -+ const result = await actions.roomProgress(sessionId); -+ if (stop) return; -+ const entry = (result ?? []).find((item) => item.callId === callId) ?? (result ?? [])[0]; -+ const key = entry ? JSON.stringify(entry.replies) : ""; -+ if (entry && key !== seen) { -+ seen = key; -+ setLive({ replies: entry.replies ?? [] }); ++function useDismiss(open, close, refs) { ++ (0, import_react10.useEffect)(() => { ++ if (!open) return void 0; ++ const onDown = (event) => { ++ if (refs.some((ref) => ref.current?.contains(event.target))) return; ++ close(); ++ }; ++ const onKey = (event) => { ++ if (event.key === "Escape") { ++ event.stopPropagation(); ++ close(); + } -+ timer = setTimeout(tick, document.hidden ? 5e3 : 1e3); + }; -+ void tick(); ++ window.addEventListener("mousedown", onDown); ++ window.addEventListener("keydown", onKey, true); + return () => { -+ stop = true; -+ clearTimeout(timer); ++ window.removeEventListener("mousedown", onDown); ++ window.removeEventListener("keydown", onKey, true); + }; -+ }, [done, sessionId, callId]); -+ let replies = live.replies; -+ if (done) { -+ try { -+ replies = JSON.parse(contentText(content)).replies ?? []; -+ } catch { -+ replies = []; - } -- if (was === "idle" || was === "alert") return void 0; -- setSettled(true); -- const timer = setTimeout(() => setSettled(false), 1800); -- return () => clearTimeout(timer); -- }, [state]); -- return settled; --} --function BotAvatar({ bot, size, state = "idle", main, admin, badge = true, live = true, gaze = false, pokeable = false }) { -- const settled = useSettled(state); -- if (bot === void 0) return (0, import_react2.createElement)(BotMark, { size }); -- const badgeKind = !badge ? null : state === "alert" ? "alert" : state !== "idle" ? "working" : admin ? "admin" : main ? "main" : null; -- return (0, import_react2.createElement)( -- "span", -- { className: "bt-mark", style: { width: size, height: size, "--bt-size": `${size}px` } }, -- (0, import_react2.createElement)(BotMark, { bot, size, state: settled ? "done" : state, live, gaze, pokeable }), -- badgeKind === "working" ? (0, import_react2.createElement)("span", { className: "bt-badge bt-badge-working", "aria-label": t("Working"), role: "img" }) : null, -- badgeKind === "alert" ? (0, import_react2.createElement)("span", { className: "bt-badge bt-badge-alert", "aria-label": t("Waiting for your answer"), role: "img" }, "?") : null, -- badgeKind === "admin" ? (0, import_react2.createElement)("span", { className: "bt-badge bt-badge-admin", "aria-label": t("Admin"), role: "img" }, (0, import_react2.createElement)(StarIcon)) : null, -- badgeKind === "main" ? (0, import_react2.createElement)("span", { className: "bt-badge bt-badge-main", "aria-label": t("Main Bot"), role: "img" }, (0, import_react2.createElement)(MainStar)) : null -- ); --} --function clusterSeats(count, frame) { -- if (count === 1) return [[0, 0, frame]]; -- if (count === 2) return [[0, 0, frame * 2 / 3], [frame / 3, frame / 3, frame * 2 / 3]]; -- if (count === 3) { -- const size = frame * 5 / 9; -- const rest = frame - size; -- return [[rest / 2, 0, size], [0, rest, size], [rest, rest, size]]; -- } -- const half = frame / 2; -- return [[0, 0, half], [half, 0, half], [0, half, half], [half, half, half]]; --} --function RoomAvatar({ room: room2, roster, size = 36 }) { -- const members = room2.members.map((id) => roster.byId[id]).filter(Boolean); -- const people = members.length === 1 ? [members[0], null] : members; -- const seat = (bot, seatSize, key) => bot ? (0, import_react2.createElement)(BotMark, { key, bot, size: seatSize }) : (0, import_react2.createElement)("span", { key, className: "bt-cluster-you", style: { width: seatSize * 0.92, height: seatSize * 0.92, margin: seatSize * 0.04 } }); -- if (people.length === 0) return (0, import_react2.createElement)(BotMark, { size }); -- if (size <= 20 && people.length > 1) { -- return (0, import_react2.createElement)( -- "span", -- { className: "bt-stack", style: { "--bt-stack-size": `${size}px` } }, -- people.slice(0, 3).map((bot, index) => seat(bot, size, bot?.id ?? `you-${index}`)), -- people.length > 3 ? (0, import_react2.createElement)("span", { className: "bt-stack-more" }, `+${people.length - 3}`) : null -- ); - } -- const seats = clusterSeats(Math.min(people.length, 4), size); -- const overflow = people.length > 4 ? people.length - 3 : 0; -- const gap = size / 14; -- const rings = seats.map(([x, y, s]) => ({ cx: x + s / 2, cy: y + s / 2, r: s * 0.48 + gap })); -- return (0, import_react2.createElement)( -- "span", -- { className: "bt-cluster", style: { width: size, height: size } }, -- seats.map(([x, y, s], index) => { -- const holes2 = rings.slice(index + 1).filter((ring) => Math.hypot(ring.cx - x - s / 2, ring.cy - y - s / 2) < ring.r + s * 0.46).map((ring) => `radial-gradient(circle at ${ring.cx - x}px ${ring.cy - y}px,transparent ${ring.r}px,#000 ${ring.r + 0.5}px)`); -- const style = { left: x, top: y, width: s, height: s }; -- if (holes2.length > 0) Object.assign(style, { WebkitMaskImage: holes2.join(","), maskImage: holes2.join(","), WebkitMaskComposite: "source-in", maskComposite: "intersect" }); -- if (overflow > 0 && index === 3) { -- return (0, import_react2.createElement)("span", { key: "more", className: "bt-cluster-more", style: { ...style, width: s * 0.92, height: s * 0.92, margin: s * 0.04, fontSize: Math.max(8, Math.round(s * 0.36)) } }, `+${overflow}`); -+ if (replies.length === 0) return null; -+ const room2 = roster.roomsById[sessionId]; -+ return (0, import_react9.createElement)( ++ }, [open]); ++} ++function MessageTools({ text: text2, name, sessionId, react, actions }) { ++ const [panel, setPanel] = (0, import_react10.useState)(null); ++ const [place, setPlace] = (0, import_react10.useState)(null); ++ const [copied, setCopied] = (0, import_react10.useState)(false); ++ const tools = (0, import_react10.useRef)(null); ++ const pop = (0, import_react10.useRef)(null); ++ const close = () => setPanel(null); ++ useDismiss(panel !== null, close, [tools, pop]); ++ const open = (kind, event) => { ++ if (panel === kind) { ++ close(); ++ return; ++ } ++ const rect = event.currentTarget.getBoundingClientRect(); ++ const below = rect.bottom + 220 < window.innerHeight; ++ setPlace(kind === "react" ? { left: rect.left + rect.width / 2, top: rect.top - 6, "--bt-origin": "bottom center" } : { left: Math.min(rect.left, window.innerWidth - 208), ...below ? { top: rect.bottom + 4 } : { bottom: window.innerHeight - rect.top + 4, "--bt-origin": "bottom left" } }); ++ setPanel(kind); ++ }; ++ const copy2 = () => { ++ void navigator.clipboard?.writeText(text2).then(() => { ++ setCopied(true); ++ setTimeout(() => setCopied(false), 1e3); ++ }); ++ }; ++ const download2 = () => { ++ const url = URL.createObjectURL(new Blob([text2], { type: "text/markdown" })); ++ const link = Object.assign(document.createElement("a"), { href: url, download: `${name}-message.md` }); ++ link.click(); ++ setTimeout(() => URL.revokeObjectURL(url), 1e3); ++ }; ++ const item = (label, Icon, run) => (0, import_react10.createElement)("button", { key: label, type: "button", role: "menuitem", onClick: () => { ++ close(); ++ run(); ++ } }, (0, import_react10.createElement)(Icon), label); ++ return (0, import_react10.createElement)( + "div", -+ { className: "bt-group-replies" }, -+ replies.map((reply, index) => { -+ const stored = roster.byId[reply.botId]; -+ const resolved = reply.remote ? stored : rosterBot(roster, reply.botId) ?? stored; -+ const bot = resolved ?? { id: reply.botId, name: reply.name, color: reply.color ?? "gray" }; -+ if (reply.kind === "user") { -+ const who = reply.name && reply.name !== "User" ? reply.name : reply.authorId; -+ if (reply.other) { -+ return (0, import_react9.createElement)(PersonMessage, { key: `user-${index}`, name: who, texts: [remoteMessageText(reply.text)], roster, selfId: sessionId, actions, sessionId, msgKey: callId ? `${sessionId}:${callId}:${index}` : void 0, useUi }); -+ } -+ return (0, import_react9.createElement)( -+ "div", -+ { key: `user-${index}`, className: "bt-urow", "data-author": reply.authorId, "data-author-name": who }, -+ who ? (0, import_react9.createElement)("span", { className: "bt-uauthor" }, who) : null, -+ (0, import_react9.createElement)( -+ "div", -+ { className: "bt-ububble" }, -+ (0, import_react9.createElement)(import_dsh_client_ui_primitives.MarkdownText, { text: remoteMessageText(reply.text), labels: mdLabels(), variant: "compact" }) -+ ), -+ room2?.origin === "face-to-face" ? (0, import_react9.createElement)("span", { className: "bt-lead", title: who || void 0 }, (0, import_react9.createElement)(PersonMark, { name: who || void 0 })) : null -+ ); -+ } -+ if (reply.kind === "failed") { -+ return (0, import_react9.createElement)( -+ "div", -+ { key: `${reply.botId}-${index}`, className: "bt-event bt-event-error", role: "status" }, -+ t("{name} could not answer: {reason}", { name: bot.name, reason: failureLabel(reply.code, reply.text) }) -+ ); -+ } -+ if (reply.kind === "lookup") { -+ return (0, import_react9.createElement)( -+ "div", -+ { key: `${reply.botId}-${index}`, className: "bt-event" }, -+ reply.text ? t("{name} looked up its own chat for “{text}”", { name: bot.name, text: reply.text }) : t("{name} looked up its own chat", { name: bot.name }) -+ ); -+ } -+ if (reply.kind === "missed") { -+ return (0, import_react9.createElement)("div", { key: `${reply.botId}-${index}`, className: "bt-event" }, t("{name} didn't answer in time", { name: bot.name })); -+ } -+ if (reply.kind === "resumed") { -+ return (0, import_react9.createElement)("div", { key: `${reply.botId}-${index}`, className: "bt-event" }, t("{name} was cut off by a restart and picked its work back up", { name: bot.name })); -+ } -+ if (reply.kind === "cut") { -+ return (0, import_react9.createElement)("div", { key: `${reply.botId}-${index}`, className: "bt-event" }, t("{name} was cut off by a restart again and stopped", { name: bot.name })); -+ } -+ if (reply.kind === "approval" || reply.kind === "working") { -+ return (0, import_react9.createElement)( -+ "div", -+ { key: `${reply.botId}-${index}`, className: "bt-event" }, -+ (0, import_react9.createElement)( -+ "button", -+ { type: "button", className: "bt-event-whole", onClick: () => actions.showDetails(sessionId) }, -+ (0, import_react9.createElement)(BotMark, { bot, size: 16 }), -+ (0, import_react9.createElement)("span", { className: "bt-event-name" }, t(reply.kind === "working" ? "{name} started on it" : "{name} paused for your approval", { name: bot.name })) -+ ) -+ ); -+ } -+ if (reply.kind === "stopped") { -+ return (0, import_react9.createElement)("div", { key: `${reply.botId}-${index}`, className: "bt-event" }, t("You stopped {name}'s work", { name: bot.name })); -+ } -+ if (reply.kind === "notice" && room2?.origin === "face-to-face" && String(reply.text ?? "").trim() === WAITING_OTHERS_NOTICE) return null; -+ if ((reply.kind === "notice" || reply.kind === "left") && leaveNoticeCoveredByClose(room2, reply.text)) return null; -+ if (reply.kind === "notice" || reply.kind === "joined" || reply.kind === "left" || reply.kind === "started") { -+ return (0, import_react9.createElement)("div", { key: `notice-${index}`, className: "bt-event", "data-flybot": "face-notice" }, remoteMessageText(reply.text)); -+ } -+ const remote = reply.remote || reply.handle && !roster.byId[reply.botId]; -+ if (remote) { -+ return (0, import_react9.createElement)(RemoteMessage, { -+ key: `${reply.botId || reply.handle}-${index}`, -+ source: { remote: { handle: reply.handle || reply.botId }, senderName: reply.name || reply.who, senderSessionId: reply.botId }, -+ text: remoteMessageText(reply.text), -+ roster, -+ actions, -+ sessionId, -+ msgKey: callId ? `${sessionId}:${callId}:${index}` : void 0, -+ useUi -+ }); - } -- const bot = people[index]; -- return (0, import_react2.createElement)("span", { key: bot?.id ?? "you", style }, seat(bot, s)); -+ return (0, import_react9.createElement)(GroupMessage, { key: `${reply.botId}-${index}`, bot, text: reply.text, room: room2, roster, actions, msgKey: callId ? `${sessionId}:${callId}:${index}` : void 0, useUi, seq: reply.seq }); - }) - ); - } -- --// src/client/icons.js --var import_react3 = require("react"); --function PlusIcon() { -- return (0, import_react3.createElement)("svg", { width: 18, height: 18, viewBox: "0 0 20 20", "aria-hidden": true }, (0, import_react3.createElement)("path", { d: "M10 3.5v13M3.5 10h13", stroke: "currentColor", strokeWidth: 1.6, strokeLinecap: "round" })); --} --function SearchIcon() { -- return (0, import_react3.createElement)("svg", { width: 16, height: 16, viewBox: "0 0 20 20", "aria-hidden": true }, (0, import_react3.createElement)("circle", { cx: 8.5, cy: 8.5, r: 5.5, stroke: "currentColor", strokeWidth: 1.6, fill: "none" }), (0, import_react3.createElement)("path", { d: "M13 13l4 4", stroke: "currentColor", strokeWidth: 1.6, strokeLinecap: "round" })); -+var DRAWN = /* @__PURE__ */ new Set(["message_bot", "stop_bot", "create_bot", "update_bot", "group_relay", "create_group", "update_group", "delete_group", "post_to_group", "ask_user", "request_secret", "ask_user_question", "read_group_chat", "remember", "forget"]); -+var CARDS = /* @__PURE__ */ new Set(["ask_user", "request_secret"]); -+function callTree(root) { -+ const calls = []; -+ const seen = /* @__PURE__ */ new Set(); -+ const visit = (call4) => { -+ const id = call4.callId ?? call4; -+ if (seen.has(id)) return; -+ seen.add(id); -+ calls.push(call4); -+ for (const child of call4.subCalls ?? []) visit(child); -+ }; -+ visit(root); -+ return calls; - } --function PeopleIcon() { -- return (0, import_react3.createElement)( -- "svg", -- { width: 14, height: 14, viewBox: "0 0 20 20", fill: "none", stroke: "currentColor", strokeWidth: 1.5, strokeLinecap: "round", "aria-hidden": true }, -- (0, import_react3.createElement)("circle", { cx: 7.5, cy: 7, r: 3 }), -- (0, import_react3.createElement)("path", { d: "M2 16.5c.6-2.8 2.8-4.5 5.5-4.5s4.9 1.7 5.5 4.5" }), -- (0, import_react3.createElement)("path", { d: "M13 4.3a3 3 0 0 1 0 5.4M15 12.4c1.6.6 2.6 2 3 4.1" }) -- ); -+function useShownCards(actions, sessionId, callIds) { -+ const key = JSON.stringify(callIds); -+ (0, import_react9.useLayoutEffect)(() => { -+ const ids = JSON.parse(key); -+ if (ids.length === 0 || !actions.noteCards) return void 0; -+ actions.noteCards(sessionId, ids, 1); -+ return () => actions.noteCards(sessionId, ids, -1); -+ }, [sessionId, key]); - } --function ArrowUpIcon() { -- return (0, import_react3.createElement)("svg", { width: 16, height: 16, viewBox: "0 0 20 20", "aria-hidden": true }, (0, import_react3.createElement)("path", { d: "M10 16V4M5 9l5-5 5 5", stroke: "currentColor", strokeWidth: 1.8, fill: "none", strokeLinecap: "round", strokeLinejoin: "round" })); -+function renderCall(call4, { sessionId, roster, actions, useUi, findBot }) { -+ const tool = toolName(call4); -+ const done = call4.kind === "tool-result"; -+ const { text: arg, value: argValue } = toolArgs(call4); -+ const self = roster.byId[sessionId]; -+ switch (tool) { -+ case "message_bot": { -+ const target = findBot(arg("to")) ?? { name: arg("to") ?? "…", color: "gray" }; -+ if (done && call4.isError) return (0, import_react9.createElement)("div", { className: "bt-event" }, t("Couldn't message {name}", { name: target.name })); -+ return (0, import_react9.createElement)(EventLine, { prefix: t("Messaged"), bots: [target], onOpen: (bot) => bot.id && actions.openExchange(self?.id ?? sessionId, bot.id), whole: true }); -+ } -+ case "stop_bot": { -+ const target = findBot(arg("bot")) ?? { name: arg("bot") ?? "…", color: "gray" }; -+ if (done && call4.isError) return (0, import_react9.createElement)("div", { className: "bt-event" }, t("Couldn't stop {name}", { name: target.name })); -+ return (0, import_react9.createElement)(EventLine, { prefix: t("Stopped"), bots: [target], onOpen: (bot) => bot.id && actions.openExchange(self?.id ?? sessionId, bot.id), whole: true }); -+ } -+ case "create_bot": { -+ const created = findBot(arg("name")); -+ if (!done || created === void 0) return null; -+ return (0, import_react9.createElement)(EventLine, { prefix: t("Created"), bots: [created], onOpen: (bot) => actions.openSession(bot.id) }); -+ } -+ case "update_bot": { -+ const target = findBot(arg("name")) ?? findBot(arg("bot")) ?? (arg("bot") ? void 0 : self); -+ return done && target ? (0, import_react9.createElement)(EventLine, { prefix: t("Updated"), bots: [target], onOpen: (bot) => actions.openSession(bot.id) }) : null; -+ } -+ case "group_relay": -+ return (0, import_react9.createElement)(GroupReplies, { sessionId, callId: call4.callId, done, content: call4.content, roster, actions, useUi }); -+ case "create_group": -+ case "update_group": -+ case "delete_group": -+ case "post_to_group": { -+ const verbs = { -+ create_group: ["Created group", "Couldn't create the group"], -+ update_group: ["Updated group", "Couldn't update the group"], -+ delete_group: ["Deleted group", "Couldn't delete the group"], -+ post_to_group: ["Posted in", "Couldn't post in the group"] -+ }[tool]; -+ if (!done) return null; -+ const result = contentText(call4.content); -+ const named = /^(Created group|Updated|Nothing changed in|Deleted|Posted in) "([^"]+)"/.exec(result); -+ if (call4.isError || !named) return (0, import_react9.createElement)("div", { className: "bt-event", title: result }, t(verbs[1])); -+ const name = named[2]; -+ const room2 = roster.rooms.find((entry) => entry.name === name); -+ return (0, import_react9.createElement)(GroupEvent, { prefix: t(verbs[0]), room: tool === "delete_group" ? void 0 : room2, name, roster, actions }); -+ } -+ case "ask_user": { -+ const question = roster.questions?.[sessionId]; -+ if (!question || question.callId !== call4.callId) return (0, import_react9.createElement)("span", { className: "bt-q-gone", hidden: true }); -+ return (0, import_react9.createElement)(PendingQuestion, { sessionId, question, actions, inline: true }); -+ } -+ case "request_secret": -+ return (0, import_react9.createElement)(SecretToolCell, { sessionId, callId: call4.callId, name: arg("name"), done, content: call4.content, roster, actions }); -+ case "ask_user_question": { -+ if (!done) return null; -+ let parsed = null; -+ try { -+ parsed = JSON.parse(contentText(call4.content)); -+ } catch { -+ parsed = null; -+ } -+ const answer = parsed?.answers?.[0]; -+ const question = argValue("questions")?.[0]; -+ if (!answer) return null; -+ return (0, import_react9.createElement)("div", { className: "bt-q-inline" }, (0, import_react9.createElement)(SettledQuestion, { question, answer })); -+ } -+ case "read_group_chat": { -+ if (!done) return null; -+ const room2 = roster.rooms.find((entry) => entry.name === arg("group")); -+ return room2 ? (0, import_react9.createElement)(GroupEvent, { prefix: t("Read"), room: room2, roster, actions }) : null; -+ } -+ case "remember": -+ case "forget": -+ return done && !call4.isError ? (0, import_react9.createElement)(MemoryEvent, { sessionId, scope: arg("scope"), result: contentText(call4.content), roster, actions, findBot }) : null; -+ default: -+ return null; ++ { ref: tools, className: "bt-tools", "data-open": panel !== null || void 0 }, ++ react ? (0, import_react10.createElement)("button", { type: "button", className: "bt-tool", "aria-label": t("Add reaction"), title: t("Add reaction"), "aria-expanded": panel === "react", onClick: (event) => open("react", event) }, (0, import_react10.createElement)(SmileIcon)) : null, ++ sessionId ? (0, import_react10.createElement)("button", { type: "button", className: "bt-tool", "aria-label": t("Reply"), title: t("Reply"), onClick: () => actions.quote(sessionId, text2) }, (0, import_react10.createElement)(ReplyIcon)) : null, ++ (0, import_react10.createElement)("button", { type: "button", className: "bt-tool", "aria-label": t("More message actions"), title: t("More"), "aria-haspopup": "menu", "aria-expanded": panel === "more", onClick: (event) => open("more", event) }, copied ? (0, import_react10.createElement)(CheckIcon) : (0, import_react10.createElement)(DotsIcon)), ++ panel === "react" ? (0, import_react_dom.createPortal)((0, import_react10.createElement)( ++ "div", ++ { ref: pop, className: "bt-react-strip", role: "menu", "aria-label": t("Reactions"), style: place }, ++ REACTIONS.map((emoji) => (0, import_react10.createElement)("button", { key: emoji, type: "button", role: "menuitem", "aria-label": emoji, onClick: () => { ++ close(); ++ react(emoji); ++ } }, emoji)) ++ ), document.body) : null, ++ panel === "more" ? (0, import_react_dom.createPortal)((0, import_react10.createElement)( ++ "div", ++ { ref: pop, className: "bt-menu", role: "menu", "aria-label": t("Message actions"), style: place }, ++ item(t("Copy"), CopyIcon, copy2), ++ item(t("Download"), DownloadIcon, download2) ++ ), document.body) : null ++ ); ++} ++var PEEK_MAX = 160; ++var CUT = "…"; ++var MARKUP = [/^\s{0,3}(?:`{3,}|~{3,})/, /^\s{0,3}([-*_])(?:\s*\1){2,}\s*$/]; ++var isMarkup = (line2) => MARKUP.some((pattern) => pattern.test(line2)); ++var graphemes = (line2) => typeof Intl !== "undefined" && typeof Intl.Segmenter === "function" ? [...new Intl.Segmenter().segment(line2)].map((part) => part.segment) : [...line2]; ++var clip = (line2) => { ++ if (line2.length <= PEEK_MAX) return line2; ++ let kept = ""; ++ for (const part of graphemes(line2)) { ++ if (kept.length + part.length > PEEK_MAX - CUT.length) break; ++ kept += part; + } - } --function MicIcon({ size = 16 }) { -- return (0, import_react3.createElement)( -- "svg", -- { width: size, height: size, viewBox: "0 0 20 20", fill: "none", stroke: "currentColor", strokeWidth: 1.6, strokeLinecap: "round", "aria-hidden": true }, -- (0, import_react3.createElement)("rect", { x: 7, y: 2.5, width: 6, height: 10, rx: 3, fill: "currentColor", stroke: "none" }), -- (0, import_react3.createElement)("path", { d: "M4.5 9.5a5.5 5.5 0 0 0 11 0M10 15v2.5" }) -- ); -+function ToolCell({ node, sessionId, useRoster, useUi, actions }) { ++ return kept + CUT; ++}; ++var onlyMarkup = (lines) => lines.length === 1 ? t("{n} line of reasoning", { n: lines.length }) : t("{n} lines of reasoning", { n: lines.length }); ++var peekLine = (text2) => { ++ const lines = text2.split("\n").map((part) => part.replace(/\s+/g, " ").trim()).filter((part) => part !== ""); ++ const prose = lines.find((line2) => !isMarkup(line2)); ++ return prose === void 0 ? onlyMarkup(lines) : clip(prose); ++}; ++function ThinkingRow({ text: text2 }) { ++ if (typeof text2 !== "string" || text2.trim() === "") return null; ++ const peek = peekLine(text2); ++ return (0, import_react10.createElement)( ++ "details", ++ { className: "bt-think" }, ++ (0, import_react10.createElement)( ++ "summary", ++ null, ++ (0, import_react10.createElement)("span", { className: "bt-think-caret", "aria-hidden": true }, "›"), ++ t("Thought it through"), ++ peek ? (0, import_react10.createElement)("span", { className: "bt-think-peek" }, peek) : null ++ ), ++ (0, import_react10.createElement)("div", { className: "bt-think-text" }, text2) ++ ); ++} ++function useFileMentions({ node, openFile, fileMentions, useTurnData }) { ++ const tail = useTurnData?.("turn-tail"); ++ const location2 = node.location; ++ const turn = location2?.kind === "turn" || location2?.kind === "step" ? location2.turn : void 0; ++ const seq = node.data.finalNode?.seq; ++ return (0, import_react10.useMemo)(() => { ++ if (typeof fileMentions !== "function" || turn?.status !== "closed" || seq === void 0) return void 0; ++ return tail?.closing?.finalNode.seq === seq ? fileMentions({ turn, seq, openFile }) : void 0; ++ }, [fileMentions, openFile, seq, tail, turn]); ++} ++function useClosingText(node, useTurnData) { ++ const tail = useTurnData?.("turn-tail"); ++ const seq = node.data.finalNode?.seq; ++ if (!tail?.closing || seq === void 0) return true; ++ return tail.closing.finalNode?.seq === seq; ++} ++function friendLead(bot, texts2) { ++ if (bot?.remote === void 0 || texts2.length === 0) return void 0; ++ if (texts2.every((text2) => /^Not sent:/.test(text2.trim()))) return void 0; ++ return (0, import_react10.createElement)("span", { className: "bt-lead", title: bot.name, "data-flybot": "friend-lead" }, (0, import_react10.createElement)(BotMark, { bot, size: 22 })); ++} ++function AssistantCell({ node, groupPart, sessionId, openFile, fileMentions, useTurnData, useRoster, useUi, useTurnSteps, useTurnContext, actions }) { + const roster = useRoster((value) => value); -+ const root = node.data.root; -+ const turn = node.location?.turn?.turn ?? root.turn; -+ const hidden = isHiddenTurn(roster, sessionId, turn); -+ const findBot = (nameOrId) => roster.byId[nameOrId] ?? botByName(roster.bots, nameOrId); -+ const calls = hidden ? [] : callTree(root); -+ const drawn = calls.filter((call4) => DRAWN.has(toolName(call4))); -+ const running = root.kind === "tool-result" ? void 0 : calls.findLast((call4) => call4.kind !== "tool-result"); -+ const note = running ? toolActivity(toolName(running), toolArgs(running).text, findBot, roster.byId[sessionId]) : null; -+ useLiveNote(actions, sessionId, `tool:${root.callId ?? node.key}`, note); -+ useShownCards(actions, sessionId, drawn.filter((call4) => CARDS.has(toolName(call4)) && call4.callId).map((call4) => call4.callId)); -+ if (drawn.length === 0) return null; -+ const env = { sessionId, roster, actions, useUi, findBot }; -+ return (0, import_react9.createElement)(import_react9.Fragment, null, drawn.map((call4, index) => (0, import_react9.createElement)(import_react9.Fragment, { key: call4.callId ?? index }, renderCall(call4, env)))); - } --function WaveIcon({ size = 16 }) { -- return (0, import_react3.createElement)( -- "svg", -- { width: size, height: size, viewBox: "0 0 20 20", fill: "currentColor", "aria-hidden": true }, -- [[3, 7.5, 5], [6.5, 4.5, 11], [10, 2.5, 15], [13.5, 5.5, 9], [17, 8, 4]].map(([x, y, height]) => (0, import_react3.createElement)("rect", { key: x, x: x - 1, y, width: 2, height, rx: 1 })) -- ); -+ -+// src/client/awaiting.js -+var AWAIT_REPLY_MS = 10 * 60 * 1e3; -+function awaitingReply(roster, sessionId, now = Date.now()) { -+ const at = roster?.remoteAwaiting?.[sessionId]; -+ if (typeof at !== "number" || roster?.byId?.[sessionId]?.remote === void 0) return false; -+ return now - at < AWAIT_REPLY_MS; - } --function ShareIcon() { -- return (0, import_react3.createElement)( -- "svg", -- { width: 16, height: 16, viewBox: "0 0 20 20", fill: "none", stroke: "currentColor", strokeWidth: 1.6, strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": true }, -- (0, import_react3.createElement)("path", { d: "M10 12.5V3M6.5 6.5L10 3l3.5 3.5M4 11v4.5A1.5 1.5 0 0 0 5.5 17h9a1.5 1.5 0 0 0 1.5-1.5V11" }) -- ); -+ -+// src/client/remote-approval-wait.js -+var REMOTE_APPROVAL_LABEL = "Waiting for their approval"; -+function remoteApprovalPhrase(remote, { tool, inputAt = 0 } = {}) { -+ if (!remote) return null; -+ if (tool && inputAt <= (tool.at ?? 0)) return null; -+ return { key: "remote-approval", label: REMOTE_APPROVAL_LABEL, state: "thinking" }; - } --function CloseIcon() { -- return (0, import_react3.createElement)("svg", { width: 16, height: 16, viewBox: "0 0 20 20", "aria-hidden": true }, (0, import_react3.createElement)("path", { d: "M5 5l10 10M15 5L5 15", stroke: "currentColor", strokeWidth: 1.6, strokeLinecap: "round" })); -+ -+// src/client/activity-row-host.js -+var RUNNING_HOST = "[data-chat-flow] [data-chat-running]"; -+var TAIL_HOST = "tail"; -+function findRunningHostIn(root) { -+ if (!root || typeof root.querySelector !== "function") return null; -+ if (typeof root.matches === "function" && root.matches("[data-chat-running]")) return root; -+ if (typeof Element !== "undefined" && root instanceof Element) return root.querySelector("[data-chat-running]"); -+ return root.querySelector(RUNNING_HOST); - } --var ChevronLeftIcon = () => (0, import_react3.createElement)("svg", { width: 16, height: 16, viewBox: "0 0 20 20", fill: "none", stroke: "currentColor", strokeWidth: 1.6, strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": true }, (0, import_react3.createElement)("path", { d: "M12.5 4.5L7 10l5.5 5.5" })); --var ChevronRightIcon = () => (0, import_react3.createElement)("svg", { width: 14, height: 14, viewBox: "0 0 20 20", fill: "none", stroke: "currentColor", strokeWidth: 1.6, strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": true }, (0, import_react3.createElement)("path", { d: "M7.5 4.5L13 10l-5.5 5.5" })); --var lineIcon = (paths) => function LineIcon() { -- return (0, import_react3.createElement)( -- "svg", -- { width: 15, height: 15, viewBox: "0 0 20 20", fill: "none", stroke: "currentColor", strokeWidth: 1.5, strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": true }, -- paths.map((d, index) => (0, import_react3.createElement)("path", { key: index, d })) -- ); --}; --var CLOUD = "M6.5 15.25H5.75a3.25 3.25 0 0 1-.55-6.45 4.75 4.75 0 0 1 9.2-1.1 3.75 3.75 0 0 1 .1 7.55H13.5"; --function CloudDownloadIcon({ done = false }) { -- return (0, import_react3.createElement)( -- "svg", -- { width: 16, height: 16, viewBox: "0 0 20 20", fill: "none", stroke: "currentColor", strokeWidth: 1.5, strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": true }, -- (0, import_react3.createElement)("path", { d: CLOUD }), -- done ? (0, import_react3.createElement)("path", { d: "M7.75 13.25l1.75 1.75 3-3.25" }) : (0, import_react3.createElement)("g", { className: "bt-cloud-arrow" }, (0, import_react3.createElement)("path", { d: "M10 9.75v7" }), (0, import_react3.createElement)("path", { d: "M7.75 14.5L10 16.75l2.25-2.25" })) -- ); -+ -+// src/client/activity-row.js -+var import_react10 = require("react"); -+var import_react_dom2 = require("react-dom"); -+var PHRASE_HOLD_MS = 800; -+var drawPhrase = (pool) => t(pool[Math.min(pool.length - 1, Math.floor(Math.random() * pool.length))]); -+function elapsedLabel(ms) { -+ const minutes = Math.floor(ms / 6e4); -+ if (minutes < 60) return `${Math.max(1, minutes)}m`; -+ const hours = Math.floor(minutes / 60); -+ return minutes % 60 === 0 ? `${hours}h` : `${hours}h ${minutes % 60}m`; - } --var SmileIcon = lineIcon(["M10 3.5a6.5 6.5 0 1 1 0 13 6.5 6.5 0 0 1 0-13z", "M7.3 11.6a3.4 3.4 0 0 0 5.4 0", "M7.75 8.25h.01M12.25 8.25h.01"]); --var ReplyIcon = lineIcon(["M8 5L4 9l4 4", "M4 9h7.5a4.5 4.5 0 0 1 4.5 4.5V15"]); --var DotsIcon = lineIcon(["M5 10h.01M10 10h.01M15 10h.01"]); --var CheckIcon = lineIcon(["M4.5 10.5l3.5 3.5 7.5-8"]); --var ArrowDownIcon = lineIcon(["M10 4v12", "M5 11l5 5 5-5"]); --var DownloadIcon = lineIcon(["M10 3.5v9", "M6.5 9.5L10 13l3.5-3.5", "M4 16h12"]); --var GaugeIcon = lineIcon(["M3.5 13.5a6.5 6.5 0 1 1 13 0", "M10 13.5l3-4", "M10 13.5h.01"]); --var PlugIcon = lineIcon(["M7 3v4M13 3v4", "M5 7h10v2.5a5 5 0 0 1-10 0z", "M10 14.5V17"]); --var CalendarIcon = lineIcon(["M5.5 5h9A1.5 1.5 0 0 1 16 6.5v8a1.5 1.5 0 0 1-1.5 1.5h-9A1.5 1.5 0 0 1 4 14.5v-8A1.5 1.5 0 0 1 5.5 5z", "M4 8.75h12", "M7.25 3.5v3M12.75 3.5v3"]); --var ConnectorIcon = lineIcon(["M7 10a2.5 2.5 0 1 1-5 0 2.5 2.5 0 0 1 5 0z", "M18 10a2.5 2.5 0 1 1-5 0 2.5 2.5 0 0 1 5 0z", "M7 10h6"]); --var SwitchIcon = lineIcon(["M4 7h9.5M10.5 3.5L14 7l-3.5 3.5", "M16 13H6.5M9.5 9.5L6 13l3.5 3.5"]); --var PaperclipIcon = lineIcon(["M15.5 9.5l-5.6 5.6a3.4 3.4 0 0 1-4.8-4.8l6.1-6.1a2.25 2.25 0 0 1 3.2 3.2l-6 6a1.1 1.1 0 0 1-1.6-1.6l5.5-5.5"]); --var ClockIcon = lineIcon(["M10 3.5a6.5 6.5 0 1 1 0 13 6.5 6.5 0 0 1 0-13z", "M10 6.5V10l2.5 1.5"]); --var GearIcon = lineIcon(["M10 7.4a2.6 2.6 0 1 1 0 5.2 2.6 2.6 0 0 1 0-5.2z", "M7.85 4.4l.29-1.87a7.7 7.7 0 0 1 3.72 0l.29 1.87a6 6 0 0 1 1.63.94l1.76-.69a7.7 7.7 0 0 1 1.86 3.23l-1.47 1.18a6 6 0 0 1 0 1.88l1.47 1.18a7.7 7.7 0 0 1-1.86 3.23l-1.76-.69a6 6 0 0 1-1.63.94l-.29 1.87a7.7 7.7 0 0 1-3.72 0l-.29-1.87a6 6 0 0 1-1.63-.94l-1.76.69a7.7 7.7 0 0 1-1.86-3.23l1.47-1.18a6 6 0 0 1 0-1.88L2.6 7.88a7.7 7.7 0 0 1 1.86-3.23l1.76.69a6 6 0 0 1 1.63-.94z"]); --var PinIcon = lineIcon(["M12.5 3l4.5 4.5-3 1.5-2.5 2.5.5 3.5-1.5 1.5L7 13 3.5 16.5", "M7 13l-3.5-3.5L5 8l3.5.5L11 6l1.5-3"]); --function PinMarkIcon() { -- return (0, import_react3.createElement)( -- "svg", -- { width: 12, height: 12, viewBox: "0 0 20 20", fill: "currentColor", "aria-hidden": true }, -- (0, import_react3.createElement)( -- "g", -- { transform: "rotate(45 10 10)" }, -- (0, import_react3.createElement)("path", { d: "M8 2.5h4a.9.9 0 0 1 0 1.8h-.4v4.1c0 1.4 1.1 2.6 2.6 3.1l.7.3v1.5H5.1v-1.5l.7-.3c1.5-.5 2.6-1.7 2.6-3.1V4.3H8a.9.9 0 0 1 0-1.8z" }), -- (0, import_react3.createElement)("path", { d: "M9.35 13.3h1.3v3.6L10 18l-.65-1.1z" }) -- ) -- ); -+var REMOTE_GRACE_MS = 1500; -+function useAwaitingReply(roster, sessionId) { -+ const at = roster?.remoteAwaiting?.[sessionId]; -+ const [, tick] = (0, import_react10.useState)(0); -+ const shown = awaitingReply(roster, sessionId); -+ (0, import_react10.useEffect)(() => { -+ if (!shown) return void 0; -+ const timer = setTimeout(() => tick((n) => n + 1), Math.max(0, at + AWAIT_REPLY_MS - Date.now()) + 50); -+ return () => clearTimeout(timer); -+ }, [shown, at]); -+ return shown; - } --var UnreadIcon = lineIcon(["M10 3.5a4.5 4.5 0 0 0-4.5 4.5v3L4 13.5h12L14.5 11V8", "M8.3 16a1.8 1.8 0 0 0 3.4 0", "M15.5 3.2a1.6 1.6 0 1 1 0 3.2 1.6 1.6 0 0 1 0-3.2z"]); --var PencilIcon = lineIcon(["M9 4H6a2 2 0 0 0-2 2v8a2 2 0 0 0 2 2h8a2 2 0 0 0 2-2v-3", "M14.3 3.7a1.6 1.6 0 0 1 2.3 2.3L10.5 12l-3 .8.8-3z"]); --var InfoIcon = lineIcon(["M10 3.5a6.5 6.5 0 1 1 0 13 6.5 6.5 0 0 1 0-13z", "M10 9.25v4", "M10 6.6h.01"]); --var StarLineIcon = lineIcon(["M10 3l2.1 4.3 4.7.7-3.4 3.3.8 4.7L10 13.8 5.8 16l.8-4.7L3.2 8l4.7-.7z"]); --var CopyIcon = lineIcon(["M8.5 7h6A1.5 1.5 0 0 1 16 8.5v6a1.5 1.5 0 0 1-1.5 1.5h-6A1.5 1.5 0 0 1 7 14.5v-6A1.5 1.5 0 0 1 8.5 7z", "M4 12.5V5.5A1.5 1.5 0 0 1 5.5 4H13"]); --var HideIcon = lineIcon(["M3 3l14 14", "M8.6 5.2A7.6 7.6 0 0 1 10 5c4.5 0 7 5 7 5a12.6 12.6 0 0 1-2 2.6M12.2 14.5A6.8 6.8 0 0 1 10 15c-4.5 0-7-5-7-5a12.4 12.4 0 0 1 3-3.4", "M8.5 8.5a2 2 0 0 0 3 3"]); --var TrashIcon = lineIcon(["M3.5 5.5h13", "M8 5.5V4a1 1 0 0 1 1-1h2a1 1 0 0 1 1 1v1.5", "M5 5.5l.7 10A1.5 1.5 0 0 0 7.2 17h5.6a1.5 1.5 0 0 0 1.5-1.5l.7-10", "M8.5 9v4.5M11.5 9v4.5"]); --var DuplicateIcon = lineIcon(["M7 7h8.5v9.5H7z", "M4.5 13V3.5H13", "M11.25 9.5v4.5M9 11.75h4.5"]); --var SlidersIcon = lineIcon(["M4 6.5h5M12.5 6.5H16M4 13.5h4M11.5 13.5H16", "M10.5 4.5v4M9.5 11.5v4"]); --var MemoryIcon = lineIcon(["M5.5 3.5h8A1.5 1.5 0 0 1 15 5v10a1.5 1.5 0 0 1-1.5 1.5h-8z", "M5.5 3.5v13", "M8.5 7.5h3.5M8.5 10.5h2.5", "M12 3.5v4l1-.8 1 .8"]); --var FolderIcon = lineIcon(["M3 6A1.5 1.5 0 0 1 4.5 4.5h3.2l1.8 2h6A1.5 1.5 0 0 1 17 8v6.5a1.5 1.5 0 0 1-1.5 1.5h-11A1.5 1.5 0 0 1 3 14.5z"]); --var ChatsIcon = lineIcon(["M3.5 5.5A1.5 1.5 0 0 1 5 4h7a1.5 1.5 0 0 1 1.5 1.5V10A1.5 1.5 0 0 1 12 11.5H8L5 14v-2.5A1.5 1.5 0 0 1 3.5 10z", "M13.5 7.5H15A1.5 1.5 0 0 1 16.5 9v4.5A1.5 1.5 0 0 1 15 15v2l-2.5-2h-3A1.5 1.5 0 0 1 8 13.5V13"]); --var SETTINGS_ICONS = { general: GearIcon, bots: PeopleIcon, groups: ChatsIcon, usage: GaugeIcon }; -- --// src/client/questions.js --var import_react4 = require("react"); --var keyLetter = (index) => String.fromCharCode(65 + index); --var optionsOf = (question) => (question?.options ?? []).map((option) => typeof option === "string" ? { label: option } : option); --var isTypingTarget = (node) => node instanceof HTMLInputElement || node instanceof HTMLTextAreaElement || node instanceof HTMLSelectElement || node instanceof HTMLElement && node.isContentEditable; --function useOptionKeys(count, onPick, ownRef) { -- const pick2 = (0, import_react4.useRef)(onPick); -- pick2.current = onPick; -- (0, import_react4.useEffect)(() => { -- if (count === 0) return void 0; -- const onKey = (event) => { -- if (event.defaultPrevented || event.metaKey || event.ctrlKey || event.altKey || event.shiftKey || event.key.length !== 1) return; -- if (isTypingTarget(event.target) || isTypingTarget(document.activeElement)) return; -- if (document.querySelector('[role="dialog"], [aria-modal="true"]') !== null) return; -- const index = event.key.toUpperCase().charCodeAt(0) - 65; -- if (index >= 0 && index < count) { -- event.preventDefault(); -- pick2.current(index); -- } else if (index === count && ownRef.current) { -- event.preventDefault(); -- ownRef.current.focus(); -- } -+function findHost(where) { -+ if (where !== TAIL_HOST) return findRunningHostIn(document); -+ const flow = document.querySelector("[data-conversation-scroll] [data-chat-flow]") ?? document.querySelector("[data-chat-flow]"); -+ if (!flow) return null; -+ const running = findRunningHostIn(flow); -+ if (running) return running; -+ const tails = flow.querySelectorAll(":scope > * [data-turn-tail], :scope > [data-turn-tail]"); -+ return tails.length ? tails[tails.length - 1] : null; ++ const data = node.data; ++ const steps = useTurnSteps(String(data.turn)); ++ const contexts = useTurnContext(String(data.turn)); ++ const mentions = useFileMentions({ node, openFile, fileMentions, useTurnData }); ++ const held = groupPart === "reasoning" ? void 0 : heldReplyOf(roster.byId[sessionId], sessionId, data.turn); ++ const closing = useClosingText(node, useTurnData); ++ const hidden = Boolean(roster.roomsById[sessionId]) || isHiddenTurn(roster, sessionId, data.turn); ++ const person = typedBy(roster, sessionId, data.turn); ++ const texts2 = groupPart === "reasoning" ? [] : data.blocks.filter((block) => block.kind === "text" && block.text.trim() !== "").map((block, index) => index === 0 ? stripTrace(block.text) : block.text).filter((text2) => text2.trim() !== ""); ++ const running = data.status === "running"; ++ useLiveNote(actions, sessionId, `text:${node.key ?? data.seq}`, !hidden && running && texts2.length > 0 ? { text: true } : null); ++ if (hidden) return null; ++ const reasoning = groupPart === "response" ? "" : turnReasoning(steps, data); ++ const relays = relayPart(data, groupPart) ? relaysBefore(contexts, steps, data) : []; ++ if (texts2.length === 0 && !reasoning && relays.length === 0) return null; ++ const selfId = roster.byId[sessionId]?.id ?? sessionId; ++ const bubbles = texts2.length ? person ? (0, import_react10.createElement)(PersonMessage, { name: person, texts: texts2, roster, selfId, actions, sessionId, msgKey: `${sessionId}:${data.turn}:${node.key ?? data.seq ?? ""}`, useUi }) : (0, import_react10.createElement)(Bubbles, { texts: texts2, roster, selfId, actions, streaming: running, sessionId, msgKey: `${sessionId}:${data.turn}:${node.key ?? data.seq ?? ""}`, useUi, mentions, lead: friendLead(roster.byId[sessionId], texts2) }) : null; ++ return (0, import_react10.createElement)( ++ import_react10.Fragment, ++ null, ++ relays.map((relay) => { ++ const source = relay.source; ++ const sender = roster.byId[source.senderSessionId] ?? { id: source.senderSessionId, name: source.senderName ?? t("a Bot"), color: "gray" }; ++ return (0, import_react10.createElement)(EventLine, { ++ key: relay.seq, ++ prefix: source.role === "stopped" ? t("Stopped by") : source.role === "reply" ? t("Reply from") : t("Message from"), ++ bots: [sender], ++ onOpen: () => { ++ if (sender.id) actions.openExchange(selfId, sender.id); ++ }, ++ whole: true ++ }); ++ }), ++ reasoning ? (0, import_react10.createElement)(ThinkingRow, { text: reasoning }) : null, ++ held && bubbles ? (0, import_react10.createElement)(HeldReply, { held, roster, sessionId, actions, closing }, bubbles) : bubbles ++ ); +} -+var lastTurnOnScreen = (kind) => { -+ let top = -1; -+ for (const node of document.querySelectorAll(`[data-chat-flow-kind${kind ? `="${kind}"` : ""}][data-chat-turn]`)) top = Math.max(top, Number(node.dataset.chatTurn)); -+ return top; -+}; -+function useReplyAfterSend(remote, sending) { -+ const sendTurn = (0, import_react10.useRef)(-1); -+ const wasSending = (0, import_react10.useRef)(false); -+ const [replied, setReplied] = (0, import_react10.useState)(false); -+ (0, import_react10.useEffect)(() => { -+ if (!remote) return void 0; -+ if (sending) { -+ wasSending.current = true; -+ sendTurn.current = -1; -+ setReplied(false); -+ return void 0; -+ } -+ if (wasSending.current) { -+ wasSending.current = false; -+ sendTurn.current = lastTurnOnScreen(); -+ } -+ if (sendTurn.current < 0) return void 0; -+ let frame = 0; -+ const check = () => { -+ frame = 0; -+ if (lastTurnOnScreen("assistant-step") > sendTurn.current) setReplied(true); - }; -- document.addEventListener("keydown", onKey, true); -- return () => document.removeEventListener("keydown", onKey, true); -- }, [count, ownRef]); -+ const observer = new MutationObserver(() => { -+ if (!frame) frame = requestAnimationFrame(check); -+ }); -+ observer.observe(document.querySelector("[data-conversation-scroll]") ?? document.body, { childList: true, subtree: true }); -+ check(); -+ return () => { -+ cancelAnimationFrame(frame); -+ observer.disconnect(); -+ }; -+ }, [remote, sending]); -+ return replied; ++function HeldReply({ held, roster, sessionId, actions, closing, children }) { ++ const bot = roster.byId[sessionId]; ++ const chat = (roster.bots ?? []).find((item) => item.remote?.handle === held.to); ++ const toName = cleanPeerName(held.toOwner) || (chat ? friendBotOwner(chat) : ownerNameOf({ handle: held.to })) || held.to || t("a friend"); ++ return (0, import_react10.createElement)( ++ "div", ++ { className: "bt-held-reply", "data-state": held.status, "data-held": isApprovalWaiting(held.status) ? "" : void 0 }, ++ children, ++ closing ? (0, import_react10.createElement)(HeldReplyActions, { held: { ...held, toName, from: bot?.name }, actions }) : null ++ ); } --function QuestionView({ question, onAnswer, onDismiss, ownAnswer = false, raised = false }) { -- const [own, setOwn] = (0, import_react4.useState)(""); -- const [picked, setPicked] = (0, import_react4.useState)([]); -- const ownRef = (0, import_react4.useRef)(null); -- const titleId = (0, import_react4.useId)(); -- const options = optionsOf(question); -- const multi = question.multiSelect === true; -- const custom = own.trim(); -- const canSubmit = multi ? picked.length > 0 || custom !== "" : custom !== ""; -- const choose = (label) => { -- if (multi) { -- setPicked((current) => current.includes(label) ? current.filter((item) => item !== label) : [...current, label]); -- return; -- } -- onAnswer({ selected: [label] }); -- }; -- const submit = () => { -- if (!canSubmit) return; -- onAnswer({ selected: multi ? picked : [], ...custom ? { custom } : {} }); -- }; -- useOptionKeys(options.length, (index) => choose(options[index].label), ownRef); -- const ownLetter = options.length > 0 ? keyLetter(options.length) : void 0; -- const field = ownAnswer ? (0, import_react4.createElement)("textarea", { -- ref: ownRef, -- rows: 1, -- value: own, -- autoComplete: "off", -- spellCheck: false, -- className: "bt-q-input", -- "aria-label": t("Your own answer"), -- placeholder: t("Type your own answer"), -- "aria-keyshortcuts": ownLetter?.toLowerCase(), -- onChange: (event) => setOwn(event.target.value), -- onKeyDown: (event) => { -- if (event.key !== "Enter" || event.shiftKey || event.nativeEvent.isComposing) return; -- event.preventDefault(); -- submit(); -+function useRunningHost(active, where = RUNNING_HOST) { -+ const [host, setHost] = (0, import_react10.useState)(null); -+ (0, import_react10.useEffect)(() => { -+ if (!active) { -+ setHost(null); -+ return void 0; - } -- }) : null; -- const rows = options.map((option, optionIndex) => { -- const selected = multi && picked.includes(option.label); -- return (0, import_react4.createElement)( -- "button", -- { -- key: option.label, -- type: "button", -- className: "bt-q-opt", -- "aria-pressed": multi ? selected : void 0, -- "aria-keyshortcuts": keyLetter(optionIndex).toLowerCase(), -- onClick: () => choose(option.label) -- }, -- (0, import_react4.createElement)("span", { className: "bt-key", "aria-hidden": true }, keyLetter(optionIndex)), -- (0, import_react4.createElement)( -- "span", -- { className: "bt-q-body" }, -- (0, import_react4.createElement)("span", { className: "bt-q-label" }, option.label), -- option.description ? (0, import_react4.createElement)("span", { className: "bt-q-desc" }, option.description) : null -- ), -- selected && !raised ? (0, import_react4.createElement)("span", { className: "bt-q-check", title: t("Selected") }, (0, import_react4.createElement)(CheckIcon)) : null -- ); -- }); -- if (raised && field) { -- rows.push((0, import_react4.createElement)( -- "label", -- { key: "own", className: "bt-q-opt bt-q-ownrow", "data-filled": custom !== "" ? "" : void 0 }, -- ownLetter ? (0, import_react4.createElement)("span", { className: "bt-key", "aria-hidden": true }, ownLetter) : null, -- (0, import_react4.createElement)("span", { className: "bt-q-body" }, field) -- )); -- } -- let foot = null; -- if (raised && multi) { -- foot = (0, import_react4.createElement)( -- "div", -- { className: "bt-q-foot" }, -- (0, import_react4.createElement)("button", { type: "button", className: "bt-q-btn", onClick: onDismiss }, t("Skip")), -- (0, import_react4.createElement)("button", { type: "submit", className: "bt-q-submit", disabled: !canSubmit }, t("Submit")) -- ); -- } else if ((raised || multi) && canSubmit) { -- foot = (0, import_react4.createElement)("div", { className: "bt-q-foot" }, (0, import_react4.createElement)("button", { type: "submit", className: "bt-q-submit" }, t("Submit"))); -- } -- return (0, import_react4.createElement)( -- "form", -- { -- className: raised ? "bt-q bt-q-raised" : "bt-q", -- "aria-labelledby": titleId, -- onSubmit: (event) => { -- event.preventDefault(); -- submit(); -+ let frame = 0; -+ const find = () => { -+ frame = 0; -+ const node = findHost(where); -+ setHost((prev) => prev === node ? prev : node); -+ }; -+ const observer = new MutationObserver(() => { -+ if (!frame) frame = requestAnimationFrame(find); -+ }); -+ observer.observe(document.querySelector("[data-conversation-scroll]") ?? document.body, { childList: true, subtree: true }); -+ find(); -+ return () => { -+ cancelAnimationFrame(frame); -+ observer.disconnect(); -+ }; -+ }, [active, where]); -+ return host; -+} -+function useRowEntrances(replacing) { -+ const check = (0, import_react10.useRef)(replacing); -+ check.current = replacing; -+ (0, import_react10.useEffect)(() => { -+ const seen = /* @__PURE__ */ new WeakSet(); -+ const started = Date.now(); -+ let column = null; -+ let frame = 0; -+ const settle = (event) => { -+ if (event.target !== event.currentTarget || !/^bt-row/.test(event.animationName)) return; -+ delete event.currentTarget.dataset.btEnter; -+ event.currentTarget.removeEventListener("animationend", settle); -+ }; -+ const scan = () => { -+ frame = 0; -+ const next = document.querySelector("[data-chat-flow]"); -+ if (next !== column) { -+ observer.disconnect(); -+ column = next; -+ if (column) observer.observe(column, { childList: true, subtree: true }); - } -- }, -- (0, import_react4.createElement)( -- "div", -- { className: "bt-q-head" }, -- (0, import_react4.createElement)( -- "div", -- { className: "bt-q-text" }, -- (0, import_react4.createElement)("p", { className: "bt-q-title", id: titleId }, question.header ? `${question.header} · ` : "", question.question), -- question.detail ? (0, import_react4.createElement)("div", { className: "bt-q-detail" }, question.detail) : null, -- raised && multi ? (0, import_react4.createElement)("div", { className: "bt-q-detail" }, t("Choose all that apply")) : null -- ), -- (0, import_react4.createElement)("button", { type: "button", className: "bt-q-x", "aria-label": t("Dismiss question"), title: t("Dismiss"), onClick: onDismiss }, (0, import_react4.createElement)(CloseIcon)) -- ), -- rows.length > 0 ? (0, import_react4.createElement)("div", { className: "bt-q-list", role: "group" }, rows) : null, -- field && !raised ? (0, import_react4.createElement)( -- "div", -- { className: "bt-q-custom" }, -- (0, import_react4.createElement)("label", { className: "bt-q-field" }, field), -- !multi && canSubmit ? (0, import_react4.createElement)("button", { type: "submit", className: "bt-q-submit" }, t("Submit")) : null -- ) : null, -- foot -- ); -+ if (!column) return; -+ const items = [...column.querySelectorAll(":scope > [data-chat-flow-key]")]; -+ let last = -1; -+ items.forEach((item, index) => { -+ if (seen.has(item)) last = index; -+ }); -+ const fresh = items.filter((item) => !seen.has(item) && item.offsetHeight > 0); -+ for (const item of fresh) seen.add(item); -+ if (last < 0 || Date.now() - started < 800 || fresh.length > 3) return; -+ for (const item of fresh) { -+ if (items.indexOf(item) < last) continue; -+ const reply = [void 0, "assistant-step", "tool-call"].includes(item.dataset.chatFlowKind); -+ item.dataset.btEnter = reply && check.current() ? "after-collapse" : "new"; -+ item.addEventListener("animationend", settle); -+ } -+ }; -+ const observer = new MutationObserver(() => { -+ if (!frame) frame = requestAnimationFrame(scan); -+ }); -+ scan(); -+ return () => { -+ cancelAnimationFrame(frame); -+ observer.disconnect(); -+ }; -+ }, []); - } --function SettledQuestion({ question, answer }) { -- const options = optionsOf(question); -- const prompt = question?.question ?? ""; -- const chosen = [...answer.selected ?? [], ...answer.custom ? [answer.custom] : []]; -- if (chosen.length === 0) { -- return (0, import_react4.createElement)( -- "div", -- { className: "bt-q bt-q-settled bt-q-dismissed", role: "group", "aria-label": prompt }, -- (0, import_react4.createElement)( -- "div", -- { className: "bt-q-head" }, -- (0, import_react4.createElement)("div", { className: "bt-q-text" }, (0, import_react4.createElement)("p", { className: "bt-q-title" }, prompt)), -- (0, import_react4.createElement)("span", { className: "bt-q-badge" }, t("Dismissed")) -- ) -- ); -- } -- return (0, import_react4.createElement)( -+function ActivityIndicator({ sessionId, useRoster, useSessionStatus, useLive, useSession, actions }) { -+ const roster = useRoster((value) => value); -+ const running = useSessionStatus((map) => map.get(sessionId)?.running === true); -+ const liveKey = useLive((state) => JSON.stringify(state[sessionId] ?? {})); -+ const pendingInput = useSession((snapshot) => Math.max(0, ...(snapshot?.pendingSubmissions ?? []).filter((sub) => sub.placement !== "queued").map((sub) => sub.time))); -+ const bot = roster.byId[sessionId]; -+ const room2 = roster.roomsById[sessionId]; -+ const entries2 = (0, import_react10.useMemo)(() => Object.values(JSON.parse(liveKey)), [liveKey]); -+ const streaming = entries2.some((entry) => entry.text); -+ const tools = entries2.filter((entry) => !entry.text).sort((a, b) => a.at - b.at); -+ const relaying = tools.some((entry) => entry.relay); -+ const awaiting = useAwaitingReply(roster, sessionId); -+ const sendRun = (0, import_react10.useRef)(false); -+ if (!running) sendRun.current = false; -+ else if (pendingInput > 0) sendRun.current = true; -+ const replied = useReplyAfterSend(bot?.remote !== void 0, running && sendRun.current); -+ const visible = bot?.remote !== void 0 ? !streaming && (running ? sendRun.current : awaiting && !replied) : running && !streaming && !relaying && (bot !== void 0 || room2 !== void 0); -+ const lastRun = (0, import_react10.useRef)({ running, send: false }); -+ const bridging = bot?.remote !== void 0 && !streaming && lastRun.current.running && lastRun.current.send && !running; -+ lastRun.current = { running, send: sendRun.current }; -+ const steady = bridging; -+ const tool = tools.at(-1); -+ const inputRef = (0, import_react10.useRef)(0); -+ const inputAt = Math.max(inputRef.current, pendingInput); -+ inputRef.current = inputAt; -+ const phrase = visible ? remoteApprovalPhrase(bot?.remote !== void 0, { tool, inputAt }) : null; -+ const want = !visible ? null : phrase ? { ...phrase, label: t(phrase.label) } : inputAt > (tool?.at ?? 0) ? { key: `input:${inputAt}`, pool: PHRASES.thinking, state: "thinking" } : tool ? { key: tool.label ? `tool:${tool.label}` : "working", label: tool.label, pool: PHRASES.working, state: tool.state ?? "working" } : { key: "thinking", pool: PHRASES.thinking, state: "thinking" }; -+ const [shown, setShown] = (0, import_react10.useState)(null); -+ const wantKey = want?.key ?? null; -+ const markState = (0, import_react10.useRef)("thinking"); -+ if (want) markState.current = want.state; -+ (0, import_react10.useEffect)(() => { -+ if (wantKey === null || shown?.key === wantKey) return void 0; -+ const make = () => setShown({ key: wantKey, text: want.label ?? drawPhrase(want.pool), at: Date.now() }); -+ const wait = shown ? shown.at + PHRASE_HOLD_MS - Date.now() : 0; -+ if (wait <= 0) { -+ make(); -+ return void 0; -+ } -+ const timer = setTimeout(make, wait); -+ return () => clearTimeout(timer); -+ }, [wantKey, shown]); -+ const [phase, setPhase] = (0, import_react10.useState)(null); -+ const endedAt2 = (0, import_react10.useRef)(0); -+ const shift = (0, import_react10.useRef)(null); -+ const leaving = (0, import_react10.useRef)(false); -+ leaving.current = phase !== null && !visible; -+ (0, import_react10.useEffect)(() => { -+ if (visible) { -+ setPhase("in"); -+ return void 0; -+ } -+ let timer; -+ const leave = () => { -+ setPhase((current) => current === "in" ? "out" : current); -+ timer = setTimeout(() => { -+ shift.current = document.querySelector("[data-conversation-scroll]")?.scrollTop ?? null; -+ setPhase((current) => current === "out" ? null : current); -+ }, 140); -+ }; -+ if (steady) timer = setTimeout(leave, REMOTE_GRACE_MS); -+ else leave(); -+ return () => clearTimeout(timer); -+ }, [visible]); -+ (0, import_react10.useLayoutEffect)(() => { -+ if (phase !== null) return; -+ endedAt2.current = Date.now(); -+ setShown(null); -+ const before = shift.current; -+ shift.current = null; -+ const scroller = document.querySelector("[data-conversation-scroll]"); -+ if (before === null || !scroller || reducedMotion()) return; -+ const delta = before - scroller.scrollTop; -+ if (delta > 0) scroller.querySelector("[data-chat-flow]")?.animate([{ transform: `translateY(${-delta}px)` }, { transform: "none" }], { duration: 140, easing: "cubic-bezier(.23,1,.32,1)" }); -+ }, [phase]); -+ (0, import_react10.useEffect)(() => { -+ if (!running) actions.clearLive(sessionId); -+ }, [running, sessionId]); -+ useRowEntrances(() => leaving.current || Date.now() - endedAt2.current < 300); -+ const [now, setNow] = (0, import_react10.useState)(Date.now); -+ (0, import_react10.useEffect)(() => { -+ if (phase !== "in") return void 0; -+ const timer = setInterval(() => setNow(Date.now()), 1e4); -+ return () => clearInterval(timer); -+ }, [phase]); -+ const remote = bot?.remote !== void 0; -+ const host = useRunningHost(running || remote && phase !== null, running || !remote ? RUNNING_HOST : TAIL_HOST); -+ const drawn = (0, import_react10.useRef)(false); -+ const carry = drawn.current; -+ (0, import_react10.useEffect)(() => { -+ drawn.current = Boolean(host && phase !== null && shown); -+ }, [host, phase, shown]); -+ if (!host || phase === null || !shown) return null; -+ const elapsed = now - shown.at >= 6e4 ? elapsedLabel(now - shown.at) : null; -+ return (0, import_react_dom2.createPortal)((0, import_react10.createElement)( - "div", -- { className: "bt-q bt-q-settled", role: "group", "aria-label": prompt }, -- prompt ? (0, import_react4.createElement)("div", { className: "bt-q-head" }, (0, import_react4.createElement)("div", { className: "bt-q-text" }, (0, import_react4.createElement)("p", { className: "bt-q-title" }, prompt))) : null, -- (0, import_react4.createElement)("div", { className: "bt-q-list", role: "group", "aria-label": t("Your answer") }, chosen.map((label, index) => { -- const optionIndex = options.findIndex((option) => option.label === label); -- return (0, import_react4.createElement)( -- "div", -- { key: `${label}-${index}`, className: "bt-q-opt" }, -- optionIndex >= 0 ? (0, import_react4.createElement)("span", { className: "bt-key", "aria-hidden": true }, keyLetter(optionIndex)) : null, -- (0, import_react4.createElement)("span", { className: "bt-q-body" }, (0, import_react4.createElement)("span", { className: "bt-q-label" }, label)), -- (0, import_react4.createElement)("span", { className: "bt-q-check", title: t("Selected") }, (0, import_react4.createElement)(CheckIcon)) -- ); -- })) -- ); -+ { className: "bt-typing", "data-exiting": phase === "out" ? "" : void 0, "data-carry": carry && phase !== "out" ? "" : void 0, "data-remote": remote ? "" : void 0, "data-flybot": shown.key === "remote-approval" ? "remote-approval" : void 0, "aria-hidden": true }, -+ bot ? (0, import_react10.createElement)(BotMark, { bot, size: 28, state: markState.current, live: true, pokeable: true }) : (0, import_react10.createElement)(RoomAvatar, { room: room2, roster, size: 28 }), -+ (0, import_react10.createElement)( -+ "span", -+ { className: "bt-typing-text" }, -+ (0, import_react10.createElement)( -+ "span", -+ { key: `${shown.key}:${shown.at}`, className: "bt-typing-label" }, -+ (0, import_react10.createElement)(Shimmer, null, `${shown.text}…`), -+ elapsed ? (0, import_react10.createElement)("span", { className: "bt-typing-time" }, `· ${elapsed}`) : null -+ ) -+ ) -+ ), host); - } --function QuestionCard({ matched }) { -- const pending = matched; -- const questions = pending.questions ?? []; -- const [index, setIndex] = (0, import_react4.useState)(0); -- const [answers, setAnswers] = (0, import_react4.useState)([]); -- const question = questions[index]; -- if (question === void 0) return null; -- const onAnswer = (answer) => { -- const next = [...answers, { id: question.id, ...answer }]; -- if (index + 1 < questions.length) { -- setAnswers(next); -- setIndex(index + 1); -- return; -- } -- pending.answer({ answers: next }); -- }; -- return (0, import_react4.createElement)(QuestionView, { key: index, question, onAnswer, onDismiss: () => pending.dismiss(), ownAnswer: true, raised: true }); -+ -+// src/client/dsh-offer.js -+var APP_OFFER = /* @__PURE__ */ new Set(["available", "downloading", "verifying", "installing", "ready"]); -+var bare = (value) => String(value ?? "").trim().replace(/^v/i, "").split("+")[0]; -+var sameVersion = (a, b) => bare(a) !== "" && bare(a) === bare(b); -+function showsDshRow(dsh, app) { -+ if (!dsh?.verified || !dsh.current || sameVersion(dsh.verified, dsh.current)) return false; -+ if (dsh.kind === "npm") return true; -+ return dsh.kind === "app" && APP_OFFER.has(app?.phase) && sameVersion(app.version, dsh.verified); - } --function PendingQuestion({ sessionId, question, actions, inline }) { -- const [answered, setAnswered] = (0, import_react4.useState)(null); -- if (answered === question.id) return null; -- const onAnswer = (answer) => { -- setAnswered(question.id); -- void actions.answerQuestion(sessionId, question.id, { selected: answer.selected ?? [], custom: answer.custom ?? "" }).catch(() => setAnswered(null)); -- }; -- const onDismiss = () => { -- setAnswered(question.id); -- void actions.dismissQuestion(sessionId); -- }; -- return (0, import_react4.createElement)( -- "div", -- { className: inline ? "bt-q-inline" : "bt-q-dock" }, -- (0, import_react4.createElement)(QuestionView, { key: question.id, question, onAnswer, onDismiss, ownAnswer: question.allowCustom === true, raised: !inline }) -- ); -+ -+// src/client/overlay-hooks.js -+var import_react11 = require("react"); -+var escapeLayers = []; -+var onEscapeKey = (event) => { -+ if (event.key === "Escape") escapeLayers.at(-1)?.current(); -+}; -+function useEscape(onClose) { -+ const ref = (0, import_react11.useRef)(onClose); -+ ref.current = onClose; -+ (0, import_react11.useEffect)(() => { -+ if (escapeLayers.length === 0) window.addEventListener("keydown", onEscapeKey); -+ escapeLayers.push(ref); -+ return () => { -+ escapeLayers.splice(escapeLayers.indexOf(ref), 1); -+ if (escapeLayers.length === 0) window.removeEventListener("keydown", onEscapeKey); -+ }; -+ }, []); - } --function QuestionDock({ sessionId, useRoster, useCards, useSession, actions }) { -- const question = useRoster((value) => value.questions?.[sessionId]); -- const drawn = useCards?.((state) => state[sessionId]?.[question?.callId] > 0) === true; -- const loading = useSession?.(historyLoading) === true; -- if (question === void 0 || question.callId && (drawn || loading)) return null; -- return (0, import_react4.createElement)(PendingQuestion, { sessionId, question, actions, inline: false }); -+function useDialogFocus(ref) { -+ (0, import_react11.useEffect)(() => { -+ const before = document.activeElement; -+ ref.current?.focus?.({ preventScroll: true }); -+ return () => { -+ if (before && typeof before.focus === "function" && before.isConnected) before.focus({ preventScroll: true }); -+ }; -+ }, []); - } --function AnsweredCell({ node }) { -- const data = node.data ?? {}; -- const questions = Array.isArray(data.questions) ? data.questions : []; -- if (questions.length === 0) { -- return data.text ? (0, import_react4.createElement)("div", { className: "bt-urow" }, (0, import_react4.createElement)("div", { className: "bt-ububble" }, data.text)) : null; -- } -- const answers = Array.isArray(data.answers) ? data.answers : []; -- return (0, import_react4.createElement)("div", { className: "bt-q-inline" }, questions.map((question) => (0, import_react4.createElement)(SettledQuestion, { -- key: question.id, -- question, -- answer: answers.find((answer) => answer.id === question.id) ?? { selected: [] } -- }))); -+function usePaneLeft() { -+ const [paneLeft, setPaneLeft] = (0, import_react11.useState)(280); -+ (0, import_react11.useLayoutEffect)(() => { -+ const column = document.querySelector('[class*="_footArea"]')?.parentElement; -+ if (column) setPaneLeft(Math.round(column.getBoundingClientRect().right)); -+ }, []); -+ return paneLeft; - } - --// src/client/secret-card.js --var import_react5 = require("react"); --var MIN_VALUE = 4; --var NONE = {}; --var KeyIcon = () => (0, import_react5.createElement)( -- "svg", -- { width: 15, height: 15, viewBox: "0 0 20 20", fill: "none", stroke: "currentColor", strokeWidth: 1.5, strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": true }, -- (0, import_react5.createElement)("path", { d: "M7 9.5a3.5 3.5 0 1 1 0 .01z" }), -- (0, import_react5.createElement)("path", { d: "M10.4 10.6L17 10.6M14.5 10.6v2.4M16.6 10.6v1.8" }) --); --var LockIcon = () => (0, import_react5.createElement)( -- "svg", -- { width: 14, height: 14, viewBox: "0 0 20 20", fill: "none", stroke: "currentColor", strokeWidth: 1.5, strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": true }, -- (0, import_react5.createElement)("path", { d: "M6.5 9V7a3.5 3.5 0 0 1 7 0v2" }), -- (0, import_react5.createElement)("rect", { x: 4.5, y: 9, width: 11, height: 8, rx: 2 }) --); --var WarnIcon = () => (0, import_react5.createElement)( -- "svg", -- { width: 14, height: 14, viewBox: "0 0 20 20", fill: "none", stroke: "currentColor", strokeWidth: 1.5, strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": true }, -- (0, import_react5.createElement)("path", { d: "M10 3.5l7 12.5H3z" }), -- (0, import_react5.createElement)("path", { d: "M10 8.5v3.5M10 14.2h.01" }) --); --var MASK_BY_CSS = typeof CSS !== "undefined" && typeof CSS.supports === "function" && CSS.supports("-webkit-text-security", "disc"); --var insecure = () => typeof location !== "undefined" && location.protocol === "http:" && !/^(localhost|127(?:\.\d{1,3}){3}|\[::1\])$/.test(location.hostname); --var scopeLabel = (scope, roster) => { -- if (scope === "all") return t("All Bots"); -- const names = (scope ?? []).map((id) => roster.byId[id]?.name).filter(Boolean); -- return names.length === 0 ? t("No Bot") : names.join(t(" and ")); -+// src/client/member-status.js -+var import_react12 = require("react"); -+var NO_WORK = { bots: {}, rooms: {}, approvals: [] }; -+var ROWS_MAX = 4; -+var stepIn = (work, botId, roomId, tool) => { -+ const own = work.bots?.[botId]; -+ return own?.roomId === roomId && own.tool === tool ? own.step : void 0; - }; --var when = (time) => Number.isFinite(time) ? new Date(time).toLocaleString(dateLocale(), { month: "short", day: "numeric", hour: "2-digit", minute: "2-digit" }) : ""; --function MaskedInput({ value, onChange, onEnter, label, autoFocus = false }) { -- return (0, import_react5.createElement)("input", { -- className: "bt-secret-input", -- type: MASK_BY_CSS ? "text" : "password", -- "data-masked": MASK_BY_CSS ? "" : void 0, -- value, -- autoFocus, -- autoComplete: MASK_BY_CSS ? "off" : "new-password", -- autoCorrect: "off", -- autoCapitalize: "off", -- spellCheck: false, -- "data-1p-ignore": "", -- "data-lpignore": "true", -- "data-bwignore": "", -- "data-form-type": "other", -- "aria-label": label, -- placeholder: t("Paste or type the key"), -- onChange: (event) => onChange(event.target.value), -- onKeyDown: (event) => { -- if (event.key !== "Enter" || event.nativeEvent.isComposing) return; -- event.preventDefault(); -- onEnter?.(); -- } -+var workIn = (room2, work, id) => { -+ const local = (work.rooms?.[room2.id] ?? []).find((entry) => entry.botId === id); -+ if (local) return { ...local, roomId: room2.id, step: stepIn(work, id, room2.id, local.tool) }; -+ const own = work.bots?.[id]; -+ return own !== void 0 && own.roomId === void 0 && own.fromRoomId === room2.id ? { ...own, roomId: room2.id, handed: true } : void 0; -+}; -+var askedIn = (room2, work, id, doing) => (work.approvals ?? []).filter((entry) => entry.botId === id && (entry.roomId === room2.id || entry.roomId === void 0 && doing?.handed === true)); -+function memberWork(room2, work = NO_WORK) { -+ return room2.members.map((id) => { -+ const doing = workIn(room2, work, id); -+ const approvals = askedIn(room2, work, id, doing); -+ return { id, approvals, doing, here: doing !== void 0, state: approvals.length > 0 ? "ask" : doing ? "work" : "idle" }; - }); - } --function ScopeSwitch({ value, onChange, bot }) { -- const options = [["all", t("All Bots")], ["bot", t("Only {name}", { name: bot.name })]]; -- return (0, import_react5.createElement)( -- "div", -- { className: "bt-secret-scope", role: "radiogroup", "aria-label": t("Who may use it") }, -- (0, import_react5.createElement)("span", { className: "bt-secret-glide", "data-at": value === "all" ? 0 : 1, "aria-hidden": true }), -- options.map(([id, text]) => (0, import_react5.createElement)("button", { -- key: id, -- type: "button", -- role: "radio", -- "aria-checked": value === id, -- className: "bt-secret-seg", -- onClick: () => onChange(id) -- }, text)) -+function busyMembers(room2, work, roster) { -+ const busy = memberWork(room2, work).filter((entry) => entry.state !== "idle" && roster.byId[entry.id]); -+ return [...busy.filter((entry) => entry.state === "ask"), ...busy.filter((entry) => entry.state === "work")].map((entry) => ({ ...entry, bot: roster.byId[entry.id] })); -+} -+function splitBusy(busy) { -+ const here = busy.filter((entry) => entry.state === "work" && entry.here); -+ const rows = [...here.filter((entry) => entry.doing.speaking === true), ...here.filter((entry) => entry.doing.speaking !== true)].slice(0, ROWS_MAX); -+ return { rows, rest: busy.filter((entry) => !rows.includes(entry)) }; -+} -+function botGroupWork(botId, work, roster) { -+ return Object.entries(work.rooms ?? {}).flatMap(([roomId, entries2]) => { -+ const entry = entries2.find((item) => item.botId === botId); -+ const room2 = roster.roomsById[roomId]; -+ return entry && room2 ? [{ room: room2, tool: entry.tool, step: stepIn(work, botId, roomId, entry.tool), since: entry.since }] : []; -+ }); -+} -+var moreRooms = (count) => count === 1 ? t("1 more room") : t("{count} more rooms", { count }); -+function summaryText(busy) { -+ const asks = busy.filter((entry) => entry.state === "ask"); -+ const workers = busy.filter((entry) => entry.state === "work"); -+ const names = asks.slice(0, 2).map((entry) => entry.bot.name); -+ const waiting = asks.length === 0 ? "" : asks.length === 1 ? t("{name} is waiting for your approval", { name: names[0] }) : asks.length === 2 ? t("{first} and {second} are waiting for your approval", { first: names[0], second: names[1] }) : t("{first}, {second} and {count} more are waiting for your approval", { first: names[0], second: names[1], count: asks.length - 2, total: asks.length }); -+ const first = workers[0]; -+ const doing = first ? `${first.bot.name} · ${workText(first.doing.tool, first.doing.step)}${workers.length > 1 ? ` +${workers.length - 1}` : ""}` : ""; -+ return { waiting, doing }; -+} -+function Face({ entry, size }) { -+ return (0, import_react12.createElement)( -+ "span", -+ { className: "bt-mw-face", "data-ask": entry.state === "ask" || void 0 }, -+ (0, import_react12.createElement)(BotMark, { bot: entry.bot, size, state: entry.state === "ask" ? "alert" : toolState(entry.doing?.tool ?? "") }) -+ ); -+} -+var elapsedSince = (now, at) => typeof at === "number" && now - at >= 6e4 ? elapsedLabel(now - at) : ""; -+function WorkRow({ bot, tool, step, label, at, now, onOpen }) { -+ const text2 = `${label} · ${workText(tool, step)}`; -+ const elapsed = elapsedSince(now, at); -+ return (0, import_react12.createElement)( -+ "button", -+ { type: "button", className: "bt-mw-row", "aria-label": [text2, elapsed].filter(Boolean).join(" · "), onClick: onOpen }, -+ (0, import_react12.createElement)(BotMark, { bot, size: 22, state: toolState(tool ?? ""), live: true }), -+ (0, import_react12.createElement)("span", { className: "bt-mw-row-text" }, (0, import_react12.createElement)(Shimmer, null, text2)), -+ elapsed ? (0, import_react12.createElement)("span", { className: "bt-mw-row-time" }, `· ${elapsed}`) : null - ); - } --function SecretHead({ bot, title, detail, name, onClose }) { -- return (0, import_react5.createElement)( -+function SummaryLine({ busy, now, onOpen }) { -+ const { waiting, doing } = summaryText(busy); -+ const elapsed = doing ? elapsedSince(now, busy.find((entry) => entry.state === "work")?.doing.since) : ""; -+ return (0, import_react12.createElement)( -+ "button", -+ { type: "button", className: "bt-mw-row bt-mw-line", "aria-label": [waiting, doing, elapsed].filter(Boolean).join(" · "), onClick: onOpen }, -+ (0, import_react12.createElement)("span", { className: "bt-mw-stack", "aria-hidden": true }, busy.slice(0, 4).map((entry) => (0, import_react12.createElement)(Face, { key: entry.id, entry, size: 16 }))), -+ (0, import_react12.createElement)( -+ "span", -+ { className: "bt-mw-row-text" }, -+ waiting ? (0, import_react12.createElement)("span", { className: "bt-mw-ask" }, waiting) : null, -+ waiting && doing ? " · " : null, -+ doing ? (0, import_react12.createElement)(Shimmer, null, doing) : null -+ ), -+ elapsed ? (0, import_react12.createElement)("span", { className: "bt-mw-row-time" }, `· ${elapsed}`) : null -+ ); -+} -+function MemberDock({ sessionId, useRoster, useWork, actions }) { -+ const roster = useRoster((value) => value); -+ const work = useWork((value) => value); -+ const room2 = roster.roomsById[sessionId]; -+ const bot = room2 ? void 0 : roster.byId[sessionId]; -+ const ownChat = bot !== void 0 && work.bots?.[bot.id] !== void 0 && work.bots[bot.id].roomId === void 0; -+ const busy = room2 ? busyMembers(room2, work, roster) : []; -+ const groups = bot !== void 0 && !ownChat ? botGroupWork(bot.id, work, roster) : []; -+ const shown = busy.length > 0 || groups.length > 0; -+ const now = useNow(shown); -+ const ref = (0, import_react12.useRef)(null); -+ useFlowColumn(ref, shown, sessionId); -+ if (!shown) return null; -+ if (room2) { -+ const { rows: rows2, rest } = splitBusy(busy); -+ const open = () => actions.showDetails(sessionId); -+ return (0, import_react12.createElement)( -+ "div", -+ { ref, className: "bt-mw-dock-stack" }, -+ rows2.map((entry) => (0, import_react12.createElement)(WorkRow, { key: entry.id, bot: entry.bot, tool: entry.doing.tool, step: entry.doing.step, label: entry.bot.name, at: entry.doing.since, now, onOpen: open })), -+ rest.length > 0 ? (0, import_react12.createElement)(SummaryLine, { busy: rest, now, onOpen: open }) : null -+ ); -+ } -+ const rows = groups.slice(0, ROWS_MAX); -+ const more = groups.length - rows.length; -+ return (0, import_react12.createElement)( - "div", -- { className: "bt-q-head bt-secret-head" }, -- (0, import_react5.createElement)("span", { className: "bt-secret-who" }, (0, import_react5.createElement)(BotMark, { bot, size: 28 }), (0, import_react5.createElement)("span", { className: "bt-secret-lock" }, (0, import_react5.createElement)(LockIcon))), -- (0, import_react5.createElement)( -+ { ref, className: "bt-mw-dock-stack" }, -+ rows.map((item) => (0, import_react12.createElement)(WorkRow, { -+ key: item.room.id, -+ bot, -+ tool: item.tool, -+ step: item.step, -+ label: t("in {name}", { name: item.room.name }), -+ at: item.since, -+ now, -+ onOpen: () => actions.openSession(item.room.id) -+ })), -+ more > 0 ? (0, import_react12.createElement)( - "div", -- { className: "bt-q-text" }, -- (0, import_react5.createElement)("p", { className: "bt-q-title" }, title), -- (0, import_react5.createElement)("div", { className: "bt-q-detail" }, (0, import_react5.createElement)("code", { className: "bt-secret-name" }, name), detail ? ` · ${detail}` : "") -- ), -- onClose ? (0, import_react5.createElement)("button", { type: "button", className: "bt-q-x", "aria-label": t("Cancel the request"), title: t("Cancel"), onClick: onClose }, (0, import_react5.createElement)(CloseIcon)) : null -+ { className: "bt-mw-row bt-mw-line bt-mw-static" }, -+ (0, import_react12.createElement)("span", { className: "bt-mw-row-text" }, moreRooms(more)) -+ ) : null - ); - } --function SecretCard({ bot, request, roster, actions }) { -- const [value, setValue] = (0, import_react5.useState)(""); -- const [scope, setScope] = (0, import_react5.useState)("all"); -- const [replacing, setReplacing] = (0, import_react5.useState)(false); -- const [busy, setBusy] = (0, import_react5.useState)(false); -- const [error, setError] = (0, import_react5.useState)(""); -- const noteId = (0, import_react5.useId)(); -- const secret = (roster.secrets ?? []).find((entry) => entry.name === request.name); -- const run = (promise) => { -+function useFlowColumn(ref, active, key) { -+ (0, import_react12.useLayoutEffect)(() => { -+ const node = ref.current; -+ const flow = document.querySelector("[data-chat-flow]"); -+ if (!active || !node || !flow) return void 0; -+ const place = () => { -+ node.style.marginInlineStart = "0px"; -+ const column = flow.getBoundingClientRect(); -+ node.style.marginInlineStart = `${Math.round(column.left - node.getBoundingClientRect().left)}px`; -+ node.style.width = `${Math.round(column.width)}px`; -+ }; -+ place(); -+ const observer = new ResizeObserver(place); -+ for (const target of [flow, flow.parentElement, node.parentElement]) if (target) observer.observe(target); -+ return () => observer.disconnect(); -+ }, [active, key]); -+} -+function useNow(active) { -+ const [now, setNow] = (0, import_react12.useState)(Date.now); -+ (0, import_react12.useEffect)(() => { -+ if (!active) return void 0; -+ const timer = setInterval(() => setNow(Date.now()), 3e4); -+ return () => clearInterval(timer); -+ }, [active]); -+ return now; -+} -+var since = (now, at) => typeof at === "number" && now - at >= 6e4 ? ` · ${elapsedLabel(now - at)}` : ""; -+var alwaysChoices = (approval, bot) => bot?.remote ? [] : [ -+ ["always-bot", "Always allow for this Bot"], -+ ...approval.roomId ? [["always-room", "Always allow for this group"]] : [] -+]; -+function ApprovalCard({ approval, bot, actions }) { -+ const [busy, setBusy] = (0, import_react12.useState)(false); -+ const answer = (choice) => { - setBusy(true); -- setError(""); -- return Promise.resolve(promise).then(() => setValue(""), (failure) => setError(failure?.message ?? String(failure))).finally(() => setBusy(false)); -- }; -- const cancel = () => run(actions.secretCancel(request.id)); -- const long = value.trim().length >= MIN_VALUE; -- const save = () => { -- if (long && !busy) void run(actions.secretSet({ requestId: request.id, value, scope })); -- }; -- const allow = () => { -- if (!busy) void run(actions.secretAllow(request.id)); -+ void Promise.resolve(actions.answerApproval(approval.id, choice)).catch((error) => actions.reportError(error, "approval")).finally(() => setBusy(false)); - }; -- const filling = request.kind === "fill" || replacing; -- const warn = insecure() ? (0, import_react5.createElement)("div", { className: "bt-secret-warn", role: "note" }, (0, import_react5.createElement)(WarnIcon), t("This page is not on HTTPS: the key crosses the network unencrypted.")) : null; -- return (0, import_react5.createElement)( -+ return (0, import_react12.createElement)( - "div", -- { className: "bt-q bt-secret", role: "group", "aria-describedby": noteId, "data-kind": request.kind }, -- (0, import_react5.createElement)(SecretHead, { -- bot, -- title: request.kind === "allow" ? t("Let {bot} use {name}?", { bot: bot.name, name: request.name }) : t("{name} needs a key", { name: bot.name }), -- detail: request.purpose, -- name: request.name, -- onClose: busy ? void 0 : cancel -- }), -- request.kind === "allow" ? (0, import_react5.createElement)("div", { className: "bt-secret-saved" }, (0, import_react5.createElement)(KeyIcon), t("Saved for {scope}", { scope: scopeLabel(secret?.scope, roster) })) : null, -- filling ? (0, import_react5.createElement)( -- "div", -- { className: "bt-secret-body" }, -- (0, import_react5.createElement)("label", { className: "bt-q-field bt-secret-field" }, (0, import_react5.createElement)(MaskedInput, { value, onChange: setValue, onEnter: save, label: t("Value of {name}", { name: request.name }), autoFocus: replacing })), -- request.kind === "fill" ? (0, import_react5.createElement)(ScopeSwitch, { value: scope, onChange: setScope, bot }) : null -- ) : null, -- (0, import_react5.createElement)( -+ { className: "bt-mw-card", role: "group", "aria-label": t("{name} is waiting for your approval", { name: bot.name }) }, -+ (0, import_react12.createElement)("div", { className: "bt-mw-tool" }, approval.tool), -+ approval.detail?.text ? (0, import_react12.createElement)("div", { className: "bt-mw-text" }, approval.detail.text) : null, -+ approval.reason ? (0, import_react12.createElement)("div", { className: "bt-mw-reason" }, approval.reason) : null, -+ approval.detail?.code ? (0, import_react12.createElement)("code", { className: "bt-mw-code", title: approval.detail.code }, approval.detail.code) : null, -+ approval.answerable ? (0, import_react12.createElement)( - "div", -- { className: "bt-secret-note", id: noteId }, -- t("{bot} never sees the value. It reaches {bot}'s shell as ", { bot: bot.name }), -- (0, import_react5.createElement)("code", null, `$DSH_SECRET_${request.name}`), -- ".", -- filling && value !== "" && !long ? t(" At least {count} characters.", { count: MIN_VALUE }) : "" -- ), -- warn, -- error ? (0, import_react5.createElement)("div", { className: "bt-secret-error", role: "alert" }, error) : null, -- (0, import_react5.createElement)( -+ { className: "bt-mw-actions" }, -+ (0, import_react12.createElement)("button", { type: "button", className: "bt-q-btn", disabled: busy, onClick: () => answer("reject") }, t("Reject")), -+ (0, import_react12.createElement)("button", { type: "button", className: "bt-q-submit", disabled: busy, onClick: () => answer("allow") }, t("Allow once")), -+ ...alwaysChoices(approval, bot).map(([choice, label]) => (0, import_react12.createElement)("button", { key: choice, type: "button", className: "bt-q-btn", "data-choice": choice, disabled: busy, onClick: () => answer(choice) }, t(label))) -+ ) : (0, import_react12.createElement)( - "div", -- { className: "bt-q-foot" }, -- request.kind === "allow" && !replacing ? (0, import_react5.createElement)("button", { type: "button", className: "bt-q-btn", disabled: busy, onClick: () => setReplacing(true) }, t("Replace value")) : null, -- (0, import_react5.createElement)("button", { type: "button", className: "bt-q-btn", disabled: busy, onClick: cancel }, t("Cancel")), -- filling ? (0, import_react5.createElement)("button", { type: "button", className: "bt-q-submit", disabled: !long || busy, onClick: save }, busy ? t("Saving…") : t("Save")) : (0, import_react5.createElement)("button", { type: "button", className: "bt-q-submit", disabled: busy, onClick: allow }, busy ? t("Allowing…") : t("Allow")) -+ { className: "bt-mw-actions" }, -+ (0, import_react12.createElement)("button", { type: "button", className: "bt-q-btn", onClick: () => actions.openSession(bot.id) }, t("Open {name}'s chat", { name: bot.name })) - ) - ); - } --function SettledSecret({ bot, request, roster }) { -- const canceled = request.status === "canceled"; -- const text = canceled ? t("{bot} asked for {name}", { bot: bot.name, name: request.name }) : request.status === "allowed" ? t("Allowed {bot} to use {name}", { bot: bot.name, name: request.name }) : t("Saved {name}", { name: request.name }); -- return (0, import_react5.createElement)( -+function MemberWork({ entry, bot, room: room2, actions, now }) { -+ const [stopping, setStopping] = (0, import_react12.useState)(false); -+ if (entry.state === "ask") { -+ return (0, import_react12.createElement)( -+ "div", -+ { className: "bt-mw-member" }, -+ (0, import_react12.createElement)("div", { className: "bt-mw-state bt-mw-ask" }, `${t("Waiting for your approval")}${since(now, entry.approvals[0].at)}`), -+ entry.approvals.map((approval) => (0, import_react12.createElement)(ApprovalCard, { key: approval.id, approval, bot, actions })) -+ ); -+ } -+ if (entry.state !== "work") return null; -+ const stop = () => { -+ setStopping(true); -+ void Promise.resolve(actions.stopMemberWork(room2.id, bot.id)).catch((error) => actions.reportError(error, "stop")).finally(() => setStopping(false)); -+ }; -+ return (0, import_react12.createElement)( - "div", -- { className: "bt-q bt-q-settled bt-secret bt-secret-done", role: "group", "aria-label": text, "data-status": request.status }, -- (0, import_react5.createElement)( -+ { className: "bt-mw-member" }, -+ (0, import_react12.createElement)( - "div", -- { className: "bt-q-head" }, -- (0, import_react5.createElement)("span", { className: "bt-secret-who" }, (0, import_react5.createElement)(BotMark, { bot, size: 22 })), -- (0, import_react5.createElement)("div", { className: "bt-q-text" }, (0, import_react5.createElement)( -- "p", -- { className: "bt-q-title" }, -- text, -- canceled ? null : (0, import_react5.createElement)("span", { className: "bt-secret-scope-tag" }, ` · ${scopeLabel(request.scope, roster)}`) -- )), -- canceled ? (0, import_react5.createElement)("span", { className: "bt-q-badge" }, t("Canceled")) : (0, import_react5.createElement)("span", { className: "bt-q-check bt-secret-check", title: t("Done") }, (0, import_react5.createElement)(CheckIcon)) -+ { className: "bt-mw-state bt-mw-work" }, -+ (0, import_react12.createElement)("span", null, (0, import_react12.createElement)(Shimmer, null, workText(entry.doing.tool, entry.doing.step)), since(now, entry.doing.since)), -+ entry.here && entry.doing.handed !== true ? (0, import_react12.createElement)("button", { type: "button", className: "bt-mw-stop", disabled: stopping, onClick: stop }, t("Stop")) : null - ) - ); - } --function RequestView({ bot, request, roster, actions }) { -- return request.status === "pending" ? (0, import_react5.createElement)(SecretCard, { key: `${request.id}:${request.kind}`, bot, request, roster, actions }) : (0, import_react5.createElement)(SettledSecret, { bot, request, roster }); -+function useMemberWork(room2, useWork) { -+ const work = useWork?.((value) => value) ?? NO_WORK; -+ const entries2 = room2 ? memberWork(room2, work) : []; -+ const now = useNow(entries2.some((entry) => entry.state !== "idle")); -+ return { entries: entries2, now }; - } --function SecretToolCell({ sessionId, callId, name, done, content, roster, actions }) { -- const bot = roster.byId[sessionId]; -- if (!bot) return null; -- const request = (roster.secretRequests?.[bot.id] ?? []).find((entry) => entry.callId === callId && (entry.sessionId ?? sessionId) === sessionId); -- if (request) return (0, import_react5.createElement)("div", { className: "bt-q-inline" }, (0, import_react5.createElement)(RequestView, { bot, request, roster, actions })); -- if (done && /already saved/.test(contentText(content))) { -- return (0, import_react5.createElement)("div", { className: "bt-event" }, (0, import_react5.createElement)("span", { className: "bt-secret-event" }, (0, import_react5.createElement)(KeyIcon), t("{bot} is using {name}", { bot: bot.name, name: name ?? t("a saved key") }))); -- } -- return null; -+var MEMBER_CSS = ` -+.bt-mw-dock-stack{box-sizing:border-box;display:flex;flex-direction:column;align-items:flex-start;gap:2px;width:var(--dsh-chat-content-width,min(960px,100%));margin:0 auto 6px;pointer-events:none} -+.bt-mw-row{pointer-events:auto;display:flex;align-items:center;gap:10px;min-width:0;max-width:calc(100% + 6px);height:32px;margin-inline-start:-6px;padding:0 12px 0 6px;border:0;border-radius:16px;background:none;color:var(--bt-ink-2);font:inherit;font-size:14px;cursor:pointer;animation:bt-mw-in .32s cubic-bezier(.23,1,.32,1) backwards;transition:background-color .16s ease-out} -+.bt-mw-row:hover{background:var(--bt-hover)} -+.bt-mw-static{cursor:default;padding-inline-start:38px} -+.bt-mw-static:hover{background:none} -+.bt-mw-row>.bt-mark{flex:none} -+.bt-mw-row-text{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} -+.bt-mw-row-time{flex:none;font-size:12px;color:var(--bt-ink-3);white-space:nowrap} -+.bt-mw-work{display:flex;align-items:center;justify-content:space-between;gap:8px} -+.bt-mw-stop{flex:none;height:22px;padding:0 10px;border:1px solid var(--bt-line,rgba(0,0,0,.12));border-radius:11px;background:none;color:var(--bt-ink-2);font:inherit;font-size:12px;cursor:pointer} -+.bt-mw-stop:hover{background:var(--bt-hover)} -+.bt-mw-stop:disabled{opacity:.5;cursor:default} -+.bt-mw-line{gap:8px} -+.bt-mw-stack{display:inline-flex;align-items:center;flex:none;line-height:0} -+.bt-mw-stack>*+*{margin-left:-4px} -+.bt-mw-face{position:relative;display:inline-flex;flex:none;border-radius:50%} -+.bt-mw-stack .bt-mw-face{box-shadow:0 0 0 1.5px var(--bt-main,#fff)} -+.bt-mw-face[data-ask]::after{content:"";position:absolute;right:-2px;bottom:-2px;width:7px;height:7px;border-radius:50%;background:#e3a008;box-shadow:0 0 0 1.5px var(--bt-main,#fff)} -+.bt-mw-ask{color:#b7791f;flex:none;white-space:nowrap} -+body[data-ds-dark-theme] .bt-mw-ask{color:#f0b84a} -+.bt-mw-member{padding:0 6px 8px 42px;display:flex;flex-direction:column;gap:6px} -+.bt-mw-state{font-size:12px;line-height:16px;color:var(--bt-ink-2);margin-top:-4px} -+.bt-mw-card{display:flex;flex-direction:column;gap:4px;padding:10px 12px;border-radius:12px;background:var(--bt-hover);font-size:12px;line-height:17px;color:var(--bt-ink-2)} -+.bt-mw-tool{font-weight:600;color:var(--bt-ink)} -+.bt-mw-text{color:var(--bt-ink);overflow-wrap:anywhere} -+.bt-mw-reason{overflow-wrap:anywhere;display:-webkit-box;-webkit-line-clamp:3;-webkit-box-orient:vertical;overflow:hidden} -+.bt-mw-code{display:block;font:11px/16px ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;color:var(--bt-ink);white-space:pre-wrap;overflow-wrap:anywhere;max-height:120px;overflow:auto} -+.bt-mw-actions{display:flex;flex-wrap:wrap;justify-content:flex-end;gap:6px;margin-top:4px} -+.bt-mw-actions>button{height:28px;padding:0 12px;font-size:12px} -+.bt-mw-actions>button:disabled{opacity:.5;cursor:default} -+@keyframes bt-mw-in{from{opacity:0;transform:translateY(4px)}} -+@media (prefers-reduced-motion:reduce){.bt-mw-row{animation:none}} -+`; -+ -+// src/shared/task-board.js -+var COLUMNS = ["not-started", "in-progress", "done"]; -+var PRIORITIES = ["urgent", "high", "normal", "low"]; -+var COLUMN_OF = { -+ open: "not-started", -+ claimed: "not-started", -+ working: "in-progress", -+ blocked: "in-progress", -+ "result-ready": "in-progress", -+ accepted: "done", -+ cancelled: "done", -+ failed: "done" -+}; -+function columnOf(task) { -+ return COLUMN_OF[task?.status] ?? "not-started"; -+} -+function occupiesSlot(task) { -+ return task?.status === "claimed" || task?.status === "working"; -+} -+function priorityOf(task) { -+ return PRIORITIES.includes(task?.priority) ? task.priority : "normal"; -+} -+var PRIORITY_RANK = { urgent: 0, high: 1, normal: 2, low: 3 }; -+function workBucket(task) { -+ if (task?.status === "open" || task?.status === "claimed" || task?.status === "working") return 0; -+ if (task?.status === "blocked" || task?.status === "result-ready") return 1; -+ return 2; -+} -+function compareForWork(a, b) { -+ const byBucket = workBucket(a) - workBucket(b); -+ if (byBucket !== 0) return byBucket; -+ const byPriority = (PRIORITY_RANK[priorityOf(a)] ?? 2) - (PRIORITY_RANK[priorityOf(b)] ?? 2); -+ if (byPriority !== 0) return byPriority; -+ const byWorking = (a?.status === "working" ? 0 : 1) - (b?.status === "working" ? 0 : 1); -+ if (byWorking !== 0) return byWorking; -+ return (b?.updatedAt ?? 0) - (a?.updatedAt ?? 0); -+} -+function sameRole(a, b) { -+ const left = String(a ?? "").trim().toLowerCase(); -+ const right = String(b ?? "").trim().toLowerCase(); -+ return left !== "" && left === right; -+} -+function compareForQueue(a, b) { -+ const byPriority = (PRIORITY_RANK[priorityOf(a)] ?? 2) - (PRIORITY_RANK[priorityOf(b)] ?? 2); -+ if (byPriority !== 0) return byPriority; -+ return (a?.createdAt ?? 0) - (b?.createdAt ?? 0); -+} -+function progressOf(task) { -+ const value = task?.progress; -+ if (typeof value !== "number" || !Number.isFinite(value)) return columnOf(task) === "done" && task?.status !== "cancelled" && task?.status !== "failed" ? 100 : 0; -+ return Math.max(0, Math.min(100, Math.round(value))); - } --function SecretDock({ sessionId, useRoster, useCards, useSession, actions }) { -- const roster = useRoster((value) => value); -- const shown = useCards?.((state) => state[sessionId]) ?? NONE; -- const loading = useSession?.(historyLoading) === true; -- const bot = roster.byId[sessionId]; -- if (!bot || currentPart(bot) !== sessionId) return null; -- const drawn = (entry) => entry.callId && entry.sessionId === sessionId && (loading || shown[entry.callId] > 0); -- const waiting = (roster.secretRequests?.[bot.id] ?? []).filter((entry) => entry.status === "pending" && !drawn(entry)); -- if (waiting.length === 0) return null; -- return (0, import_react5.createElement)("div", { className: "bt-q-dock bt-secret-dock" }, waiting.map((request) => (0, import_react5.createElement)(SecretCard, { key: `${request.id}:${request.kind}`, bot, request, roster, actions }))); -+ -+// src/client/inbox.js -+var import_react13 = require("react"); -+var KEEP_MS = 7 * 864e5; -+var POLL_MS2 = 3e4; -+var DONE_MAX = 20; -+var FEEDBACK_MS = 5e3; -+var line = (d) => (0, import_react13.createElement)("path", { d }); -+var icon = (...paths) => function StatusIcon() { -+ return (0, import_react13.createElement)("svg", { width: 14, height: 14, viewBox: "0 0 20 20", fill: "none", stroke: "currentColor", strokeWidth: 1.8, strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": true }, paths.map(line)); -+}; -+var RING = "M10 3.25a6.75 6.75 0 1 1 0 13.5 6.75 6.75 0 0 1 0-13.5z"; -+var InboxIcon = icon("M3.5 11.5l1.9-6.1A1.5 1.5 0 0 1 6.83 4.35h6.34a1.5 1.5 0 0 1 1.43 1.05l1.9 6.1", "M3.5 11.5v3.25a1.5 1.5 0 0 0 1.5 1.5h10a1.5 1.5 0 0 0 1.5-1.5V11.5", "M3.5 11.5h3.75l1 1.75h3.5l1-1.75h3.75"); -+var AskIcon = icon(RING, "M10 6.75v3.75", "M10 13.25h.01"); -+var HeldIcon = icon(RING, "M10 6.5V10l2.25 1.5"); -+var QuestionIcon = icon(RING, "M8.1 8a2 2 0 1 1 2.65 1.9c-.45.18-.75.6-.75 1.1v.25", "M10 13.5h.01"); -+var ResultIcon = icon(RING, "M7 10.25l2 2 4-4.25"); -+var FailedIcon = icon(RING, "M7.75 7.75l4.5 4.5M12.25 7.75l-4.5 4.5"); -+var RunIcon = icon(RING, "M8.5 7.25v5.5l4.25-2.75z"); -+var DoneIcon = icon("M4.5 10.5l3.5 3.5 7.5-8"); -+var CancelledIcon = icon(RING, "M5.25 14.75l9.5-9.5"); -+var STATUSES = { -+ approval: { label: "Needs approval", tone: "ask", Icon: AskIcon }, -+ held: { label: "Waiting to send", tone: "ask", Icon: HeldIcon }, -+ input: { label: "Needs your answer", tone: "ask", Icon: QuestionIcon }, -+ result: { label: "Result ready", tone: "ask", Icon: ResultIcon }, -+ failed: { label: "Failed", tone: "danger", Icon: FailedIcon }, -+ running: { label: "Working", tone: "run", Icon: RunIcon }, -+ task: { label: "In progress", tone: "run", Icon: RunIcon }, -+ done: { label: "Done", tone: "done", Icon: DoneIcon }, -+ cancelled: { label: "Cancelled", tone: "muted", Icon: CancelledIcon } -+}; -+var GROUPS = [ -+ { id: "needs", title: "Needs you", empty: "Nothing needs you right now." }, -+ { id: "progress", title: "In progress", empty: "Nothing is running." }, -+ { id: "done", title: "Done", empty: "Nothing finished in the last 7 days." } -+]; -+var sharedTasks = []; -+var taskListeners = /* @__PURE__ */ new Set(); -+function publishTasks(list) { -+ sharedTasks = Array.isArray(list) ? list : []; -+ for (const listener of taskListeners) listener(sharedTasks); -+} -+function useTasks(actions) { -+ const [tasks, setTasks] = (0, import_react13.useState)(sharedTasks); -+ (0, import_react13.useEffect)(() => { -+ taskListeners.add(setTasks); -+ let stopped = false; -+ let timer; -+ const load = () => Promise.resolve(actions?.tasks?.({ archive: true })).then((list) => { -+ if (!stopped && list !== void 0) publishTasks(list); -+ }).catch(() => { -+ }).finally(() => { -+ if (!stopped) timer = setTimeout(load, POLL_MS2); -+ }); -+ load(); -+ return () => { -+ stopped = true; -+ clearTimeout(timer); -+ taskListeners.delete(setTasks); -+ }; -+ }, []); -+ return tasks; -+} -+var workflowsOf = (settled) => settled?.phase === "ready" && Array.isArray(settled.pack?.workflows) ? settled.pack.workflows : []; -+var sharedWorkflows = []; -+var workflowListeners = /* @__PURE__ */ new Set(); -+var workflowPoll = null; -+function publishWorkflows(settled) { -+ sharedWorkflows = workflowsOf(settled); -+ for (const listener of workflowListeners) listener(sharedWorkflows); -+} -+function useWorkflows(actions) { -+ const [workflows, setWorkflows] = (0, import_react13.useState)(sharedWorkflows); -+ (0, import_react13.useEffect)(() => { -+ workflowListeners.add(setWorkflows); -+ if (workflowPoll === null && typeof actions?.workflowState === "function") { -+ const load = () => Promise.resolve(actions.workflowState()).then(publishWorkflows, () => { -+ }); -+ load(); -+ workflowPoll = setInterval(load, POLL_MS2); -+ } -+ return () => { -+ workflowListeners.delete(setWorkflows); -+ if (workflowListeners.size === 0 && workflowPoll !== null) { -+ clearInterval(workflowPoll); -+ workflowPoll = null; -+ } -+ }; -+ }, []); -+ return workflows; - } --function ScopeEditor({ secret, roster, onSave }) { -- const [mode, setMode] = (0, import_react5.useState)(secret.scope === "all" ? "all" : "some"); -- const [picked, setPicked] = (0, import_react5.useState)(secret.scope === "all" ? [] : secret.scope); -- const toggle = (id) => setPicked((list) => list.includes(id) ? list.filter((item) => item !== id) : [...list, id]); -- const same = mode === "all" ? secret.scope === "all" : secret.scope !== "all" && [...picked].sort().join() === [...secret.scope].sort().join(); -- return (0, import_react5.createElement)( -- "div", -- { className: "bt-secret-edit-block" }, -- (0, import_react5.createElement)("div", { className: "bt-secret-edit-label" }, t("Who may use it")), -- (0, import_react5.createElement)( -- "div", -- { className: "bt-secret-radios", role: "radiogroup", "aria-label": t("Who may use {name}", { name: secret.name }) }, -- [["all", t("All Bots")], ["some", t("Chosen Bots")]].map(([id, text]) => (0, import_react5.createElement)( -- "label", -- { key: id, className: "bt-secret-radio" }, -- (0, import_react5.createElement)("input", { type: "radio", name: `scope-${secret.name}`, checked: mode === id, onChange: () => setMode(id) }), -- text -- )) -- ), -- mode === "some" ? (0, import_react5.createElement)("div", { className: "bt-secret-bots" }, roster.bots.map((bot) => (0, import_react5.createElement)("button", { -- key: bot.id, -- type: "button", -- className: "bt-secret-bot", -- "aria-pressed": picked.includes(bot.id), -- onClick: () => toggle(bot.id) -- }, (0, import_react5.createElement)(BotAvatar, { bot, size: 20, badge: false, live: false }), bot.name, picked.includes(bot.id) ? (0, import_react5.createElement)(CheckIcon) : null))) : null, -- (0, import_react5.createElement)( -- "div", -- { className: "bt-secret-row-actions" }, -- (0, import_react5.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", disabled: same, onClick: () => onSave(mode === "all" ? "all" : picked) }, t("Save access")) -- ) -- ); -+var ownBot = (roster, id) => { -+ const bot = roster.byId?.[id]; -+ if (bot === void 0 || bot.remote) return void 0; -+ const me = roster.self?.memberId; -+ return me && bot.ownerId && bot.ownerId !== me ? void 0 : bot; -+}; -+var people = (roster, botId) => ({ assigner: roster.self?.memberId ?? "me", owner: roster.self?.memberId ?? "me", executingBot: botId ?? null }); -+var roomName = (roster, id) => roster.roomsById?.[id]?.name; -+var placeOf = (roster, roomId) => roomId && roomName(roster, roomId) ? t("in {name}", { name: roomName(roster, roomId) }) : ""; -+var lastWhy = (task) => [...task.actors ?? []].reverse().find((entry) => typeof entry.why === "string" && entry.why !== "")?.why ?? ""; -+var chatOfTask = (task) => task.origin?.roomId ?? task.origin?.sessionId ?? task.ownerBotId; -+var ownAskChat = (roster, bot, text2) => { -+ if (bot === void 0 || typeof text2 !== "string" || text2 === "") return void 0; -+ for (const [key, question] of Object.entries(roster.questions ?? {})) { -+ if (question == null || typeof question !== "object" || roster.roomsById?.[key]) continue; -+ if (question.question !== text2) continue; -+ if (key === bot.id || key === currentPart(bot)) return bot.id; -+ } -+ return void 0; -+}; -+var placeKey = (botId, roomId) => `${botId}|${roomId ?? ""}`; -+var partOf = (roster, chat) => roster.roomsById?.[chat] || !roster.byId?.[chat] ? chat : currentPart(roster.byId[chat]); -+var runningIn = (work, botId, roomId) => roomId === void 0 ? work.bots?.[botId]?.roomId === void 0 ? work.bots?.[botId] : void 0 : (work.rooms?.[roomId] ?? []).find((entry) => entry.botId === botId) ?? (work.bots?.[botId]?.roomId === roomId ? work.bots[botId] : void 0); -+function latestActivity(card, { roster, work, sessions }) { -+ const options = []; -+ if (card.kind === "task") options.push(lastWhy(card.task)); -+ if (card.kind === "run" || card.kind === "task") { -+ const entry = card.executingBot ? runningIn(work, card.executingBot, roster.roomsById?.[card.chat] ? card.chat : void 0) : void 0; -+ if (entry) options.push(`${workPhrase(entry.tool)}…`); -+ } -+ if (sessions?.byId && card.chat) options.push(previewOf(sessions, partOf(roster, card.chat), [], [], botNames(roster))); -+ return options.find((text2) => typeof text2 === "string" && text2 !== "" && text2 !== card.title && text2 !== card.reason) ?? ""; -+} -+function toolCalls(card, { roster, work }) { -+ const botId = card.bot?.id; -+ if (botId === void 0) return 0; -+ const roomId = roster.roomsById?.[card.chat] ? card.chat : void 0; -+ const running = runningIn(work, botId, roomId); -+ if (running !== void 0) return running.calls ?? 0; -+ let newest; -+ for (const entry of work.ended ?? []) { -+ if (entry.botId !== botId || (entry.roomId ?? "") !== (roomId ?? "")) continue; -+ if ((entry.at ?? 0) >= (newest?.at ?? -Infinity)) newest = entry; -+ } -+ return newest?.calls ?? 0; -+} -+var endedAt = (run) => [run?.finishedAt, run?.updatedAt, run?.at].find((at) => typeof at === "number"); -+function inboxModel({ roster, work = {}, tasks = [], sessions, workflows = [], now = Date.now() }) { -+ const needs = []; -+ const progress = []; -+ const done = []; -+ const fresh = (at) => typeof at === "number" && now - at <= KEEP_MS; -+ const waitingAt = /* @__PURE__ */ new Set(); -+ for (const approval of work.approvals ?? []) { -+ const bot = ownBot(roster, approval.botId); -+ if (bot === void 0) continue; -+ waitingAt.add(`${approval.botId}|${approval.roomId ?? ""}`); -+ needs.push({ -+ id: `approval:${approval.id}`, -+ kind: "approval", -+ status: "approval", -+ bot, -+ at: approval.at, -+ title: [approval.tool, approval.detail?.text].filter(Boolean).join(" · ") || t("A tool call"), -+ place: placeOf(roster, approval.roomId), -+ reason: approval.reason ?? "", -+ chat: approval.roomId ?? bot.id, -+ approval, -+ ...people(roster, bot.id) -+ }); -+ } -+ for (const [key, question] of Object.entries(roster.questions ?? {})) { -+ if (question == null || typeof question !== "object") continue; -+ const room2 = roster.roomsById?.[key]; -+ const bot = room2 ? ownBot(roster, question.botId) : (roster.bots ?? []).find((candidate) => ownBot(roster, candidate.id) && (candidate.id === key || currentPart(candidate) === key)); -+ if (bot === void 0) continue; -+ waitingAt.add(room2 ? `${bot.id}|${room2.id}` : `${bot.id}|`); -+ needs.push({ -+ id: `question:${question.id ?? key}`, -+ kind: "question", -+ status: "input", -+ bot, -+ ...room2 ? { room: room2 } : {}, -+ at: question.askedAt, -+ title: question.question ?? "", -+ reason: question.detail ?? "", -+ place: room2 ? placeOf(roster, key) : "", -+ chat: room2 ? key : bot.id, -+ sessionId: key, -+ question, -+ ...people(roster, bot.id) -+ }); -+ } -+ const held = (chat, count, extra) => needs.push({ -+ id: `held:${chat}`, -+ kind: "held", -+ status: "held", -+ chat, -+ at: void 0, -+ title: count === 1 ? t("1 message waits for your approval") : t("{count} messages wait for your approval", { count }), -+ hint: t("Approve it in the chat"), -+ ...extra -+ }); -+ for (const bot of roster.bots ?? []) { -+ const count = bot.pendingCount ?? 0; -+ if (count <= 0) continue; -+ if (bot.remote) held(bot.id, count, { bot, place: "", ...people(roster, null) }); -+ else if (ownBot(roster, bot.id)) held(bot.id, count, { bot, place: "", ...people(roster, bot.id) }); -+ } -+ for (const room2 of roster.rooms ?? []) { -+ const count = room2.pendingCount ?? 0; -+ if (count > 0) held(room2.id, count, { room: room2, place: placeOf(roster, room2.id), ...people(roster, null) }); -+ } -+ const seen = /* @__PURE__ */ new Set(); -+ const running = (botId, entry, roomId) => { -+ const bot = ownBot(roster, botId); -+ const key = `${botId}|${roomId ?? ""}`; -+ if (bot === void 0 || seen.has(key) || waitingAt.has(key)) return; -+ seen.add(key); -+ progress.push({ -+ id: `run:${key}`, -+ kind: "run", -+ status: "running", -+ bot, -+ at: entry.since, -+ since: entry.since, -+ title: `${workPhrase(entry.tool)}…`, -+ place: placeOf(roster, roomId), -+ chat: roomId ?? bot.id, -+ ...people(roster, bot.id) -+ }); -+ }; -+ for (const [botId, entry] of Object.entries(work.bots ?? {})) running(botId, entry, entry.roomId); -+ for (const [roomId, entries2] of Object.entries(work.rooms ?? {})) for (const entry of entries2) running(entry.botId, entry, roomId); -+ const lastEnded = /* @__PURE__ */ new Map(); -+ for (const entry of work.ended ?? []) { -+ const key = placeKey(entry.botId, entry.roomId); -+ if ((entry.at ?? 0) >= (lastEnded.get(key)?.at ?? -Infinity)) lastEnded.set(key, entry); -+ } -+ 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({ -+ id: `turn:${entry.id}`, -+ kind: "turn", -+ status: "failed", -+ bot, -+ at: entry.at, -+ title: asked || t("The last turn did not finish"), -+ reason: entry.failed, -+ place: placeOf(roster, entry.roomId), -+ chat, -+ ...people(roster, bot.id) -+ }); -+ } -+ for (const task of tasks) { -+ const bot = ownBot(roster, task.ownerBotId); -+ if (bot === void 0) continue; -+ const askedIn2 = task.waitingFor === "input" ? ownAskChat(roster, bot, task.inputRequest?.question) : void 0; -+ const card = { -+ id: `task:${task.id}`, -+ kind: "task", -+ bot, -+ at: task.updatedAt, -+ title: task.title || t("Untitled task"), -+ place: placeOf(roster, task.origin?.roomId), -+ chat: askedIn2 ?? chatOfTask(task), -+ task, -+ ...people(roster, task.workBotId ?? bot.id) -+ }; -+ if (task.status === "failed") { -+ if (fresh(task.updatedAt)) needs.push({ ...card, status: "failed", reason: lastWhy(task) }); -+ } else if (task.status === "result-ready") needs.push({ ...card, status: "result", reason: task.result?.summary ?? "" }); -+ else if (task.status === "blocked") needs.push({ ...card, status: task.waitingFor === "input" ? "input" : "approval", reason: task.inputRequest?.question ?? "" }); -+ else { -+ const column = columnOf(task); -+ if (column === "in-progress") progress.push({ ...card, status: "task", since: startedAtOf(task) }); -+ else if (column === "done" && fresh(task.updatedAt)) done.push({ ...card, status: task.status === "cancelled" ? "cancelled" : "done" }); -+ } -+ } -+ for (const graph of Array.isArray(workflows) ? workflows : []) { -+ if (graph === null || typeof graph !== "object") continue; -+ const seenRuns = /* @__PURE__ */ new Set(); -+ let latest; -+ for (const run of [...graph.runs ?? [], ...graph.history ?? []]) { -+ if (run === null || typeof run !== "object" || run.status !== "failed" || seenRuns.has(run.id)) continue; -+ seenRuns.add(run.id); -+ const at2 = endedAt(run); -+ if (!fresh(at2)) continue; -+ if (latest === void 0 || at2 > endedAt(latest)) latest = run; -+ } -+ if (latest === void 0) continue; -+ const at = endedAt(latest); -+ const name = typeof graph.name === "string" && graph.name.trim() !== "" ? graph.name.trim() : String(graph.id ?? t("A workflow")); -+ const nodeName = typeof latest.failure?.nodeName === "string" ? latest.failure.nodeName.trim() : ""; -+ const reason = typeof latest.failure?.text === "string" && latest.failure.text !== "" ? latest.failure.text : typeof latest.failure?.code === "string" ? latest.failure.code : ""; -+ const caller = typeof latest.task?.callerBotId === "string" ? ownBot(roster, latest.task.callerBotId) : void 0; -+ needs.push({ -+ id: `workflow:${graph.id ?? name}:${latest.id}`, -+ kind: "workflow", -+ status: "failed", -+ at, -+ workflow: { id: graph.id ?? "", name }, -+ run: { id: latest.id }, -+ title: nodeName === "" ? name : `${name} · ${nodeName}`, -+ reason, -+ ...caller === void 0 ? {} : { bot: caller } -+ }); -+ } -+ if (sessions?.byId) { -+ for (const bot of roster.bots ?? []) { -+ if (ownBot(roster, bot.id) === void 0 || work.bots?.[bot.id] !== void 0 || waitingAt.has(`${bot.id}|`)) continue; -+ if (lastEnded.get(placeKey(bot.id))?.failed !== void 0) continue; -+ const part = currentPart(bot); -+ const at = sessions.byId[part]?.updatedAt; -+ if (!fresh(at)) continue; -+ const text2 = previewOf(sessions, part, [], [], botNames(roster)); -+ if (text2 === "") continue; -+ done.push({ id: `chat:${bot.id}`, kind: "chat", status: "done", bot, at, title: text2, place: "", chat: bot.id, ...people(roster, bot.id) }); -+ } -+ } -+ const newest = (a, b) => (b.at ?? Infinity) - (a.at ?? Infinity); -+ for (const card of [...needs, ...progress, ...done]) { -+ card.activity = latestActivity(card, { roster, work, sessions }); -+ card.calls = toolCalls(card, { roster, work }); -+ } -+ const settled = done.sort(newest); -+ return { needs: needs.sort(newest), progress: progress.sort(newest), done: settled.slice(0, DONE_MAX), doneAll: settled, doneTotal: settled.length }; -+} -+var needsCount = (roster, work, tasks, now, workflows = []) => inboxModel({ roster, work, tasks, now, workflows }).needs.length; -+function startedAtOf(task) { -+ const started = (task.actors ?? []).filter((actor) => actor.status === "working" && typeof actor.at === "number"); -+ return started.length ? started.at(-1).at : task.updatedAt; -+} -+function visibleNeeds(model, answered) { -+ return { ...model, needs: model.needs.filter((card) => !answered?.[card.id]) }; -+} -+function inboxAnswer(actions, card, choice) { -+ if (card?.kind === "question") { -+ const reply = choice !== null && typeof choice === "object" ? choice : {}; -+ const selected = Array.isArray(reply.selected) ? reply.selected.filter((item) => typeof item === "string" && item !== "") : []; -+ const custom = typeof reply.custom === "string" ? reply.custom.trim() : ""; -+ if (selected.length === 0 && custom === "") return void 0; -+ return Promise.resolve(actions.answerQuestion(card.sessionId, card.question.id, { selected, custom })); -+ } -+ if (card?.kind === "task" && card.status === "input") { -+ const text2 = typeof choice === "string" ? choice.trim() : ""; -+ const questionId = card.task?.inputRequest?.id; -+ if (text2 === "" || typeof questionId !== "string") return void 0; -+ return Promise.resolve(actions.answerTaskInput(card.task.id, questionId, text2)); -+ } -+ if (card?.kind === "task" && card.status === "result") { -+ if (choice === "accept") return Promise.resolve(actions.acceptTaskResult(card.task.id)); -+ const note = choice !== null && typeof choice === "object" ? String(choice.note ?? "").trim() : ""; -+ if (note === "") return void 0; -+ return Promise.resolve(actions.sendBackTask(card.task.id, note)); -+ } -+ if (card?.kind === "approval" && card.approval?.answerable === true) { -+ return Promise.resolve(actions.answerApproval(card.approval.id, choice)); -+ } -+ return void 0; - } --function ValueEditor({ secret, onSave }) { -- const [value, setValue] = (0, import_react5.useState)(""); -- const [replaced, setReplaced] = (0, import_react5.useState)(false); -- const long = value.trim().length >= MIN_VALUE; -- const save = () => { -- if (!long) return; -- void Promise.resolve(onSave(value)).then((ok) => { -- if (ok) { -- setValue(""); -- setReplaced(true); -+var answerSlots = /* @__PURE__ */ new Map(); -+var answerSlot = (id) => { -+ let slot = answerSlots.get(id); -+ if (slot === void 0) { -+ slot = { text: "", picked: [] }; -+ answerSlots.set(id, slot); -+ } -+ return slot; -+}; -+var resultSlots = /* @__PURE__ */ new Map(); -+var resultSlot = (id) => { -+ let slot = resultSlots.get(id); -+ if (slot === void 0) { -+ slot = { open: false, text: "" }; -+ resultSlots.set(id, slot); -+ } -+ return slot; -+}; -+function ResultActions({ card, busy, onAnswer }) { -+ const [, redraw] = (0, import_react13.useState)(0); -+ if (!(card.kind === "task" && card.status === "result")) return null; -+ const slot = resultSlot(card.id); -+ const send = () => { -+ const note = slot.text.trim(); -+ if (busy || note === "") return; -+ onAnswer(card, { note }); -+ }; -+ return (0, import_react13.createElement)( -+ "span", -+ { className: "bt-inbox-result" }, -+ slot.open ? (0, import_react13.createElement)("input", { -+ type: "text", -+ className: "bt-inbox-answer-text", -+ value: slot.text, -+ disabled: busy, -+ "aria-label": t("What should change"), -+ placeholder: t("What should change"), -+ onChange: (event) => { -+ slot.text = String(event.target?.value ?? ""); -+ redraw((count) => count + 1); -+ }, -+ onKeyDown: (event) => { -+ if (event.key !== "Enter" || event.shiftKey || event.nativeEvent.isComposing) return; -+ event.preventDefault(); -+ send(); - } -- }); -+ }) : null, -+ slot.open ? (0, import_react13.createElement)("button", { type: "button", className: "bt-send bt-soft-sm", disabled: busy, onClick: send }, t("Send it back")) : (0, import_react13.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", disabled: busy, onClick: () => { -+ slot.open = true; -+ redraw((count) => count + 1); -+ } }, t("Send back")), -+ (0, import_react13.createElement)("button", { type: "button", className: "bt-send bt-soft-sm", disabled: busy, onClick: () => onAnswer(card, "accept") }, t("Accept")) -+ ); -+} -+function AnswerFields({ card, busy, onAnswer }) { -+ const [, redraw] = (0, import_react13.useState)(0); -+ const question = card.kind === "question"; -+ const taskInput = card.kind === "task" && card.status === "input" && typeof card.task?.inputRequest?.id === "string"; -+ if (!question && !taskInput) return null; -+ const slot = answerSlot(card.id); -+ const multi = question && card.question?.multiSelect === true; -+ const options = question && Array.isArray(card.question?.options) ? card.question.options : []; -+ const send = () => { -+ if (busy) return; -+ const custom = slot.text.trim(); -+ if (question) { -+ const selected = multi ? slot.picked.filter((label) => label !== "") : []; -+ if (selected.length === 0 && custom === "") return; -+ onAnswer(card, { selected, custom }); -+ return; -+ } -+ if (custom === "") return; -+ onAnswer(card, custom); - }; -- const edit = (next) => { -- setValue(next); -- setReplaced(false); -+ const choose = (label) => { -+ if (busy) return; -+ if (!multi) { -+ onAnswer(card, { selected: [label], custom: "" }); -+ return; -+ } -+ slot.picked = slot.picked.includes(label) ? slot.picked.filter((item) => item !== label) : [...slot.picked, label]; -+ redraw((count) => count + 1); - }; -- return (0, import_react5.createElement)( -- "div", -- { className: "bt-secret-edit-block" }, -- (0, import_react5.createElement)("div", { className: "bt-secret-edit-label" }, t("Replace the value")), -- (0, import_react5.createElement)( -- "div", -- { className: "bt-secret-inline" }, -- (0, import_react5.createElement)("label", { className: "bt-q-field bt-secret-field" }, (0, import_react5.createElement)(MaskedInput, { value, onChange: edit, onEnter: save, label: t("New value of {name}", { name: secret.name }) })), -- (0, import_react5.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", disabled: !long, onClick: save }, t("Replace")) -- ), -- value !== "" && !long ? (0, import_react5.createElement)("div", { className: "bt-set-hint" }, t("At least {count} characters.", { count: MIN_VALUE })) : replaced ? (0, import_react5.createElement)("div", { className: "bt-set-hint bt-secret-ok", role: "status" }, (0, import_react5.createElement)(CheckIcon), t("Replaced. Bots get the new value from their next command.")) : null -+ return (0, import_react13.createElement)( -+ "span", -+ { className: "bt-inbox-answer" }, -+ ...options.map((option, index) => (0, import_react13.createElement)("button", { -+ key: `${option.label}-${index}`, -+ type: "button", -+ className: "bt-soft bt-soft-sm", -+ disabled: busy, -+ "aria-pressed": multi ? slot.picked.includes(option.label) : void 0, -+ onClick: () => choose(option.label) -+ }, option.label)), -+ (0, import_react13.createElement)("input", { -+ type: "text", -+ className: "bt-inbox-answer-text", -+ value: slot.text, -+ disabled: busy, -+ "aria-label": t("Your own answer"), -+ placeholder: t("Type your own answer"), -+ onChange: (event) => { -+ slot.text = String(event.target?.value ?? ""); -+ redraw((count) => count + 1); -+ }, -+ onKeyDown: (event) => { -+ if (event.key !== "Enter" || event.shiftKey || event.nativeEvent.isComposing) return; -+ event.preventDefault(); -+ send(); -+ } -+ }), -+ (0, import_react13.createElement)("button", { type: "button", className: "bt-send bt-soft-sm", disabled: busy, onClick: send }, t("Submit")) - ); - } --function SecretRow({ secret, roster, actions, run, open, onToggle }) { -- const [confirm, setConfirm] = (0, import_react5.useState)(false); -- (0, import_react5.useEffect)(() => { -- if (!open) setConfirm(false); -- }, [open]); -- const asker = secret.requestedBy ? roster.byId[secret.requestedBy]?.name ?? t("a deleted Bot") : t("you"); -- const allowed = secret.scope === "all" ? [] : secret.scope.map((id) => roster.byId[id]).filter(Boolean); -- const meta = [t("Asked by {name}", { name: asker }), t("added {time}", { time: when(secret.createdAt) }), secret.lastUsedAt ? t("last used {time}", { time: when(secret.lastUsedAt) }) : t("not used yet")].join(" · "); -- return (0, import_react5.createElement)( -+function InboxRow({ wide, useRoster, useWork, useUi, actions }) { -+ const roster = useRoster((value) => value); -+ const work = useWork ? useWork((value) => value) : {}; -+ const open = useUi ? useUi((value) => Boolean(value.inbox)) : false; -+ const tasks = useTasks(actions); -+ const workflows = useWorkflows(actions); -+ const count = needsCount(roster, work, tasks, void 0, workflows); -+ const label = count > 0 ? t("Inbox, {count} need you", { count }) : t("Inbox"); -+ return (0, import_react13.createElement)( -+ "button", -+ { -+ type: "button", -+ className: "bt-row bt-inbox-row", -+ "aria-label": label, -+ title: wide ? void 0 : label, -+ "aria-haspopup": "dialog", -+ "aria-expanded": open, -+ onClick: () => actions.openInbox() -+ }, -+ (0, import_react13.createElement)("span", { className: "bt-inbox-mark" }, (0, import_react13.createElement)(InboxIcon)), -+ wide ? (0, import_react13.createElement)("span", { className: "bt-inbox-label" }, t("Inbox")) : null, -+ count > 0 ? (0, import_react13.createElement)("span", { className: "bt-inbox-count", "aria-hidden": true }, count > 99 ? "99+" : String(count)) : null +-@keyframes bt-fade-in{from{opacity:0}to{opacity:1}} +-@keyframes bt-fade-out{from{opacity:1}to{opacity:0}} +-[data-composer-card]{display:flex!important;flex-direction:row;flex-wrap:wrap;align-items:center;gap:0!important;min-height:44px;border-radius:22px!important;padding:7.5px!important;box-sizing:border-box;background:var(--bt-main)!important;border:.5px solid var(--bt-line-2)!important;box-shadow:0 2px 8px -1px rgba(0,0,0,.05),0 1px 2px rgba(0,0,0,.03),0 0 0 1px rgba(228,228,228,.04)!important;transition:border-color .15s ease,background-color .15s ease,${spring(SPRING_SHAPE, "border-radius")}!important} +-[data-composer-card]:hover,[data-composer-card]:focus-within{border-color:color-mix(in srgb,var(--bt-ink) 30%,transparent)!important} +-[data-composer-card]:has(> [data-slot="conversation.input.attachments"] *),[data-composer-card]:has([data-composer-input] br + *),[data-composer-card]:has([data-composer-input] > :nth-child(2)){border-radius:18px!important} +-[class*="_root"]:has(> [data-composer-card]){padding:0 11px 13px 16px!important} +-[class*="_root"]:has(> [data-composer-card])>[data-composer-card]{max-width:min(992px,100%)!important} +-[data-composer-card]>[class*="_row"]{display:contents!important} +-[data-composer-card] [class*="_tools"]{order:1;flex:none;align-self:flex-end;margin:0} +-[data-composer-card]>[data-input-scroll]{order:2;flex:1 1 0;min-width:0;padding:0 8px!important;margin:0!important} +-[data-composer-card] [class*="_trailing"]{order:3;flex:none;align-self:flex-end;margin:0} +-[data-composer-card]>[data-slot="conversation.input.attachments"]{order:0;flex-basis:100%} +-[data-composer-card] [data-composer-input]{min-height:24px;padding:2px 0!important} +-[data-composer-card] [data-composer-placeholder]{left:0!important;top:2px!important} +-[data-composer-placeholder]{font-size:0!important;line-height:0!important} +-[data-composer-placeholder]::after{content:var(--bt-placeholder,"Message");display:block;font-size:14px;line-height:24px;color:color-mix(in srgb,var(--bt-ink) 36%,transparent)} +-/* The team's "+" (ComposerPlus) takes the seat of the shell's command-menu button. */ +-[data-composer-card] button[class*="_add"]{display:none!important} +-.bt-plus{flex:none;width:28px;height:28px;padding:0;border:0;border-radius:50%;display:inline-flex;align-items:center;justify-content:center;background:color-mix(in srgb,var(--bt-ink) 5%,transparent);box-shadow:inset 0 0 0 .5px var(--bt-line);color:var(--bt-ink-2);cursor:pointer;transition:background-color .15s ease,color .15s ease,${spring(SPRING_TAP, "scale")}} +-.bt-plus:hover,.bt-plus[aria-expanded=true]{background:color-mix(in srgb,var(--bt-ink) 10%,transparent);color:var(--bt-ink)} +-.bt-plus:active{scale:.9} +-.bt-plus svg{width:14px;height:14px;transition:${spring(SPRING_POP, "rotate")}} +-.bt-plus[aria-expanded=true] svg{rotate:45deg} +-.bt-plus-menu{min-width:180px} +-[data-composer-card] [class*="_tools"]{gap:0!important} +-[data-composer-card] [class*="_modes"]:empty{display:none!important} +-[data-composer-card] [class*="_activity"]:not(:has([data-slot] > *)){display:none!important} +-[data-composer-card] [class*="_trailing"]{gap:8px} +-[data-composer-card] [class*="_standardControls"]{display:flex;align-items:center;gap:8px} +-[data-composer-card] button[class*="_primary"]{width:28px!important;height:28px!important;background:var(--bt-accent)!important;color:var(--bt-accent-ink,#fff)!important;border-radius:50%!important;transition:opacity .15s ease,${spring(SPRING_TAP, "scale")};animation:bt-icon-swap ${SPRING_POP.duration}ms ${SPRING_POP.easing}} +-[data-composer-card] button[class*="_primary"]:hover:not(:disabled){opacity:.9} +-[data-composer-card] button[class*="_primary"]:active:not(:disabled){scale:.9} +-@keyframes bt-icon-swap{from{opacity:0;transform:scale(.5) rotate(-30deg)}} +-[data-composer-card] button[class*="_primary"] svg{width:14px;height:14px} +-[data-composer-card] button[class*="_primary"]:disabled{display:none!important} +-[data-composer-dock]{display:none!important} +-.bt-mic,.bt-voice{flex:none;width:28px;height:28px;border-radius:50%;border:0;padding:0;display:inline-grid;place-items:center;cursor:pointer;transition:background-color .15s ease,color .15s ease,${spring(SPRING_TAP, "scale")}} +-.bt-mic{background:color-mix(in srgb,var(--bt-ink) 5%,transparent);box-shadow:inset 0 0 0 .5px var(--bt-line);color:var(--bt-ink-2)} +-.bt-mic:hover{background:color-mix(in srgb,var(--bt-ink) 10%,transparent);color:var(--bt-ink)} +-.bt-mic[aria-pressed=true]{background:#ff3e51;color:#fff;animation:bt-mic-pulse 1.4s ease-in-out infinite} +-@keyframes bt-mic-pulse{0%,100%{box-shadow:0 0 0 0 rgba(255,62,81,.35)}50%{box-shadow:0 0 0 5px rgba(255,62,81,0)}} +-.bt-voice{display:none;background:var(--bt-accent);color:var(--bt-accent-ink,#fff);animation:bt-icon-swap ${SPRING_POP.duration}ms ${SPRING_POP.easing}} +-.bt-voice:hover{opacity:.9} +-.bt-mic:active,.bt-voice:active{scale:.9} +-[data-composer-card]:has(button[class*="_primary"]:disabled) .bt-voice{display:inline-grid} +-body[data-bt-room] .bt-voice{display:none!important} +-body[data-bt-room] [data-composer-card]:has(button[class*="_primary"]:disabled) .bt-mic:not([aria-pressed=true]){background:var(--bt-accent);color:var(--bt-accent-ink,#fff)} +-.bt-mic-note{position:absolute;right:12px;bottom:calc(100% + 8px);padding:5px 10px;border-radius:8px;background:var(--bt-user);color:var(--bt-user-ink);font-size:12px;line-height:16px;white-space:nowrap;pointer-events:none;animation:bt-rise .16s ease-out} +-[data-chain-overlay-fallback="conversation.composer"]:has(~ .bt-q){display:block!important;order:2} +-.bt-q{order:1} +-div:has(> nav [class*="_marks"]){display:none!important} +-/* Every user-side bubble (sent, steering mid-turn, not yet admitted, question replies) +- takes its surface and its text from the one --bt-user pair: a skin that repaints +- the host's bubble surface alone would otherwise leave the text unreadable. */ +-[class*="_userRow"] [class*="_bubble"],[data-chat-flow-kind=question-reply] [class*="_bubble"],[data-chat-flow-kind=command-input] [class*="_bubble"]{background:var(--bt-user)!important;color:var(--bt-user-ink)!important;border-radius:18px!important;padding:7px 12px!important;font-size:var(--dsh-content-font-size,14px)!important;line-height:calc(var(--dsh-content-font-size,14px) + 8px)!important} +-[class*="_userRow"] [class*="_bubble"] [class*="_markdown"],[class*="_userRow"] [class*="_bubble"] a{color:inherit} +-/* Sent messages get the hover toolbar below; steering and unsent bubbles keep none. */ +-[class*="_userRow"] [class*="_actions"]{display:none!important} +-[data-chat-flow-kind=user] [class*="_userRow"]{flex-direction:row-reverse;align-items:center;justify-content:flex-start} +-[data-chat-flow-kind=user] [class*="_actions"]{display:flex!important;height:24px!important;gap:2px!important;opacity:0!important;visibility:hidden;transition:opacity .12s ease,visibility .12s!important;flex:none} +-[data-chat-flow-kind=user] [class*="_userRow"]:hover [class*="_actions"],[data-chat-flow-kind=user] [class*="_userRow"]:focus-within [class*="_actions"]{opacity:1!important;visibility:visible} +-[data-chat-flow-kind=user] [class*="_actions"] [class*="_timeStart"]{display:none} +-[data-chat-flow-kind=user] [class*="_actions"] button{width:24px;height:24px;border-radius:6px;color:var(--bt-ink-2)} +-[data-chat-flow-kind=user] [class*="_actions"] button:hover{background:var(--bt-hover);color:var(--bt-ink)} +-[data-chat-flow-kind=user] [class*="_actions"] svg{width:15px;height:15px} +-.bt-q-inline{display:flex;margin:4px 0 6px} +-[data-chat-flow-kind=assistant-step]+[data-step-process]:has(.bt-q-inline){margin-top:0!important} +-[data-chat-flow-kind=question-reply]:has(.bt-q-inline),[data-chat-group-key]:has([data-chat-flow-kind=question-reply] .bt-q-inline),[data-chat-group-key]:has(.bt-q-gone){margin-top:0!important} +-.bt-q-inline .bt-q{margin:0;max-width:min(550px,86%);order:0} +-.bt-q-dock{width:100%;display:flex;justify-content:center} +-.bt-q-dock .bt-q{margin:0 auto 8px} +-[class*="_toBottomSlot"]{justify-content:center!important;padding-right:0!important} +-[data-conversation-scroll] [class*="_toBottomSlot"]{bottom:calc(var(--dsh-composer-height,152px) + 8px)!important} +-[class*="_toBottomSlot"]>button{width:36px!important;height:36px!important;margin-top:-36px!important;background:var(--bt-main)!important;color:var(--bt-ink)!important;box-shadow:0 0 0 .5px var(--bt-line-2),0 2px 8px -2px rgba(0,0,0,.12)!important;animation:bt-tobottom-in ${SPRING_TAP.duration}ms ${SPRING_TAP.easing},bt-veil-in .16s ease-out;transition:background-color .12s ease,${spring(SPRING_TAP, "scale")}} +-[class*="_toBottomSlot"]>button:active{scale:.9} +-[class*="_toBottomSlot"]>button:hover{background:color-mix(in srgb,var(--bt-ink) 4%,var(--bt-main))!important} +-[class*="_toBottomSlot"]>button svg{display:none} +-[class*="_toBottomSlot"]>button::before{content:"";width:20px;height:20px;background:currentColor;-webkit-mask:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20' fill='none' stroke='black' stroke-width='1.6' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M10 4v12M5 11l5 5 5-5'/%3E%3C/svg%3E") center/contain no-repeat;mask:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20' fill='none' stroke='black' stroke-width='1.6' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M10 4v12M5 11l5 5 5-5'/%3E%3C/svg%3E") center/contain no-repeat} +-@keyframes bt-tobottom-in{from{translate:0 8px;scale:.8}} +-[data-bt-news] [class*="_toBottomSlot"]>button{visibility:hidden!important;pointer-events:none!important} +-.bt-news{position:fixed;z-index:30;transform:translateX(-50%);display:inline-flex;align-items:center;overflow:hidden;border-radius:999px;background:#0c64c1;color:#fcfcfc;box-shadow:inset 0 0 0 1px rgba(20,20,20,.05),0 1px 3px 0 rgba(0,0,0,.12);font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",system-ui,sans-serif;animation:bt-news-in ${SPRING_TAP.duration}ms ${SPRING_TAP.easing},bt-veil-in .16s ease-out} +-@keyframes bt-news-in{from{translate:0 8px;scale:.9}} +-.bt-news[data-direction=up]{animation-name:bt-news-in-up,bt-veil-in} +-@keyframes bt-news-in-up{from{translate:0 -8px;scale:.9}} +-.bt-news-jump{display:inline-flex;align-items:center;gap:2px;margin:0;padding:4px 26px 4px 4px;border:0;border-radius:0;background:transparent;color:inherit;font:inherit;font-size:13px;line-height:18px;white-space:nowrap;cursor:pointer;outline:none} +-.bt-news-ico{flex:none;display:inline-flex;align-items:center;justify-content:center;width:20px;height:20px} +-.bt-news-ico svg{width:16px;height:16px} +-.bt-news-x{position:absolute;right:4px;top:50%;z-index:1;transform:translateY(-50%);width:20px;height:20px;padding:0;border:0;border-radius:999px;background:transparent;color:inherit;opacity:.75;display:inline-flex;align-items:center;justify-content:center;cursor:pointer} +-.bt-news-x svg{width:12px;height:12px} +-.bt-news-x:hover{opacity:1;background:rgba(20,20,20,.5)} +-@media (prefers-reduced-motion:reduce){.bt-news,[class*="_toBottomSlot"]>button{animation:none}} +-.bt-new-sep{display:flex;align-items:center;gap:8px;margin:14px 0 8px;padding:2px 0;font-size:11px;line-height:14px;font-weight:500;letter-spacing:.02em;text-transform:uppercase;color:#0c64c1;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",system-ui,sans-serif} +-.bt-new-sep::before,.bt-new-sep::after{content:"";flex:1;height:1px;background:color-mix(in srgb,#0c64c1 20%,transparent)} +-.bt-open-new{display:flex;flex-direction:column} +-.bt-open-new .bt-new-sep{margin:18px 0 0} +-.bt-open-new .bt-time-sep{height:16px;margin:32px 0 2px} +-.bt-earlier{display:flex;flex-direction:column;gap:6px} +-.bt-earlier-top{display:flex;justify-content:center;align-items:center;height:34px;flex:none} +-.bt-earlier-top .bt-soft{height:28px;font-size:12px;padding:0 12px} +-.bt-urow{display:flex;justify-content:flex-end} +-.bt-ububble{max-width:min(560px,78%);background:var(--bt-user);color:var(--bt-user-ink);border-radius:18px;padding:7px 12px;font-size:14px;line-height:20px;box-sizing:border-box;overflow-wrap:anywhere} +-.bt-ububble [class*="_markdown"],.bt-ububble a{color:inherit!important} +-.bt-ububble p{margin:0;line-height:22px} +-.bt-uimages{display:block;font-size:12px;opacity:.7} +-.bt-earlier .bt-event{margin:4px 0} +-.bt-part-line{display:flex;align-items:center;gap:10px;margin:10px 0 6px;font-size:11px;line-height:14px;color:var(--bt-ink-3)} +-.bt-part-line::before,.bt-part-line::after{content:"";flex:1;height:1px;background:var(--bt-line)} +-`; +-function installStyles(text, tag) { +- const style = document.createElement("style"); +- style.dataset.dshBot = tag; +- style.textContent = text; +- document.head.appendChild(style); +- return style; ++function PersonMessage({ name, texts: texts2, roster, selfId, actions, sessionId, msgKey, useUi }) { ++ if (!(texts2 ?? []).some((text2) => String(text2 ?? "").trim() !== "")) return null; ++ const lead = (0, import_react10.createElement)("span", { className: "bt-lead", title: name }, (0, import_react10.createElement)(PersonMark, { name })); ++ return (0, import_react10.createElement)( ++ "div", ++ { className: "bt-group-msg bt-person-msg", "data-person": name }, ++ (0, import_react10.createElement)("span", { className: "bt-group-name" }, name), ++ (0, import_react10.createElement)(Bubbles, { texts: texts2, roster, selfId, actions, sessionId, msgKey, useUi, lead }) + ); +} -+function Status({ status }) { -+ const { label, tone, Icon } = STATUSES[status]; -+ return (0, import_react13.createElement)("span", { className: "bt-inbox-status", "data-tone": tone }, (0, import_react13.createElement)(Icon), (0, import_react13.createElement)("span", null, t(label))); -+} -+function whenText(card, now) { -+ if (card.status === "running" || card.status === "task") { -+ return typeof card.since === "number" && now - card.since >= 6e4 ? t("for {time}", { time: elapsedLabel(now - card.since) }) : t("just started"); ++function EventLine({ prefix, bots, joiner = t("and"), onOpen, whole = false }) { ++ if (whole && bots.length === 1) { ++ return (0, import_react10.createElement)( ++ "div", ++ { className: "bt-event" }, ++ (0, import_react10.createElement)( ++ "button", ++ { type: "button", className: "bt-event-whole", title: bots[0].name, onClick: () => onOpen?.(bots[0]) }, ++ (0, import_react10.createElement)("span", null, prefix), ++ (0, import_react10.createElement)(BotMark, { bot: bots[0], size: 16 }), ++ (0, import_react10.createElement)("span", { className: "bt-event-name" }, bots[0].name) ++ ) ++ ); + } -+ return typeof card.at === "number" ? relativeShort(card.at) : ""; ++ const parts = []; ++ parts.push((0, import_react10.createElement)("span", { key: "p" }, prefix)); ++ bots.forEach((bot, index) => { ++ if (index > 0) parts.push((0, import_react10.createElement)("span", { key: `j${index}` }, joiner)); ++ parts.push((0, import_react10.createElement)( ++ "button", ++ { key: bot.id ?? index, type: "button", onClick: () => onOpen?.(bot) }, ++ (0, import_react10.createElement)(BotMark, { bot, size: 16 }), ++ bot.name ++ )); ++ }); ++ return (0, import_react10.createElement)("div", { className: "bt-event" }, parts); +} -+function InboxCard({ card, roster, now, busy, error, onAnswer, onOpen }) { -+ const who = card.bot ? card.bot.name : card.room?.name ?? ""; -+ const produced = card.kind === "task" && card.status === "result" ? [card.bot?.model] : []; -+ const meta = [who, ...produced, card.place, whenText(card, now)].filter(Boolean).join(" · "); -+ const answerable = card.kind === "approval" && card.approval?.answerable === true; -+ const evidence = card.kind === "task" && card.status === "result" ? card.task?.result?.links ?? [] : []; -+ const sentBack = card.kind === "task" && card.status === "task" ? card.task?.followUp?.note : void 0; -+ const target = card.kind === "task" ? card.task?.target?.href : void 0; -+ const writeBack = card.kind === "task" && card.task?.writeBack?.state === "failed" ? card.task.writeBack : void 0; -+ const unconfirmed = card.kind === "task" && card.task?.writeBack?.state === "unconfirmed" ? card.task.writeBack : void 0; -+ return (0, import_react13.createElement)( - "div", -- { className: "bt-secret-item", "data-open": open ? "" : void 0 }, -- (0, import_react5.createElement)( -- "button", -- { type: "button", className: "bt-set-row bt-secret-summary", "aria-expanded": open, onClick: onToggle }, -- (0, import_react5.createElement)( -+ { className: "bt-inbox-card", role: "group", "aria-label": `${t(STATUSES[card.status].label)}: ${card.title}`, "data-status": card.status, "data-run": card.kind === "workflow" ? card.run?.id : void 0 }, -+ (0, import_react13.createElement)("span", { className: "bt-inbox-ava" }, card.bot ? (0, import_react13.createElement)(BotAvatar, { bot: card.bot, size: 28, badge: false, live: false }) : card.room ? (0, import_react13.createElement)(RoomAvatar, { room: card.room, roster, size: 28 }) : null), -+ (0, import_react13.createElement)( -+ "span", -+ { className: "bt-inbox-copy" }, -+ (0, import_react13.createElement)( - "span", -- { className: "bt-set-copy" }, -- (0, import_react5.createElement)("span", { className: "bt-secret-title" }, (0, import_react5.createElement)(KeyIcon), (0, import_react5.createElement)("code", { className: "bt-secret-name" }, secret.name), secret.purpose ? (0, import_react5.createElement)("span", { className: "bt-secret-purpose" }, secret.purpose) : null), -- (0, import_react5.createElement)("span", { className: "bt-set-hint" }, meta) -+ { className: "bt-inbox-top" }, -+ (0, import_react13.createElement)(Status, { status: card.status }), -+ (0, import_react13.createElement)("span", { className: "bt-inbox-meta", title: meta }, meta) - ), -- (0, import_react5.createElement)( -- "span", -- { className: "bt-set-control" }, -- secret.scope === "all" ? (0, import_react5.createElement)("span", { className: "bt-tag" }, t("All Bots")) : allowed.length === 0 ? (0, import_react5.createElement)("span", { className: "bt-tag" }, t("No Bot")) : (0, import_react5.createElement)("span", { className: "bt-secret-faces", title: allowed.map((bot) => bot.name).join(", ") }, allowed.slice(0, 5).map((bot) => (0, import_react5.createElement)(BotAvatar, { key: bot.id, bot, size: 20, badge: false, live: false }))) -- ) -+ (0, import_react13.createElement)("span", { className: "bt-inbox-title", title: card.title }, card.title), -+ card.calls > 0 ? (0, import_react13.createElement)("span", { className: "bt-inbox-calls" }, t("{count} tool calls", { count: card.calls })) : null, -+ card.reason ? (0, import_react13.createElement)("span", { className: "bt-inbox-reason", title: card.reason }, failureSentence(card.reason, t)) : null, -+ card.activity ? (0, import_react13.createElement)("span", { className: "bt-inbox-activity", title: card.activity }, t("Latest: {text}", { text: card.activity })) : null, -+ card.hint ? (0, import_react13.createElement)("span", { className: "bt-inbox-hint" }, card.hint) : null, -+ evidence.length > 0 ? (0, import_react13.createElement)("span", { className: "bt-inbox-evidence" }, ...evidence.map((href, index) => (0, import_react13.createElement)("a", { -+ key: `${href}-${index}`, -+ href, -+ target: "_blank", -+ rel: "noreferrer noopener", -+ className: "bt-inbox-evidence-link", -+ title: href -+ }, href))) : null, -+ sentBack ? (0, import_react13.createElement)("span", { className: "bt-inbox-reason", title: sentBack }, t("Sent back: {note}", { note: sentBack })) : null, -+ target ? (0, import_react13.createElement)("span", { className: "bt-inbox-evidence" }, (0, import_react13.createElement)("a", { -+ href: target, -+ target: "_blank", -+ rel: "noreferrer noopener", -+ className: "bt-inbox-evidence-link bt-inbox-target", -+ title: target -+ }, t("Comment goes to {target}", { target: target.replace(/^https:\/\/github\.com\//, "") }))) : null, -+ writeBack ? (0, import_react13.createElement)("span", { className: "bt-inbox-writeback", role: "alert", title: writeBack.error }, t("Could not comment on {target}: {error}", { target: writeBack.target, error: writeBack.error })) : null, -+ unconfirmed ? (0, import_react13.createElement)("span", { className: "bt-inbox-writeback", title: unconfirmed.target }, t("Could not confirm the comment on {target} went out", { target: unconfirmed.target })) : null, -+ (0, import_react13.createElement)(AnswerFields, { card, busy, onAnswer }), -+ (0, import_react13.createElement)(ResultActions, { card, busy, onAnswer }), -+ error ? (0, import_react13.createElement)("span", { className: "bt-inbox-error", role: "alert" }, error) : null - ), -- open ? (0, import_react5.createElement)( -- "div", -- { className: "bt-secret-edit" }, -- (0, import_react5.createElement)(ScopeEditor, { key: JSON.stringify(secret.scope), secret, roster, onSave: (scope) => run(actions.secretUpdate(secret.name, scope)) }), -- (0, import_react5.createElement)(ValueEditor, { secret, onSave: (value) => run(actions.secretSet({ name: secret.name, value })) }), -- (0, import_react5.createElement)( -- "div", -- { className: "bt-secret-row-actions" }, -- confirm ? [ -- (0, import_react5.createElement)("span", { key: "q", className: "bt-set-hint" }, t("Delete {name}? Bots lose it at once.", { name: secret.name })), -- (0, import_react5.createElement)("button", { key: "no", type: "button", className: "bt-soft bt-soft-sm", onClick: () => setConfirm(false) }, t("Keep")), -- (0, import_react5.createElement)("button", { key: "yes", type: "button", className: "bt-soft bt-soft-sm bt-secret-danger", onClick: () => run(actions.secretDelete(secret.name)) }, t("Delete")) -- ] : (0, import_react5.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm bt-secret-danger", onClick: () => setConfirm(true) }, (0, import_react5.createElement)(TrashIcon), t("Delete")) -- ) -- ) : null -+ (0, import_react13.createElement)( -+ "span", -+ { className: "bt-inbox-actions" }, -+ answerable ? (0, import_react13.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", disabled: busy, onClick: () => onAnswer(card, "reject") }, t("Reject")) : null, -+ answerable ? (0, import_react13.createElement)("button", { type: "button", className: "bt-send bt-soft-sm", disabled: busy, onClick: () => onAnswer(card, "allow") }, t("Allow once")) : null, -+ ...(answerable ? alwaysChoices(card.approval, card.bot) : []).map(([choice, label]) => (0, import_react13.createElement)("button", { key: choice, type: "button", className: "bt-soft bt-soft-sm", "data-choice": choice, disabled: busy, onClick: () => onAnswer(card, choice) }, t(label))), -+ (0, import_react13.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", "aria-label": who ? t("Open {name}", { name: card.room?.name ?? who }) : t("Open"), onClick: () => onOpen(card) }, t("Open")) -+ ) - ); - } --function AddSecret({ actions, run, onDone }) { -- const [name, setName] = (0, import_react5.useState)(""); -- const [purpose, setPurpose] = (0, import_react5.useState)(""); -- const [value, setValue] = (0, import_react5.useState)(""); -- const ready = /^[A-Za-z][\w-]{0,63}$/.test(name.trim()) && value.trim().length >= MIN_VALUE; -- const save = () => { -- if (!ready) return; -- void run(actions.secretSet({ name, purpose, value, scope: "all" })).then((ok) => { -- if (ok) { -- setValue(""); -- onDone(); -- } -- }); -+function useNow2(ms) { -+ const [now, setNow] = (0, import_react13.useState)(Date.now); -+ (0, import_react13.useEffect)(() => { -+ const timer = setInterval(() => setNow(Date.now()), ms); -+ return () => clearInterval(timer); -+ }, [ms]); -+ return now; ++var heldForm = (text2) => String(text2 ?? "").replace(/\s+/g, " ").trim().replace(/^(to (the )?face[ -]?room|to the room)\s*:\s*/i, "").trim(); ++var sameName = (a, b) => String(a ?? "").replace(/\s+/g, " ").trim().toLowerCase() === String(b ?? "").replace(/\s+/g, " ").trim().toLowerCase(); ++function isHeldLine(room2, text2, bot) { ++ if (room2?.origin !== "face-to-face") return false; ++ const line2 = heldForm(text2); ++ if (!line2) return false; ++ return (room2.pendingApprovals ?? []).some((item) => { ++ if (item?.kind !== "face_room_message") return false; ++ if (bot !== void 0 && String(item.from ?? "").trim() !== "" && !sameName(item.from, bot.name)) return false; ++ const held = heldForm(item.text); ++ return held !== "" && (held === line2 || held.length >= 799 && line2.startsWith(held)); ++ }); +} -+function openCard(actions, card) { -+ actions.closeInbox(); -+ if (card?.kind === "workflow") { -+ actions.openSettings("workflow"); -+ return; ++var HELD_SENDING_MS = 6e4; ++function heldLineState(room2, { seq, botId, text: text2 } = {}, now = Date.now()) { ++ if (room2?.origin !== "face-to-face" || !Array.isArray(room2.heldLines)) return void 0; ++ const said = String(text2 ?? "").trim(); ++ const entry = room2.heldLines.filter((item) => seq !== void 0 && item?.seq !== void 0 ? item.seq === seq : item?.botId === botId && String(item?.text ?? "").trim() === said).at(-1); ++ if (entry === void 0) return void 0; ++ if (entry.status === "rejected") return "rejected"; ++ if (entry.status === "sending" && now - (Number(entry.at) || 0) > HELD_SENDING_MS) return void 0; ++ if (entry.status !== "pending" && entry.status !== "sending") return void 0; ++ return room2.face?.status === "left" || room2.face?.status === "closed" ? "rejected" : "held"; ++} ++function GroupMessage({ bot, text: text2, room: room2, roster, actions, msgKey, useUi, seq }) { ++ if (String(text2 ?? "").trim() === "") return null; ++ const held = heldLineState(room2, { seq, botId: bot.id, text: text2 }); ++ if (held === "rejected") { ++ return (0, import_react10.createElement)( ++ "div", ++ { className: "bt-event bt-not-sent", "data-flybot": "held-not-sent", role: "status" }, ++ (0, import_react10.createElement)(BotMark, { bot, size: 16 }), ++ (0, import_react10.createElement)("span", null, t("{name} · Not sent", { name: bot.name })) ++ ); + } -+ actions.openSession(card.chat); ++ if (held === "held" || isHeldLine(room2, text2, bot)) return null; ++ const lead = (0, import_react10.createElement)( ++ "button", ++ { type: "button", className: "bt-lead", "aria-label": t("Open {name}'s chat", { name: bot.name }), onClick: () => actions.openSession(bot.id) }, ++ (0, import_react10.createElement)(BotMark, { bot, size: 22 }) ++ ); ++ const name = (0, import_react10.createElement)( ++ "span", ++ { className: "bt-group-name", style: { color: inkText(colorOf(bot)) } }, ++ faceBotLabel(room2, roster, bot) ?? bot.name, ++ room2?.admin === bot.id ? (0, import_react10.createElement)("span", { className: "bt-group-role" }, t("Admin")) : null ++ ); ++ return (0, import_react10.createElement)( ++ "div", ++ { className: "bt-group-msg" }, ++ name, ++ (0, import_react10.createElement)(Bubbles, { texts: [text2], roster, selfId: bot.id, actions, sessionId: room2?.id, msgKey, useUi, lead }) ++ ); +} -+function InboxDialog({ roster, useWork, useSessions, actions, leaving = false }) { -+ const work = useWork((value) => value); -+ const sessions = useSessions((value) => value); -+ const tasks = useTasks(actions); -+ const workflows = useWorkflows(actions); -+ const now = useNow2(3e4); -+ const [answered, setAnswered] = (0, import_react13.useState)({}); -+ const [busy, setBusy] = (0, import_react13.useState)(""); -+ const [errors, setErrors] = (0, import_react13.useState)({}); -+ const [feedback, setFeedback] = (0, import_react13.useState)(""); -+ const [allDone, setAllDone] = (0, import_react13.useState)(false); -+ const close = (0, import_react13.useCallback)(() => actions.closeInbox(), []); -+ useEscape(close); -+ (0, import_react13.useEffect)(() => { -+ if (!feedback) return void 0; -+ const timer = setTimeout(() => setFeedback(""), FEEDBACK_MS); -+ return () => clearTimeout(timer); -+ }, [feedback]); -+ const model = inboxModel({ roster, work, tasks, sessions, workflows, now }); -+ const groups = visibleNeeds(model, answered); -+ const answer = (card, choice) => { -+ setBusy(card.id); -+ setErrors((value) => ({ ...value, [card.id]: "" })); -+ const pending = inboxAnswer(actions, card, choice); -+ if (pending === void 0) { -+ setBusy(""); -+ return; -+ } -+ void Promise.resolve(pending).then(() => { -+ setAnswered((value) => ({ ...value, [card.id]: true })); -+ const name = card.bot?.name ?? ""; -+ setFeedback(card.kind === "approval" ? choice === "allow" ? t("Allowed once for {name}", { name }) : choice === "always-bot" ? t("Always allowed for {name}", { name }) : choice === "always-room" ? t("Always allowed in this group") : t("Rejected for {name}", { name }) : t("Answered")); -+ }, (failure) => setErrors((value) => ({ ...value, [card.id]: hostText(failure?.message ?? String(failure)) }))).finally(() => setBusy("")); - }; -- return (0, import_react5.createElement)( -+ const open = (card) => openCard(actions, card); -+ return (0, import_react13.createElement)( - "div", -- { className: "bt-set-row bt-set-paste bt-secret-add" }, -- (0, import_react5.createElement)("input", { className: "bt-input", "aria-label": t("Key name"), placeholder: t("NAME, e.g. GITHUB_TOKEN"), value: name, spellCheck: false, autoComplete: "off", autoFocus: true, onChange: (event) => setName(event.target.value.toUpperCase()) }), -- (0, import_react5.createElement)("input", { className: "bt-input", "aria-label": t("What it is for"), placeholder: t("What it is for"), value: purpose, autoComplete: "off", onChange: (event) => setPurpose(event.target.value) }), -- (0, import_react5.createElement)("label", { className: "bt-q-field bt-secret-field" }, (0, import_react5.createElement)(MaskedInput, { value, onChange: setValue, onEnter: save, label: t("Value") })), -- (0, import_react5.createElement)( -+ { className: "bt-mem-layer bt-inbox-layer", "data-leaving": leaving || void 0 }, -+ (0, import_react13.createElement)("div", { className: "bt-scrim bt-mem-scrim", onMouseDown: close }), -+ (0, import_react13.createElement)( - "div", -- { className: "bt-secret-row-actions" }, -- (0, import_react5.createElement)("span", { className: "bt-set-hint" }, t("New keys are for all Bots; narrow it down afterwards.")), -- (0, import_react5.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: onDone }, t("Cancel")), -- (0, import_react5.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", disabled: !ready, onClick: save }, t("Save key")) -+ { className: "bt-mem bt-inbox", role: "dialog", "aria-modal": true, "aria-labelledby": "bt-inbox-heading" }, -+ (0, import_react13.createElement)( -+ "div", -+ { className: "bt-mem-head" }, -+ (0, import_react13.createElement)("h2", { id: "bt-inbox-heading" }, t("Inbox")), -+ (0, import_react13.createElement)("span", { className: "bt-mem-status" }, t("Your Bots")), -+ (0, import_react13.createElement)("span", { className: "bt-mem-head-gap" }), -+ (0, import_react13.createElement)("button", { type: "button", className: "bt-icon-btn", "aria-label": t("Close inbox"), autoFocus: true, onClick: close }, (0, import_react13.createElement)(CloseIcon)) ++function remoteSpeaker(source, roster, sessionId) { ++ const handle = source.remote.handle; ++ const member = (roster.remoteBots ?? []).find((bot2) => bot2.handle === handle); ++ const chat = (roster.bots ?? []).find((bot2) => bot2.remote?.handle === handle); ++ const room2 = roster.roomsById?.[sessionId]; ++ const seat = faceSeatLook(room2, handle); ++ const faceSeat = room2?.origin === "face-to-face" ? faceSeats(room2, roster, { left: true }).find((item) => !item.mine && normRemoteHandle(item.record?.handle) === normRemoteHandle(handle)) : void 0; ++ const name = faceSeat ? faceSeatLabel(faceSeat) : remoteBotTitle({ ++ displayName: chat?.name ?? member?.name ?? source.senderName ?? handle, ++ ownerName: member?.homeName || member?.owner || chat?.remote?.homeName || chat?.remote?.owner, ++ handle, ++ isChief: member?.isChief ?? chat?.remote?.isChief ++ }); ++ const bot = seat ? lookBotForRemote({ handle, displayName: name, ...seat }, roster) : chat ?? lookBotForRemote({ ++ handle, ++ displayName: name, ++ color: member?.color, ++ avatar: member?.avatar ?? (member?.shape ? { shape: member.shape } : void 0) ++ }, roster); ++ const carrier = cleanCarrier(chat?.name ?? member?.name); ++ return { bot, member, name, handle, carrier, chatId: chat ? currentPart(chat) : void 0 }; ++} ++function faceSeatLook(room2, handle) { ++ if (room2?.origin !== "face-to-face" || !handle) return void 0; ++ const seats = (room2.participants ?? []).filter((p) => p?.remote && (p.handle === handle || p.id === handle)); ++ const seat = seats.find((p) => p.leftSeq === void 0) ?? seats.at(-1); ++ const look = {}; ++ if (typeof seat?.color === "string" && seat.color) look.color = seat.color; ++ if (seat?.avatar && typeof seat.avatar === "object") look.avatar = seat.avatar; ++ return Object.keys(look).length ? look : void 0; ++} ++var cleanCarrier = (value) => typeof value === "string" && value.trim() && !/[@()]/.test(value) ? value.trim() : ""; ++function RemoteMessage({ source, text: text2, roster, actions, sessionId, msgKey, useUi }) { ++ const room2 = roster.roomsById?.[sessionId]; ++ const face = room2?.origin === "face-to-face"; ++ const { bot, member, name, handle, carrier, chatId } = remoteSpeaker(source, roster, sessionId); ++ const files = Array.isArray(source.attachments) ? source.attachments : []; ++ const words = stripTrace(text2); ++ if (String(words ?? "").trim() === "" && files.length === 0) return null; ++ const person = typeof source.remote.typedBy === "string" ? source.remote.typedBy : ""; ++ if (person) { ++ return (0, import_react10.createElement)( ++ "div", ++ { className: "bt-group-msg bt-person-msg bt-remote-msg", "data-person": person, "data-remote": handle }, ++ // One short header: the person, and the Bot that carried the line only when it is known (#275). ++ (0, import_react10.createElement)( ++ "span", ++ { className: "bt-group-name", title: handle }, ++ person, ++ carrier ? (0, import_react10.createElement)("span", { className: "bt-remote-handle" }, t("via {name}", { name: carrier })) : null + ), -+ (0, import_react13.createElement)( -+ "div", -+ { className: "bt-mem-body bt-inbox-body" }, -+ (0, import_react13.createElement)("div", { className: "bt-inbox-feedback", role: "status", "aria-live": "polite" }, feedback), -+ GROUPS.map((group) => { -+ const settled = group.id === "done" ? model.doneAll ?? groups.done : null; -+ const canOpen = group.id === "done" && settled.length > DONE_MAX; -+ const rows = canOpen && allDone ? settled : groups[group.id]; -+ const hidden = group.id === "done" ? Math.max(0, (model.doneTotal ?? rows.length) - rows.length) : 0; -+ return (0, import_react13.createElement)( -+ "section", -+ { key: group.id, className: "bt-inbox-group", "aria-labelledby": `bt-inbox-${group.id}` }, -+ (0, import_react13.createElement)( -+ "h3", -+ { id: `bt-inbox-${group.id}`, className: "bt-inbox-head" }, -+ (0, import_react13.createElement)("span", null, t(group.title)), -+ rows.length > 0 ? (0, import_react13.createElement)("span", { className: "bt-inbox-head-count" }, String(rows.length)) : null -+ ), -+ rows.length === 0 ? (0, import_react13.createElement)("p", { className: "bt-inbox-empty" }, t(group.empty)) : (0, import_react13.createElement)("div", { className: "bt-inbox-list" }, rows.map((card) => (0, import_react13.createElement)(InboxCard, { -+ key: card.id, -+ card, -+ roster, -+ now, -+ busy: busy === card.id, -+ error: errors[card.id], -+ onAnswer: answer, -+ onOpen: open -+ }))), -+ hidden > 0 ? (0, import_react13.createElement)("div", { key: "more", className: "bt-inbox-more" }, t("{count} more not shown", { count: hidden })) : null, -+ canOpen ? (0, import_react13.createElement)("button", { -+ key: "all", -+ type: "button", -+ className: "bt-inbox-show-all", -+ "aria-expanded": allDone, -+ onClick: () => setAllDone((value) => !value) -+ }, t(allDone ? "Show fewer" : "Show all")) : null -+ ); -+ }) -+ ) - ) - ); - } --function SecretsPage({ roster, actions, run, Section }) { -- const [open, setOpen] = (0, import_react5.useState)(null); -- const [adding, setAdding] = (0, import_react5.useState)(false); -- const secrets = roster.secrets ?? []; -- return [ -- (0, import_react5.createElement)( -- "p", -- { key: "about", className: "bt-secret-about" }, -- t("Keys your Bots asked for. Bots never see the values: they use them as "), -- (0, import_react5.createElement)("code", null, "$DSH_SECRET_NAME"), -- t(" in their shell, and every value is replaced by "), -- (0, import_react5.createElement)("code", null, "[secret:NAME]"), -- t(" before anything goes to a model.") -- ), -- (0, import_react5.createElement)( -- Section, -- { key: "list", title: t("Secrets · {count}", { count: secrets.length }) }, -- secrets.length === 0 && !adding ? (0, import_react5.createElement)("div", { className: "bt-set-row" }, (0, import_react5.createElement)("span", { className: "bt-set-hint" }, t("No keys yet. When a Bot needs one, it shows a card in its chat."))) : null, -- secrets.map((secret) => (0, import_react5.createElement)(SecretRow, { key: secret.name, secret, roster, actions, run, open: open === secret.name, onToggle: () => setOpen(open === secret.name ? null : secret.name) })), -- adding ? (0, import_react5.createElement)(AddSecret, { actions, run, onDone: () => setAdding(false) }) : null -- ), -- adding ? null : (0, import_react5.createElement)("div", { key: "add", className: "bt-actions" }, (0, import_react5.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: () => setAdding(true) }, t("Add a key"))) -- ]; --} --var SECRET_CSS = ` --.bt-secret-head{align-items:center;gap:10px} --.bt-secret-who{position:relative;flex:none;display:inline-flex} --.bt-secret-lock{position:absolute;right:-5px;bottom:-4px;width:16px;height:16px;border-radius:50%;background:var(--bt-main);color:var(--bt-ink-2);display:inline-flex;align-items:center;justify-content:center;box-shadow:0 0 0 1.5px var(--bt-bubble)} --.bt-secret-lock svg{width:11px;height:11px} --.bt-secret-name{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:12px;line-height:16px;padding:1px 6px;border-radius:5px;background:var(--bt-hover);color:var(--bt-ink)} --.bt-secret-body{display:flex;flex-direction:column;gap:8px} --.bt-secret-field{align-items:center;min-height:36px} --.bt-secret-input{flex:1;width:100%;min-width:0;margin:0;padding:0;border:0;outline:none;background:transparent;color:var(--bt-ink);font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:14px;line-height:20px;letter-spacing:.04em} --.bt-secret-input[data-masked]{-webkit-text-security:disc} --.bt-secret-input::placeholder{color:var(--bt-ink-3);font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",system-ui,sans-serif;letter-spacing:0} --.bt-secret-scope{position:relative;display:inline-grid;grid-template-columns:1fr 1fr;align-self:flex-start;padding:2px;border-radius:999px;background:var(--bt-hover)} --.bt-secret-glide{position:absolute;top:2px;bottom:2px;left:2px;width:calc(50% - 2px);border-radius:999px;background:var(--bt-main);box-shadow:0 1px 3px rgba(20,20,20,.08),inset 0 0 0 .5px color-mix(in srgb,var(--bt-ink) 6%,transparent);transition:${spring(SPRING_SHAPE, "transform")}} --.bt-secret-glide[data-at="1"]{transform:translateX(100%)} --.bt-secret-seg{position:relative;z-index:1;height:28px;padding:0 14px;border:0;border-radius:999px;background:none;color:var(--bt-ink-2);font:inherit;font-size:13px;white-space:nowrap;cursor:pointer;transition:color .16s ease} --.bt-secret-seg[aria-checked=true]{color:var(--bt-ink);font-weight:500} --.bt-secret-seg:focus-visible{outline:2px solid var(--bt-accent);outline-offset:-2px} --/* A key card asks the user to act, so the process group carrying it stays open in every -- work-details mode (same override as .bt-group-replies in styles.js). */ --[data-step-process]:has(.bt-secret)>div:first-child{display:none!important} --[data-step-process]:has(.bt-secret)>[data-step-process-body]{display:block!important;content-visibility:visible!important;max-height:none!important;overflow:visible!important;mask-image:none!important;scrollbar-gutter:auto!important} --.bt-secret-ok{display:flex;align-items:center;gap:4px;margin-top:6px;animation:bt-secret-in .22s cubic-bezier(.22,1,.36,1)} --.bt-secret-ok svg{width:13px;height:13px;color:#16a34a} --@keyframes bt-secret-in{from{opacity:0;transform:translateY(-2px)}} --.bt-secret-saved{display:flex;align-items:center;gap:6px;font-size:13px;line-height:18px;color:var(--bt-ink-2)} --.bt-secret-note{font-size:12px;line-height:17px;color:var(--bt-ink-3)} --.bt-secret-note code,.bt-secret-about code{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:11.5px} --.bt-secret-warn{display:flex;align-items:center;gap:6px;padding:6px 10px;border-radius:8px;font-size:12px;line-height:16px;color:#b25e09;background:color-mix(in srgb,#f59e0b 12%,transparent)} --.bt-secret-error{font-size:12px;line-height:16px;color:#e02135} --.bt-secret .bt-q-btn:disabled{opacity:.5;cursor:default} --.bt-secret-done .bt-q-head{align-items:center} --.bt-secret-done .bt-q-title{font-size:13px;line-height:18px;color:var(--bt-ink-2);font-weight:500} --.bt-secret-scope-tag{font-weight:400;color:var(--bt-ink-3)} --.bt-secret-check{color:var(--bt-green,#22a06b);animation:bt-secret-pop ${SPRING_POP.duration}ms ${SPRING_POP.easing} backwards} --.bt-secret-done{animation:bt-secret-settle .32s cubic-bezier(.22,1,.36,1) backwards} --@keyframes bt-secret-pop{from{opacity:0;transform:scale(.4)}} --@keyframes bt-secret-settle{from{opacity:.4;transform:scale(.985)}} --.bt-secret-event{display:inline-flex;align-items:center;gap:5px} --.bt-secret-dock{flex-direction:column;align-items:center} --.bt-secret-dock .bt-q{max-width:min(550px,calc(100% - 32px))} --.bt-secret-about{margin:0 0 12px;padding:0 4px;font-size:12px;line-height:17px;color:var(--bt-ink-2)} --.bt-secret-title{display:flex;align-items:center;gap:6px;min-width:0;color:var(--bt-ink)} --.bt-secret-title svg{flex:none;color:var(--bt-ink-2)} --.bt-secret-purpose{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--bt-ink-2)} --.bt-secret-faces{display:inline-flex} --.bt-secret-faces>*+*{margin-left:-6px} --.bt-secret-edit{display:flex;flex-direction:column;gap:14px;padding:4px 12px 14px;animation:bt-q-in ${SPRING_SHAPE.duration}ms ${SPRING_SHAPE.easing} backwards,bt-veil-in .2s ease-out backwards} --.bt-secret-edit-block{display:flex;flex-direction:column;gap:8px} --.bt-secret-edit-label{font-size:12px;line-height:16px;font-weight:600;color:var(--bt-ink-2)} --.bt-secret-radios{display:flex;gap:16px;font-size:13px} --.bt-secret-radio{display:inline-flex;align-items:center;gap:6px;cursor:pointer} --.bt-secret-bots{display:flex;flex-wrap:wrap;gap:6px} --.bt-secret-bot{display:inline-flex;align-items:center;gap:6px;height:30px;padding:0 10px 0 5px;border-radius:999px;border:.5px solid var(--bt-line);background:var(--bt-card);color:var(--bt-ink-2);font:inherit;font-size:12px;cursor:pointer;transition:background-color .12s ease,color .12s ease,border-color .12s ease} --.bt-secret-bot[aria-pressed=true]{background:var(--bt-active);color:var(--bt-ink);border-color:var(--bt-line-2)} --.bt-secret-bot svg{width:13px;height:13px} --.bt-secret-inline{display:flex;gap:8px;align-items:center} --.bt-secret-row-actions{display:flex;align-items:center;justify-content:flex-end;gap:8px;flex-wrap:wrap} --.bt-secret-row-actions .bt-set-hint{flex:1} --.bt-secret-danger{color:#e02135;display:inline-flex;align-items:center;gap:4px} --.bt-secret-danger svg{width:13px;height:13px} --.bt-secret-add .bt-input{height:34px} --@media (prefers-reduced-motion:reduce){.bt-secret-glide{transition:none}.bt-secret-check,.bt-secret-done,.bt-secret-ok,.bt-secret-edit{animation:none}} -+var INBOX_CSS = ` -+.bt-inbox-row{min-height:40px;padding:6px 8px;gap:10px} -+.bt-inbox-mark{flex:none;width:36px;height:28px;display:inline-flex;align-items:center;justify-content:center;color:var(--bt-ink-2)} -+.bt-inbox-mark svg{width:18px;height:18px} -+.bt-inbox-label{flex:1 1 auto;min-width:0;font-size:14px;line-height:20px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis} -+.bt-inbox-count{flex:none;min-width:20px;height:20px;padding:0 6px;border-radius:999px;background:var(--bt-accent);color:var(--bt-accent-ink,#fff);font-size:11.5px;line-height:20px;font-weight:600;text-align:center;font-variant-numeric:tabular-nums;box-sizing:border-box} -+.bt-rail .bt-inbox-row{position:relative} -+.bt-rail .bt-inbox-count{position:absolute;top:2px;right:calc(50% - 22px);min-width:16px;height:16px;padding:0 4px;font-size:10px;line-height:16px;box-shadow:0 0 0 1.5px var(--bt-sidebar)} -+.bt-inbox-body{gap:18px} -+.bt-inbox-feedback{min-height:0;font-size:13px;line-height:18px;color:var(--bt-ink-2)} -+.bt-inbox-feedback:empty{display:none} -+.bt-inbox-group{display:flex;flex-direction:column;gap:8px} -+.bt-inbox-head{display:flex;align-items:center;gap:6px;margin:0;font-size:13px;line-height:18px;font-weight:600;color:var(--bt-ink-2)} -+.bt-inbox-head-count{font-weight:500;color:var(--bt-ink-3);font-variant-numeric:tabular-nums} -+.bt-inbox-empty{margin:0;font-size:13px;line-height:18px;color:var(--bt-ink-3)} -+.bt-inbox-more{margin:0;font-size:12.5px;line-height:18px;color:var(--bt-ink-3);font-variant-numeric:tabular-nums} -+.bt-inbox-show-all{align-self:flex-start;padding:0;border:0;background:none;font:inherit;font-size:12.5px;line-height:18px;color:var(--bt-accent);cursor:pointer} -+.bt-inbox-show-all:hover{text-decoration:underline} -+.bt-inbox-show-all:focus-visible{outline:2px solid color-mix(in srgb,var(--bt-accent) 45%,transparent);outline-offset:2px;border-radius:4px} -+.bt-inbox-list{display:flex;flex-direction:column;gap:6px} -+.bt-inbox-card{display:flex;align-items:flex-start;gap:10px;padding:10px 12px;border:.5px solid var(--bt-line-2);border-radius:12px;background:var(--bt-card,var(--bt-main))} -+.bt-inbox-card:focus-within{box-shadow:0 0 0 2px color-mix(in srgb,var(--bt-accent) 35%,transparent)} -+.bt-inbox-ava{flex:none;line-height:0;padding-top:2px} -+.bt-inbox-copy{flex:1 1 auto;min-width:0;display:flex;flex-direction:column;gap:2px} -+.bt-inbox-top{display:flex;align-items:center;gap:8px;min-width:0} -+.bt-inbox-status{flex:none;display:inline-flex;align-items:center;gap:4px;height:20px;padding:0 7px 0 5px;border-radius:999px;font-size:11.5px;line-height:20px;font-weight:600;white-space:nowrap} -+.bt-inbox-status svg{flex:none} -+.bt-inbox-status[data-tone=ask]{background:color-mix(in srgb,var(--bt-star) 18%,transparent);color:color-mix(in srgb,var(--bt-star) 70%,var(--bt-ink))} -+.bt-inbox-status[data-tone=danger]{background:rgba(224,33,53,.12);color:#c21d2e} -+.bt-inbox-status[data-tone=run]{background:color-mix(in srgb,var(--bt-accent) 14%,transparent);color:color-mix(in srgb,var(--bt-accent) 80%,var(--bt-ink))} -+.bt-inbox-status[data-tone=done]{background:color-mix(in srgb,var(--bt-green) 16%,transparent);color:color-mix(in srgb,var(--bt-green) 70%,var(--bt-ink))} -+.bt-inbox-status[data-tone=muted]{background:var(--bt-hover);color:var(--bt-ink-2)} -+.bt-inbox-meta{min-width:0;font-size:12px;line-height:16px;color:var(--bt-ink-3);white-space:nowrap;overflow:hidden;text-overflow:ellipsis} -+.bt-inbox-title{font-size:13.5px;line-height:19px;color:var(--bt-ink);white-space:nowrap;overflow:hidden;text-overflow:ellipsis} -+.bt-inbox-reason,.bt-inbox-hint,.bt-inbox-activity{font-size:12px;line-height:16px;color:var(--bt-ink-2);white-space:nowrap;overflow:hidden;text-overflow:ellipsis} -+/* Needs you is answered on the card, so its ask is the whole text (#1177). The phone sheet clamps these two classes to two lines at a lower specificity; this unsets that clamp. In progress and Done stay one line. */ -+.bt-inbox-card:is([data-status=approval],[data-status=held],[data-status=input],[data-status=result],[data-status=failed]) :is(.bt-inbox-title,.bt-inbox-reason){white-space:pre-wrap;overflow-wrap:anywhere;overflow:visible;text-overflow:clip;display:block;min-width:0;-webkit-line-clamp:unset;-webkit-box-orient:unset} -+.bt-inbox-calls{font-size:12px;line-height:16px;color:var(--bt-ink-3);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;font-variant-numeric:tabular-nums} -+.bt-inbox-activity{color:var(--bt-ink-3)} -+.bt-inbox-error{font-size:12px;line-height:16px;color:#c21d2e;overflow-wrap:anywhere} -+.bt-inbox-writeback{font-size:12px;line-height:16px;color:#c21d2e;overflow-wrap:anywhere} -+.bt-inbox-evidence{display:flex;flex-wrap:wrap;gap:6px;font-size:12px;line-height:16px} -+.bt-inbox-evidence-link{color:var(--bt-blue,#3a7afe);text-decoration:none;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:100%} -+.bt-inbox-evidence-link:hover{text-decoration:underline} -+.bt-inbox-result{display:flex;flex-wrap:wrap;align-items:center;gap:6px;margin-top:6px} -+.bt-inbox-result .bt-send,.bt-inbox-result .bt-soft{height:28px;font-size:12px} -+.bt-inbox-answer{display:flex;flex-wrap:wrap;align-items:center;gap:6px;margin-top:6px} -+.bt-inbox-answer-text{flex:1 1 140px;min-width:0;height:28px;padding:0 8px;border:.5px solid var(--bt-line-2);border-radius:8px;background:transparent;color:var(--bt-ink);font:inherit;font-size:12px} -+.bt-inbox-answer .bt-send,.bt-inbox-answer .bt-soft{height:28px;font-size:12px} -+.bt-inbox-answer button[aria-pressed=true]{background:var(--bt-active);color:var(--bt-ink)} -+.bt-inbox-actions{flex:none;display:flex;align-items:center;gap:6px;align-self:center} -+.bt-inbox-actions .bt-send{height:28px;font-size:12px} - `; - --// src/client/turn-reasoning.js --function turnReasoning(steps, current) { -- const members = steps.length ? steps : [current]; -- if (members.find((step) => stepReasoning(step) !== "")?.step !== current.step) return ""; -- return members.filter((step) => !thinkingLive(step)).map(stepReasoning).filter(Boolean).join("\n\n"); --} --var texts = (step) => step.blocks.filter((block) => block.kind === "text" && block.text.trim() !== ""); --var thinkingLive = (step) => step.status === "running" && texts(step).length === 0 && step.blocks.some((block) => block.kind === "reasoning"); --var stepReasoning = (step) => step.blocks.filter((block) => block.kind === "reasoning").map((block) => block.text).join("\n").trim(); -- --// src/client/cells.js --var import_react6 = require("react"); --var import_react_dom = require("react-dom"); --var import_dsh_client_ui_primitives = require("@deepseek-ai/dsh-client-ui-primitives"); --var mdLabels = () => ({ code: { copyLabel: t("Copy"), copiedLabel: t("Copied") }, footnotes: t("Footnotes") }); --function Bubbles({ texts: texts2, roster, selfId, actions, streaming, sessionId, msgKey, useUi, lead, mentions }) { -- const onClick = (event) => { -- const target = mentionTarget(event); -- if (target === null) return; -- event.preventDefault(); -- event.stopPropagation(); -- actions.openSession(target); -- }; -- const reactions = useUi ? useUi((state) => msgKey ? state.reactions?.[msgKey] ?? null : null) : null; -- return (0, import_react6.createElement)( -- "div", -- { className: "bt-astack", "data-lead": lead ? "" : void 0, onClickCapture: onClick }, -- texts2.map((text, index) => { -- const key = msgKey ? `${msgKey}:${index}` : void 0; -- const picked = key ? reactions?.[index] ?? [] : []; -- const last = index === texts2.length - 1; -- const live = streaming && last; -- return (0, import_react6.createElement)( -- "div", -- { key: index, className: "bt-msg" }, -- (0, import_react6.createElement)( -- "div", -- { className: "bt-msg-line" }, -- lead && last ? lead : null, -- (0, import_react6.createElement)( -- "div", -- { className: "bt-bubble" }, -- (0, import_react6.createElement)(import_dsh_client_ui_primitives.MarkdownText, { text: linkMentions(text, roster.bots, selfId), streaming: live, labels: mdLabels(), variant: "compact", fileMentions: mentions }) -- ), -- live ? null : (0, import_react6.createElement)(MessageTools, { text, name: roster.byId[selfId]?.name ?? t("Bot"), sessionId, react: key ? (emoji) => actions.toggleReaction(msgKey, index, emoji) : void 0, actions }) -- ), -- picked.length ? (0, import_react6.createElement)("div", { className: "bt-reacts" }, picked.map((emoji) => (0, import_react6.createElement)("button", { -- key: emoji, -- type: "button", -- className: "bt-react", -- "aria-label": t("Remove reaction {emoji}", { emoji }), -- onClick: () => actions.toggleReaction(msgKey, index, emoji) -- }, emoji))) : null -- ); -- }) -+// src/shared/board-card.js -+function taskCardKey(id) { -+ const text2 = String(id ?? "").trim(); -+ return text2 === "" ? null : `task:${text2}`; ++ String(words ?? "").trim() === "" ? null : (0, import_react10.createElement)(Bubbles, { texts: [words], roster, selfId: bot.id, actions, sessionId, msgKey, useUi, lead: (0, import_react10.createElement)("span", { className: "bt-lead", title: person }, (0, import_react10.createElement)(PersonMark, { name: person })) }), ++ files.length ? (0, import_react10.createElement)(RemoteFiles, { files, plugin: member?.plugin ?? "social", botId: roster.byId[sessionId]?.id ?? sessionId }) : null ++ ); ++ } ++ const mark = (0, import_react10.createElement)(BotMark, { bot, size: 22 }); ++ const lead = chatId ? (0, import_react10.createElement)("button", { type: "button", className: "bt-lead", title: handle, "aria-label": t("Open {name}'s chat", { name }), onClick: () => actions.openSession(chatId) }, mark) : (0, import_react10.createElement)("span", { className: "bt-lead", title: handle }, mark); ++ return (0, import_react10.createElement)( ++ "div", ++ { className: "bt-group-msg bt-remote-msg", "data-remote": handle }, ++ // A face room's header is the one name alone; the handle is on hover only (#457). ++ face ? (0, import_react10.createElement)("span", { className: "bt-group-name", style: { color: inkText(colorOf(bot)) }, title: handle }, name) : (0, import_react10.createElement)( ++ "span", ++ { className: "bt-group-name", style: { color: inkText(colorOf(bot)) } }, ++ name, ++ (0, import_react10.createElement)("span", { className: "bt-remote-handle", title: source.remote.home ?? handle, "aria-label": t("Friend's Bot") }, (0, import_react10.createElement)(GlobeMark), handle) ++ ), ++ String(words ?? "").trim() === "" ? null : (0, import_react10.createElement)(Bubbles, { texts: [words], roster, selfId: bot.id, actions, sessionId, msgKey, useUi, lead }), ++ files.length ? (0, import_react10.createElement)(RemoteFiles, { files, plugin: member?.plugin ?? "social", botId: roster.byId[sessionId]?.id ?? sessionId }) : null ++ ); ++} ++function FirstContact({ source, roster, sessionId }) { ++ const person = typeof source.remote.typedBy === "string" ? source.remote.typedBy : ""; ++ const who = person || remoteSpeaker(source, roster).name; ++ const bot = roster.byId[sessionId]?.name ?? t("your Bot"); ++ return (0, import_react10.createElement)("div", { className: "bt-event", "data-first-contact": source.remote.handle }, (0, import_react10.createElement)("span", null, t("{name} is talking to {bot} through Bot Social", { name: who, bot }))); +} -+function taskCard(task) { -+ const key = taskCardKey(task?.id); -+ if (key === null) return null; -+ return { -+ source: "task", -+ key, -+ id: String(task.id), -+ title: String(task.title ?? "") ++function GlobeMark() { ++ return (0, import_react10.createElement)( ++ "svg", ++ { width: 10, height: 10, viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: 2, strokeLinecap: "round", "aria-hidden": true }, ++ (0, import_react10.createElement)("circle", { cx: 12, cy: 12, r: 9 }), ++ (0, import_react10.createElement)("path", { d: "M3 12h18M12 3a14 14 0 0 1 0 18M12 3a14 14 0 0 0 0 18" }) ++ ); ++} ++function RemoteFiles({ files, plugin, botId }) { ++ const [busy, setBusy] = (0, import_react10.useState)(""); ++ const [error, setError] = (0, import_react10.useState)(""); ++ const download2 = async (file) => { ++ setBusy(file.id); ++ setError(""); ++ try { ++ const got = await call(`flybot/${plugin}/attachment`, { id: file.id, botId }); ++ const value = got?.value ?? got; ++ const bytes = Uint8Array.from(atob(value.content_base64), (c) => c.charCodeAt(0)); ++ const url = URL.createObjectURL(new Blob([bytes], { type: "application/octet-stream" })); ++ const link = Object.assign(document.createElement("a"), { href: url, download: value.name ?? file.name }); ++ link.click(); ++ setTimeout(() => URL.revokeObjectURL(url), 1e4); ++ } catch (reason) { ++ setError(String(reason?.message ?? reason)); ++ } finally { ++ setBusy(""); ++ } + }; ++ return (0, import_react10.createElement)( ++ "div", ++ { className: "bt-file-chips" }, ++ files.map((file) => (0, import_react10.createElement)( ++ "button", ++ { key: file.id, type: "button", className: "bt-file-chip", disabled: busy === file.id, title: `Download ${file.name} (untrusted file from a remote Bot)`, onClick: () => download2(file) }, ++ `📎 ${file.name} · ${fileSize(file.size)}` ++ )), ++ error ? (0, import_react10.createElement)("span", { className: "bt-file-error", role: "alert" }, error) : null ++ ); +} -+ -+// src/client/board-card.js -+var import_react14 = require("react"); -+var TASK_SKIN = { -+ card: "bt-board-card", -+ top: "bt-board-card-top", -+ id: "bt-board-id", -+ badge: "bt-board-badge", -+ title: "bt-board-title", -+ labels: "bt-board-labels", -+ label: "bt-board-label", -+ owner: "bt-board-who", -+ note: "bt-board-note" ++function GroupEvent({ prefix, room: room2, name, roster, actions }) { ++ return (0, import_react10.createElement)( ++ "div", ++ { className: "bt-event" }, ++ (0, import_react10.createElement)("span", null, prefix), ++ room2 ? (0, import_react10.createElement)("button", { type: "button", onClick: () => actions.openSession(room2.id) }, (0, import_react10.createElement)(RoomAvatar, { room: room2, roster, size: 16 }), room2.name) : name ? (0, import_react10.createElement)("span", null, name) : null ++ ); ++} ++function MemoryEvent({ sessionId, scope, result, roster, actions, findBot }) { ++ if (!/^(Saved to|Updated in|Removed from) /.test(result)) return null; ++ const self = roster.byId[sessionId]; ++ const team = /^(team|团队|共享)$/i.test(String(scope ?? "").trim()); ++ const other = team || !scope ? void 0 : findBot(scope); ++ const target = other && other.id !== self?.id ? other : self; ++ if (!target) return null; ++ const label = team ? t("Team memory updated") : target === self ? t("Memory updated") : t("Updated {name}'s memory", { name: target.name }); ++ return (0, import_react10.createElement)( ++ "div", ++ { className: "bt-event" }, ++ (0, import_react10.createElement)("button", { type: "button", title: result, onClick: () => actions.openMemory(target.id) }, (0, import_react10.createElement)(MemoryIcon), label) ++ ); ++} ++function PartLine({ time }) { ++ return (0, import_react10.createElement)("div", { className: "bt-part-line", role: "separator" }, Number.isFinite(time) ? (0, import_react10.createElement)("span", null, dayLabel(time)) : null); ++} ++var scrollParent = (node) => { ++ for (let at = node.parentElement; at; at = at.parentElement) { ++ if (/(auto|scroll)/.test(getComputedStyle(at).overflowY)) return at; ++ } ++ return null; +}; -+function BoardCard({ -+ card, -+ skin = TASK_SKIN, -+ attrs = {}, -+ idText = "", -+ topExtra = null, -+ badge = "", -+ meta = null, -+ note = "", -+ footer = null, -+ actions = null, -+ emptyTitle = "", -+ draggable = false, -+ dragValue = "", -+ onDragStart = null, -+ onDragEnd = null -+}) { -+ const labels = Array.isArray(card?.labels) ? card.labels : []; -+ return (0, import_react14.createElement)( -+ "article", -+ { -+ ...attrs, -+ className: skin.card, -+ "data-source": card?.source, -+ "data-key": card?.key ?? void 0, -+ draggable: draggable || void 0, -+ onDragStart: draggable ? (event) => { -+ event.dataTransfer?.setData("text/plain", dragValue); -+ if (event.dataTransfer) event.dataTransfer.effectAllowed = "move"; -+ onDragStart?.(); -+ } : void 0, -+ onDragEnd: draggable ? () => onDragEnd?.() : void 0 -+ }, -+ (0, import_react14.createElement)( -+ "span", -+ { className: skin.top }, -+ (0, import_react14.createElement)("span", { className: skin.id }, idText), -+ topExtra, -+ badge ? (0, import_react14.createElement)("span", { className: skin.badge, "data-badge": card?.badge ?? void 0 }, badge) : null ++function EarlierParts({ sessionId, roster, actions, useUi }) { ++ const [view, setView] = (0, import_react10.useState)({ items: [], cursor: void 0, loading: true, failed: false, startedAt: null }); ++ const top = (0, import_react10.useRef)(null); ++ const busy = (0, import_react10.useRef)(false); ++ const alive = (0, import_react10.useRef)(true); ++ const restore = (0, import_react10.useRef)(null); ++ const load = (cursor) => { ++ if (busy.current) return; ++ busy.current = true; ++ setView((current) => ({ ...current, loading: true, failed: false })); ++ actions.earlier(sessionId, cursor).then((page) => { ++ busy.current = false; ++ if (!alive.current) return; ++ const scroller = top.current && scrollParent(top.current); ++ if (scroller) restore.current = { scroller, fromEnd: scroller.scrollHeight - scroller.scrollTop }; ++ setView((current) => ({ items: [...page.items, ...current.items], cursor: page.cursor, loading: false, failed: false, startedAt: page.startedAt ?? current.startedAt })); ++ }, () => { ++ busy.current = false; ++ if (alive.current) setView((current) => ({ ...current, loading: false, failed: true })); ++ }); ++ }; ++ (0, import_react10.useEffect)(() => { ++ alive.current = true; ++ load(void 0); ++ return () => { ++ alive.current = false; ++ }; ++ }, [sessionId]); ++ (0, import_react10.useLayoutEffect)(() => { ++ const pending = restore.current; ++ restore.current = null; ++ if (pending) pending.scroller.scrollTop = pending.scroller.scrollHeight - pending.fromEnd; ++ }, [view.items]); ++ (0, import_react10.useEffect)(() => { ++ const node = top.current; ++ if (!node || view.loading || view.failed || !view.cursor || typeof IntersectionObserver !== "function") return void 0; ++ const observer = new IntersectionObserver((entries2) => { ++ if (entries2.some((entry) => entry.isIntersecting)) load(view.cursor); ++ }, { root: scrollParent(node), rootMargin: "600px 0px 0px 0px" }); ++ observer.observe(node); ++ return () => observer.disconnect(); ++ }, [view.cursor, view.loading, view.failed]); ++ const selfId = roster.byId[sessionId]?.id; ++ const keyOf = (item) => `${item.sessionId ?? sessionId}:${item.turn ?? ""}:${item.seq ?? item.time ?? ""}`; ++ const rows = []; ++ for (const item of view.items) { ++ if (item.kind === "bot") { ++ if (String(item.text ?? "").trim() === "") continue; ++ const last = rows.at(-1); ++ if (last?.kind === "bot") { ++ last.texts.push(item.text); ++ last.msgKeys.push(keyOf(item)); ++ } else rows.push({ kind: "bot", texts: [item.text], msgKeys: [keyOf(item)] }); ++ continue; ++ } ++ rows.push(item); ++ } ++ return (0, import_react10.createElement)( ++ "div", ++ { className: "bt-earlier", role: "feed", "aria-label": t("Earlier messages"), "aria-busy": view.loading }, ++ (0, import_react10.createElement)( ++ "div", ++ { ref: top, className: "bt-earlier-top" }, ++ view.loading ? (0, import_react10.createElement)(Shimmer, null, t("Loading earlier messages…")) : view.failed ? (0, import_react10.createElement)("button", { type: "button", className: "bt-soft", onClick: () => load(view.cursor) }, t("Couldn’t load earlier messages · Retry")) : view.cursor ? (0, import_react10.createElement)("button", { type: "button", className: "bt-soft", onClick: () => load(view.cursor) }, t("Load earlier")) : null + ), -+ (0, import_react14.createElement)("span", { className: skin.title }, card?.title || emptyTitle), -+ labels.length ? (0, import_react14.createElement)("span", { className: skin.labels }, labels.map((name) => (0, import_react14.createElement)("span", { key: name, className: skin.label, "data-label": name }, name))) : null, -+ card?.owner ? (0, import_react14.createElement)("span", { className: skin.owner, "data-kind": card.ownerKind ?? "human" }, card.owner) : null, -+ note ? (0, import_react14.createElement)("span", { className: skin.note }, note) : null, -+ meta, -+ footer, -+ actions - ); ++ rows.map((row, index) => { ++ const key = rows.length - index; ++ switch (row.kind) { ++ case "divider": ++ return (0, import_react10.createElement)(PartLine, { key, time: row.time }); ++ case "user": ++ return (0, import_react10.createElement)("div", { key, className: "bt-urow" }, (0, import_react10.createElement)( ++ "div", ++ { className: "bt-ububble" }, ++ row.text ? (0, import_react10.createElement)(import_dsh_client_ui_primitives.MarkdownText, { text: row.text, labels: mdLabels(), variant: "compact" }) : null, ++ row.images ? (0, import_react10.createElement)("span", { className: "bt-uimages" }, row.images === 1 ? t("1 image") : t("{count} images", { count: row.images })) : null ++ )); ++ case "answer": ++ return (0, import_react10.createElement)(AnsweredCell, { key, node: { data: row } }); ++ case "bot": ++ return (0, import_react10.createElement)(Bubbles, { key, texts: row.texts, msgKeys: row.msgKeys, roster, selfId, actions, sessionId, useUi }); ++ case "event": { ++ if (row.role === "report") return (0, import_react10.createElement)(GroupEvent, { key, prefix: t("Replies from"), room: roster.roomsById[row.roomId], name: t("a group chat"), roster, actions }); ++ const other = roster.byId[row.botId] ?? { id: row.botId, name: row.name ?? t("a Bot"), color: "gray" }; ++ const prefix = { stopped: t("Stopped by"), reply: t("Reply from") }[row.role] ?? t("Message from"); ++ return (0, import_react10.createElement)(EventLine, { key, prefix, bots: [other], onOpen: () => { ++ if (selfId && other.id) actions.openExchange(selfId, other.id); ++ }, whole: true }); ++ } ++ default: ++ return null; ++ } ++ }), ++ (0, import_react10.createElement)(PartLine, { time: view.startedAt }) ++ ); + } +-var FRAGMENT_SELECTOR = /\[class\*="(_[\w-]+)"\]/g; +-var CLASS_TOKEN = /\.((?:\\.|[\w-])+)/g; +-function sheetClassNames(sheet) { +- const names = []; +- const visit = (rules) => { +- for (const rule of rules) { +- if (rule.selectorText) for (const match of rule.selectorText.matchAll(CLASS_TOKEN)) names.push(match[1]); +- if (rule.cssRules) visit(rule.cssRules); ++function TriggerCell({ node, sessionId, useRoster, useSessions, useTurnClock, useUi, actions }) { ++ const roster = useRoster((value) => value); ++ const data = node.data; ++ const source = data.source !== null && typeof data.source === "object" ? data.source : {}; ++ const selfId = roster.byId[sessionId]?.id ?? sessionId; ++ const deferred = source.kind === "bot" && source.deferred === true; ++ const relayed = isRelayContext(data); ++ const turn = node.location?.turn?.turn ?? data.turn; ++ (0, import_react10.useLayoutEffect)(() => { ++ if (relayed) actions.noteTurnPeer(sessionId, turn, source.senderSessionId); ++ }, [relayed, sessionId, turn, source.senderSessionId]); ++ const order = useSessions((list) => { ++ const outline = list.projectionsBySession?.[sessionId]?.values?.turnOutline ?? list.byId[sessionId]?.projectionValues?.turnOutline; ++ return Array.isArray(outline) ? outline.map((entry) => entry.turn).join(",") : ""; ++ }); ++ const peers = useTurnClock((state) => { ++ if (!relayed) return ""; ++ const turns = state[sessionId] ?? {}; ++ return Object.keys(turns).filter((key) => turns[key].peer !== void 0).map((key) => `${key}:${turns[key].peer}`).join(","); ++ }); ++ const visit = useUi((state) => state.visits?.[sessionId]); ++ const hiddenTurns = roster.exchangeTurns[sessionId]; ++ const unreadOpening = useTurnClock((state) => { ++ const first = openingTurn(state[sessionId], order, hiddenTurns); ++ return first?.turn === turn && unseenSince(visit, first.start) ? clockLabel(first.start) : ""; ++ }); ++ const body = triggerBody(); ++ return unreadOpening ? (0, import_react10.createElement)(import_react10.Fragment, null, (0, import_react10.createElement)("div", { className: "bt-open-new" }, (0, import_react10.createElement)(NewDivider), (0, import_react10.createElement)("div", { className: "bt-time-sep", role: "separator" }, unreadOpening)), body) : body; ++ function triggerBody() { ++ if (source.form === "remote-chat") return null; ++ if (source.shown === true) return null; ++ if (source.remote?.handle) { ++ const message = (0, import_react10.createElement)(RemoteMessage, { source, text: remoteMessageText(stripHeader(contentText(data.content))), roster, actions, sessionId, msgKey: `${sessionId}:${data.turn}:remote`, useUi }); ++ return source.remote.firstContact === true ? (0, import_react10.createElement)(import_react10.Fragment, null, (0, import_react10.createElement)(FirstContact, { source, roster, sessionId }), message) : message; + } +- }; +- try { +- visit(sheet.cssRules); +- } catch { ++ if (source.kind === "bot") { ++ if (source.handoff === true) return roster.roomsById[sessionId] ? null : (0, import_react10.createElement)(EarlierParts, { sessionId, roster, actions, useUi }); ++ if (source.role === "room" || source.role === "kickoff") return null; ++ if (source.role === "mention") return (0, import_react10.createElement)("div", { className: "bt-event" }, (0, import_react10.createElement)("span", null, contentText(data.content))); ++ if (source.role === "secret" || deferred) return null; ++ if (source.role === "post") { ++ if (source.failed !== void 0 || source.resumed === true || source.stopped === true) return null; ++ if (source.notice === true || source.senderSessionId === "face-notice") return null; ++ const room2 = roster.roomsById[sessionId]; ++ const poster = roster.byId[source.senderSessionId] ?? { id: source.senderSessionId, name: source.senderName ?? t("a Bot"), color: "gray" }; ++ return (0, import_react10.createElement)(GroupMessage, { bot: poster, text: stripHeader(contentText(data.content)), room: room2, roster, actions, msgKey: `${sessionId}:${data.turn}:post`, useUi }); ++ } ++ if (source.role === "report") { ++ return (0, import_react10.createElement)(GroupEvent, { prefix: t("Replies from"), room: roster.roomsById[source.roomId], name: t("a group chat"), roster, actions }); ++ } ++ if (source.role === "resume" || source.resumed === true) return (0, import_react10.createElement)("div", { className: "bt-event" }, t("Picked up its work after a restart")); ++ if (source.role === "retry" || source.retried === true) return (0, import_react10.createElement)("div", { className: "bt-event" }, t("Asked to make the tool call it wrote as text")); ++ const run = relayed ? commRunOf( ++ order.split(",").filter(Boolean).map(Number), ++ Object.fromEntries(peers.split(",").filter(Boolean).map((pair) => [Number(pair.slice(0, pair.indexOf(":"))), pair.slice(pair.indexOf(":") + 1)])), ++ turn, ++ hiddenTurns ++ ) : null; ++ if (run === "folded") return null; ++ const sender = roster.byId[source.senderSessionId] ?? { id: source.senderSessionId, name: source.senderName ?? t("a Bot"), color: "gray" }; ++ const count = run?.count ?? (isHiddenTurn(roster, sessionId, turn) ? 2 : 1); ++ const bots = [sender]; ++ for (const peer of (run?.peerIds ?? []).map((id) => roster.byId[id]).filter(Boolean)) { ++ if (!bots.some((bot) => bot.id === peer.id)) bots.push(peer); ++ } ++ return (0, import_react10.createElement)(EventLine, { ++ prefix: source.role === "stopped" ? t("Stopped by") : count > 1 ? t("{count} messages with", { count }) : t("Message from"), ++ bots, ++ onOpen: (bot) => actions.openExchange(selfId, bot.id), ++ whole: true ++ }); ++ } ++ if (source.kind === "schedule") { ++ const prompts = reminderPrompts(contentText(data.content)); ++ return (0, import_react10.createElement)("div", { className: "bt-event" }, prompts.length > 0 ? t("Scheduled task: {text}", { text: prompts.join(" · ").slice(0, 80) }) : t("Routine started")); ++ } ++ if (source.kind === "user-question-reply") return null; ++ return (0, import_react10.createElement)("div", { className: "bt-event" }, contentText(data.content).slice(0, 80) || t("Update")); + } +- return names; } --var REACTIONS = ["👍", "❤️", "😂", "😮", "😢", "🙏"]; --function useDismiss(open, close, refs) { -- (0, import_react6.useEffect)(() => { -- if (!open) return void 0; -- const onDown = (event) => { -- if (refs.some((ref) => ref.current?.contains(event.target))) return; -- close(); -- }; -- const onKey = (event) => { -- if (event.key === "Escape") { -- event.stopPropagation(); -- close(); +-function compileFragmentSelectors(text, names) { +- const compiled = /* @__PURE__ */ new Map(); +- return text.replace(FRAGMENT_SELECTOR, (whole, fragment) => { +- if (!compiled.has(fragment)) { +- const exact = []; +- for (const name of names) if (name.includes(fragment)) exact.push(`.${name}`); +- compiled.set(fragment, exact.length ? `:is(${exact.join(",")})` : whole); +- } +- return compiled.get(fragment); ++function NewDivider() { ++ return (0, import_react10.createElement)("div", { className: "bt-new-sep", role: "separator", "aria-label": t("New messages") }, (0, import_react10.createElement)("span", null, t("New"))); ++} ++function TurnProcessCell({ node, sessionId, turnProcess, useRoster, useUi, useTurnClock, actions }) { ++ const open = turnProcess === void 0 || !turnProcess.foldable || turnProcess.open; ++ (0, import_react10.useEffect)(() => { ++ if (!open) turnProcess.setOpen(true); ++ }, [open, turnProcess]); ++ const turn = node?.location?.turn?.turn ?? node?.data?.turn; ++ const start = node?.location?.turn?.start?.time; ++ const hidden = useRoster((roster) => isHiddenTurn(roster, sessionId, turn)); ++ (0, import_react10.useEffect)(() => { ++ actions.noteTurnTime(sessionId, turn, "first", start); ++ }, [sessionId, turn, start]); ++ const visit = useUi((state) => state.visits?.[sessionId]); ++ const fresh = useTurnClock((state) => { ++ const own = state[sessionId]?.[turn]; ++ return !hidden && typeof visit?.seen === "number" && own?.first !== void 0 && own.first <= visit.seen && own.end !== void 0 && own.end > visit.seen && own.end <= visit.entered; + }); ++ return fresh ? (0, import_react10.createElement)(NewDivider) : null; + } +-function installCompiledStyles(text, tag) { +- const style = installStyles("", tag); +- const cache = /* @__PURE__ */ new WeakMap(); +- const render = () => { +- const names = /* @__PURE__ */ new Set(); +- for (const sheet of document.styleSheets) { +- if (sheet.ownerNode?.dataset?.dshBot) continue; +- let list = cache.get(sheet); +- if (!list) { +- list = sheetClassNames(sheet); +- cache.set(sheet, list); - } +- for (const name of list) names.add(name); +- } +- const next = compileFragmentSelectors(text, names); +- if (style.textContent !== next) style.textContent = next; +- }; +- render(); +- let timer; +- const schedule = () => { +- clearTimeout(timer); +- timer = setTimeout(render, 120); +- }; +- const onLoad = (event) => { +- if (event.target instanceof HTMLLinkElement) schedule(); +- }; +- const observer = new MutationObserver(schedule); +- observer.observe(document.head, { childList: true }); +- document.addEventListener("load", onLoad, true); +- return () => { +- observer.disconnect(); +- document.removeEventListener("load", onLoad, true); +- clearTimeout(timer); +- style.remove(); +- }; ++function Nothing() { ++ return null; + } +- +-// src/client/mark.js +-var import_react2 = require("react"); +-var STATES = ["idle", "thinking", "searching", "working", "sending", "orbit", "alert", "done"]; +-var botState = (running, activity, alert) => running ? STATES.includes(activity) ? activity : "working" : alert ? "alert" : "idle"; +-var toolState = (name) => /^(message_bot|stop_bot|create_bot|update_bot|create_group|update_group|delete_group|post_to_group)$/.test(name) ? "sending" : /search|fetch|browse|read|grep|glob|list|recall/i.test(name) ? "searching" : "working"; +-function useGaze(ref, enabled) { +- (0, import_react2.useEffect)(() => { +- if (!enabled) return void 0; +- let frame = 0; +- let point = null; +- const place = () => { +- frame = 0; +- const node = ref.current; +- if (!node || !point) return; +- const box = node.getBoundingClientRect(); +- const clamp = (value) => Math.max(-1, Math.min(1, value)); +- node.style.setProperty("--bt-gx", clamp((point.x - box.left - box.width / 2) / 160).toFixed(2)); +- node.style.setProperty("--bt-gy", clamp((point.y - box.top - box.height / 2) / 160).toFixed(2)); - }; -- window.addEventListener("mousedown", onDown); -- window.addEventListener("keydown", onKey, true); +- const onMove = (event) => { +- point = { x: event.clientX, y: event.clientY }; +- if (!frame) frame = requestAnimationFrame(place); +- }; +- const onLeave = () => { +- point = null; +- ref.current?.style.setProperty("--bt-gx", "0"); +- ref.current?.style.setProperty("--bt-gy", "0"); +- }; +- window.addEventListener("pointermove", onMove, { passive: true }); +- document.documentElement.addEventListener("pointerleave", onLeave); - return () => { -- window.removeEventListener("mousedown", onDown); -- window.removeEventListener("keydown", onKey, true); +- cancelAnimationFrame(frame); +- window.removeEventListener("pointermove", onMove); +- document.documentElement.removeEventListener("pointerleave", onLeave); - }; -- }, [open]); -+ -+// src/client/kanban.js -+var import_react15 = require("react"); -+var POLL_MS3 = 15e3; -+function BoardIcon() { -+ return (0, import_react15.createElement)( -+ "svg", -+ { width: 18, height: 18, viewBox: "0 0 20 20", fill: "none", stroke: "currentColor", strokeWidth: 1.6, strokeLinejoin: "round", "aria-hidden": true }, -+ (0, import_react15.createElement)("rect", { x: 2.5, y: 3, width: 4, height: 14, rx: 1 }), -+ (0, import_react15.createElement)("rect", { x: 8, y: 3, width: 4, height: 9, rx: 1 }), -+ (0, import_react15.createElement)("rect", { x: 13.5, y: 3, width: 4, height: 12, rx: 1 }) -+ ); - } --function MessageTools({ text, name, sessionId, react, actions }) { -- const [panel, setPanel] = (0, import_react6.useState)(null); -- const [place, setPlace] = (0, import_react6.useState)(null); -- const [copied, setCopied] = (0, import_react6.useState)(false); -- const tools = (0, import_react6.useRef)(null); -- const pop = (0, import_react6.useRef)(null); -- const close = () => setPanel(null); -- useDismiss(panel !== null, close, [tools, pop]); -- const open = (kind, event) => { -- if (panel === kind) { -- close(); -- return; -+function shortId(id) { -+ const hex = String(id ?? "").replace(/^task-/, ""); -+ return `#${hex.slice(-6)}`; -+} -+var ARCHIVE_SHOWN = 20; -+function boardTasksFor(tasks, archive, limit = ARCHIVE_SHOWN) { -+ const newest = [...archive ?? []].sort((a, b) => (b.updatedAt ?? 0) - (a.updatedAt ?? 0) || (b.createdAt ?? 0) - (a.createdAt ?? 0)); -+ return [...tasks ?? [], ...newest.slice(0, limit)]; -+} -+function boardColumns(tasks) { -+ const columns = { "not-started": [], "in-progress": [], done: [] }; -+ for (const task of tasks ?? []) { -+ const column = columnOf(task); -+ (columns[column] ?? columns["not-started"]).push(task); -+ } -+ for (const list of Object.values(columns)) list.sort((a, b) => (b.updatedAt ?? 0) - (a.updatedAt ?? 0)); -+ return columns; -+} -+function columnTree(tasks) { -+ const ids = new Set((tasks ?? []).map((task) => task.id)); -+ const childrenOf = /* @__PURE__ */ new Map(); -+ const nested = /* @__PURE__ */ new Set(); -+ for (const task of tasks ?? []) { -+ if (!task.parentId || !ids.has(task.parentId)) continue; -+ const parent = tasks.find((item) => item.id === task.parentId); -+ if (parent?.kind !== "group") continue; -+ const list = childrenOf.get(parent.id) ?? []; -+ list.push(task); -+ childrenOf.set(parent.id, list); -+ nested.add(task.id); -+ } -+ const nests = []; -+ const loose = []; -+ for (const task of tasks ?? []) { -+ if (nested.has(task.id)) continue; -+ if (task.kind === "group") nests.push({ task, children: childrenOf.get(task.id) ?? [] }); -+ else loose.push(task); -+ } -+ return { nests, loose }; -+} -+function adminOf(roster, room2) { -+ const me = roster?.self?.memberId; -+ if (!me) return room2?.origin !== "face-to-face"; -+ const human = (room2?.participants ?? []).find((person) => person.kind === "human" && person.id === me && person.leftSeq === void 0); -+ if (human?.role === "admin") return true; -+ const admin = roster?.byId?.[room2?.admin]; -+ return Boolean(admin && !admin.remote && admin.ownerId === me); -+} -+function boardRail(roster) { -+ const me = roster?.self?.memberId; -+ const bots = (roster?.bots ?? []).filter((bot) => bot && !bot.remote && (!me || !bot.ownerId || bot.ownerId === me)); -+ const ids = new Set(bots.map((bot) => bot.id)); -+ const children = /* @__PURE__ */ new Map(); -+ for (const bot of bots) { -+ const parent = bot.createdBy && ids.has(bot.createdBy) ? bot.createdBy : ""; -+ const list = children.get(parent) ?? []; -+ list.push(bot); -+ children.set(parent, list); +- }, [enabled]); ++var HIDDEN_REMOVED_TOOLS = /* @__PURE__ */ new Set(["ask_user_question"]); ++var isConnectorTool = (name) => typeof name === "string" && /^mcp__(github|composio)__/.test(name); ++var SILENT_REGISTRATION_TOOLS = /* @__PURE__ */ new Set([ ++ "list_bots", ++ "message_bot", ++ "stop_bot", ++ "create_bot", ++ "update_bot", ++ "archive_bot", ++ "list_models", ++ "bot_schedules", ++ "create_group", ++ "update_group", ++ "delete_group", ++ "post_to_group", ++ "ask_user", ++ "track_tasks", ++ "request_secret", ++ "list_secrets", ++ "group_relay", ++ "read_own_chat", ++ "read_group_chat", ++ "remember", ++ "forget", ++ "recall", ++ "read_browser", ++ "list_capabilities", ++ "save_capability", ++ "delete_capability", ++ "attach_capability", ++ "detach_capability", ++ "track_links", ++ "project_state", ++ "project_digest", ++ "meeting" ++]); ++var isSilentRegistration = (name) => typeof name === "string" && SILENT_REGISTRATION_TOOLS.has(name); ++var isHiddenNotice = (block) => block?.type === "tool-removal" && (HIDDEN_REMOVED_TOOLS.has(block.toolName) || isConnectorTool(block.toolName) || isPluginTool(block.toolName) || isSilentRegistration(block.toolName)) || block?.type === "tool-addition" && (isConnectorTool(block.toolName) || isPluginTool(block.toolName) || isSilentRegistration(block.toolName)); ++function isHiddenToolRemoval(node) { ++ const content = node?.data?.content; ++ if (!Array.isArray(content) || content.length === 0) return false; ++ return content.every(isHiddenNotice); ++} ++function ContextCell({ node, useRoster }) { ++ useRoster?.((roster) => (roster.pluginTools ?? []).join("\n")); ++ if (isHiddenToolRemoval(node)) return null; ++ const content = Array.isArray(node?.data?.content) ? node.data.content : []; ++ const parts = []; ++ for (const block of content) { ++ if (isHiddenNotice(block)) continue; ++ if (block?.type === "tool-removal" && block.toolName) parts.push(`Tool removed: ${block.toolName}`); ++ else if (block?.type === "tool-addition" && block.toolName) parts.push(`Tool added: ${block.toolName}`); + } -+ for (const list of children.values()) list.sort((a, b) => (a.createdAt ?? 0) - (b.createdAt ?? 0) || String(a.name).localeCompare(String(b.name))); -+ const rows = []; -+ const walk = (parent, depth) => { -+ for (const bot of children.get(parent) ?? []) { -+ rows.push({ kind: "bot", id: bot.id, name: bot.name, depth, bot }); -+ walk(bot.id, depth + 1); - } -- const rect = event.currentTarget.getBoundingClientRect(); -- const below = rect.bottom + 220 < window.innerHeight; -- setPlace(kind === "react" ? { left: rect.left + rect.width / 2, top: rect.top - 6, "--bt-origin": "bottom center" } : { left: Math.min(rect.left, window.innerWidth - 208), ...below ? { top: rect.bottom + 4 } : { bottom: window.innerHeight - rect.top + 4, "--bt-origin": "bottom left" } }); -- setPanel(kind); ++ if (parts.length === 0) return null; ++ return (0, import_react10.createElement)("div", { className: "bt-event" }, parts.join(" · ")); + } +-var FIDGETS = ["hop", "sway", "stretch", "look"]; +-function BotMark({ bot, look: given, size = 36, state = "idle", live = false, gaze = false, pokeable = false, label }) { +- const look = given ?? lookOf(bot); +- const ref = (0, import_react2.useRef)(null); +- const maskId = (0, import_react2.useRef)(""); +- if (maskId.current === "") maskId.current = `bt-mark-${nextMarkSerial()}`; +- const [poked, setPoked] = (0, import_react2.useState)(false); +- useGaze(ref, gaze); +- const common = { +- ref, +- className: poked ? "bt-mark bt-mark-poke" : "bt-mark", +- "data-shape": look.image ? "image" : shapeId(look.shape), +- "data-state": STATES.includes(state) ? state : "idle", +- "data-live": live ? "" : void 0, +- "data-fidget": live ? FIDGETS[fnv1a(`${bot?.id ?? ""}:fidget`) % FIDGETS.length] : void 0, +- "data-gaze": gaze ? "" : void 0, +- "data-poke": pokeable ? "" : void 0, +- style: { width: size, height: size, ...live ? { "--bt-delay": `${-(fnv1a(String(bot?.id ?? "")) % 5e3) / 1e3}s`, "--bt-fidget-delay": `${-(fnv1a(`${bot?.id ?? ""}:when`) % 9e3) / 1e3}s` } : {} }, +- role: label ? "img" : void 0, +- "aria-label": label, +- "aria-hidden": label ? void 0 : true, +- onClick: pokeable ? () => setPoked(true) : void 0, +- onAnimationEnd: poked ? (event) => { +- if (/^bt-poke/.test(event.animationName)) setPoked(false); +- } : void 0 - }; -- const copy2 = () => { -- void navigator.clipboard?.writeText(text).then(() => { -- setCopied(true); -- setTimeout(() => setCopied(false), 1e3); -- }); - }; -- const download2 = () => { -- const url = URL.createObjectURL(new Blob([text], { type: "text/markdown" })); -- const link = Object.assign(document.createElement("a"), { href: url, download: `${name}-message.md` }); -- link.click(); -- setTimeout(() => URL.revokeObjectURL(url), 1e3); -+ walk("", 0); -+ const groups = (roster?.rooms ?? []).filter((room2) => room2 && room2.hidden !== true && room2.origin !== "face-to-face" && adminOf(roster, room2)).map((room2) => ({ kind: "group", id: room2.id, name: room2.displayName || room2.name || t("Group"), room: room2 })); -+ return { bots: rows, groups }; -+} -+function canMoveTask(roster, task) { -+ const me = roster?.self?.memberId; -+ if (!me) return true; -+ const bot = roster?.byId?.[task?.ownerBotId]; -+ return Boolean(bot && !bot.remote && (!bot.ownerId || bot.ownerId === me)); -+} -+var TERMINAL_STATUSES = /* @__PURE__ */ new Set(["accepted", "cancelled", "failed"]); -+function finishedAt(task) { -+ const terminal = (task?.actors ?? []).find((entry) => TERMINAL_STATUSES.has(entry?.status)); -+ if (Number.isFinite(terminal?.at)) return terminal.at; -+ return Number.isFinite(task?.updatedAt) ? task.updatedAt : void 0; -+} -+function priorityLabel(priority) { -+ if (priority === "urgent") return t("Highest"); -+ if (priority === "high") return t("High"); -+ if (priority === "low") return t("Low"); -+ return t("Normal"); -+} -+function statusLabel(status) { -+ switch (status) { -+ case "claimed": -+ return t("Claimed"); -+ case "working": -+ return t("In progress"); -+ case "blocked": -+ return t("Blocked"); -+ case "result-ready": -+ return t("Result ready"); -+ case "accepted": -+ return t("Done"); -+ case "cancelled": -+ return t("Cancelled"); -+ case "failed": -+ return t("Failed"); -+ default: -+ return t("Not started"); +- if (look.image) return (0, import_react2.createElement)("span", common, (0, import_react2.createElement)("img", { className: "bt-mark-img", src: look.image, alt: "", draggable: false })); +- const box = shapeOf(look.shape).size; +- return (0, import_react2.createElement)("span", common, (0, import_react2.createElement)("svg", { viewBox: `0 0 ${box} ${box}`, dangerouslySetInnerHTML: { __html: characterMarkup(look.shape, look.color).replaceAll(MASK_ID, maskId.current) } })); ++var SEPARATOR_GAP_MS = 30 * 60 * 1e3; ++function clockLabel(time, now = Date.now()) { ++ const date = new Date(time); ++ const midnight = (value) => new Date(value.getFullYear(), value.getMonth(), value.getDate()).getTime(); ++ const days = Math.round((midnight(new Date(now)) - midnight(date)) / 864e5); ++ const clock4 = date.toLocaleTimeString(dateLocale(), { hour: "numeric", minute: "2-digit" }); ++ if (days === 0) return t("Today {time}", { time: clock4 }); ++ if (days === 1) return t("Yesterday {time}", { time: clock4 }); ++ const sameYear = date.getFullYear() === new Date(now).getFullYear(); ++ return `${date.toLocaleDateString(dateLocale(), { weekday: "short", month: "short", day: "numeric", ...sameYear ? {} : { year: "numeric" } })} ${clock4}`; + } +-function StarIcon({ size = 8 }) { +- return (0, import_react2.createElement)( +- "svg", +- { width: size, height: size, viewBox: "0 0 10 10", "aria-hidden": true }, +- (0, import_react2.createElement)("path", { d: "M5 .6l1.3 2.8 3 .3-2.3 2 .7 3L5 7.2 2.3 8.7l.7-3L.7 3.7l3-.3z", fill: "#fff" }) ++function nextTurnStart(turns, turn) { ++ let next; ++ for (const key in turns) { ++ const candidate = Number(key); ++ if (candidate > turn && turns[key].first !== void 0 && (next === void 0 || candidate < next)) next = candidate; + } ++ return next === void 0 ? void 0 : turns[next].first; +} -+function dropLabel(column) { -+ if (column === "in-progress") return t("Start task"); -+ if (column === "done") return t("Complete task"); -+ return t("Not started"); -+} -+function columnTitle(column) { -+ if (column === "in-progress") return t("In progress"); -+ if (column === "done") return t("Done"); -+ return t("Not started"); ++function openingTurn(turns, firstTurns, hiddenTurns) { ++ if (!turns || !firstTurns) return null; ++ const start = nextTurnStart(turns, -Infinity); ++ if (start === void 0) return null; ++ const turn = Number(Object.keys(turns).filter((key) => turns[key].first === start)[0]); ++ const listed = firstTurns.split(",").map(Number); ++ const earlier = listed.filter((entry) => entry < turn); ++ return listed.includes(turn) && earlier.every((entry) => hiddenTurns?.includes(entry)) ? { turn, start } : null; +} -+function botName(roster, task) { -+ return roster?.byId?.[task.ownerBotId]?.name || task.ownerBotName || ""; ++var unseenSince = (visit, time) => typeof visit?.seen === "number" && time > visit.seen && time <= visit.entered; ++function movedOnSince(visit, turns, hiddenTurns) { ++ if (typeof visit?.entered !== "number") return false; ++ for (const key in turns ?? {}) { ++ if (hiddenTurns?.includes(Number(key))) continue; ++ if (turns[key].first !== void 0 && turns[key].first > visit.entered) return true; ++ } ++ return false; +} -+function lanesOf(tasks, roster) { -+ const order = []; -+ const map = /* @__PURE__ */ new Map(); -+ for (const task of tasks) { -+ const id = task.ownerBotId ?? ""; -+ if (!map.has(id)) { -+ map.set(id, []); -+ order.push(id); ++var settledVisit = (visit) => typeof visit?.seen === "number" && visit.seen < visit.entered ? { ...visit, seen: visit.entered } : visit; ++function TurnTailCell({ turn: location2, sessionId, useTurnClock, useUi, actions }) { ++ const turn = location2.turn; ++ const time = location2.end.time; ++ (0, import_react10.useEffect)(() => { ++ actions.noteTurnTime(sessionId, turn, "end", time); ++ }, [sessionId, turn, time]); ++ const label = useTurnClock((state) => { ++ const start = nextTurnStart(state[sessionId] ?? {}, turn); ++ return start !== void 0 && start - time >= SEPARATOR_GAP_MS ? clockLabel(start) : ""; ++ }); ++ const visit = useUi((state) => state.visits?.[sessionId]); ++ const fresh = useTurnClock((state) => { ++ if (typeof visit?.seen !== "number" || time > visit.seen) return false; ++ const start = nextTurnStart(state[sessionId] ?? {}, turn); ++ return start !== void 0 && start > visit.seen && start <= visit.entered; ++ }); ++ if (!label && !fresh) return null; ++ return (0, import_react10.createElement)( ++ import_react10.Fragment, ++ null, ++ label ? (0, import_react10.createElement)("div", { className: "bt-time-sep", role: "separator" }, label) : null, ++ fresh ? (0, import_react10.createElement)(NewDivider) : null + ); + } +-function AdminStar() { +- return (0, import_react2.createElement)("span", { className: "bt-admin-star", role: "img", "aria-label": t("Admin") }, (0, import_react2.createElement)(StarIcon, { size: 7 })); +-} +-function MainStar() { +- return (0, import_react2.createElement)( +- "svg", +- { viewBox: "0 0 20 20", "aria-hidden": true }, +- (0, import_react2.createElement)("path", { d: "M10 2.4l2.29 5.04 5.51.63-4.09 3.74 1.11 5.42L10 14.5l-4.82 2.73 1.11-5.42L2.2 8.07l5.51-.63z" }) +- ); ++function Shimmer({ children }) { ++ return (0, import_react10.createElement)("span", { className: "bt-shimmer" }, children); + } +-function useSettled(state) { +- const prev = (0, import_react2.useRef)(state); +- const [settled, setSettled] = (0, import_react2.useState)(false); +- (0, import_react2.useEffect)(() => { +- const was = prev.current; +- prev.current = state; +- if (state !== "idle") { +- setSettled(false); +- return void 0; ++var PHRASES = { ++ thinking: ["Thinking about it", "Weighing the options", "Working out a plan", "Sorting out the details"], ++ working: ["On it", "Busy with it", "Moving ahead", "Taking care of it", "Making headway"] ++}; ++var TOOL_PHRASES = [ ++ [/web_?search|search_?web/i, "Searching online"], ++ [/fetch|browse|browser|web/i, "Reading a page"], ++ [/shell|bash|pwsh|powershell|exec|command|terminal/i, "Running shell commands"], ++ [/^(read|view|cat)\b|read_/i, "Opening a file"], ++ [/grep|glob|list_dir|^ls$|find|search/i, "Looking through files"], ++ [/write|edit|patch|apply|replace/i, "Writing changes"], ++ [/image|photo|draw/i, "Working on an image"] ++]; ++var GROUP_PHRASES = { create_group: "Creating a group", update_group: "Updating the group", delete_group: "Deleting the group", post_to_group: "Posting in the group" }; ++function toolActivity(name, arg, findBot, self) { ++ switch (name) { ++ case "ask_user": ++ return null; ++ case "request_secret": ++ return null; ++ case "list_secrets": ++ return { label: t("Checking the keys"), state: "searching" }; ++ case "ask_user_question": ++ return { label: t("Waiting for your answer"), state: "alert" }; ++ case "group_relay": ++ return { relay: true, state: "orbit" }; ++ case "message_bot": { ++ const target = findBot(arg("to")); ++ return { label: target ? t("Asking {name}", { name: target.name }) : t("Asking another Bot"), state: "sending" }; + } +- if (was === "idle" || was === "alert") return void 0; +- setSettled(true); +- const timer = setTimeout(() => setSettled(false), 1800); +- return () => clearTimeout(timer); +- }, [state]); +- return settled; ++ case "stop_bot": { ++ const target = findBot(arg("bot")); ++ return { label: target ? t("Stopping {name}", { name: target.name }) : t("Stopping another Bot"), state: "sending" }; + } -+ map.get(id).push(task); ++ case "create_bot": ++ return { label: t("Creating {name}", { name: arg("name") ?? t("a Bot") }), state: "sending" }; ++ case "update_bot": { ++ const target = findBot(arg("name")) ?? findBot(arg("bot")) ?? (arg("bot") ? void 0 : self); ++ return { label: t("Updating {name}", { name: target?.name ?? t("a Bot") }), state: "sending" }; ++ } ++ case "list_bots": ++ return { label: t("Checking the team"), state: "searching" }; ++ case "read_group_chat": ++ return { label: t("Reading {name}", { name: arg("group") ?? t("a group chat") }), state: "searching" }; ++ case "read_own_chat": ++ return { label: t("Looking back through the chat"), state: "searching" }; ++ case "remember": ++ return { label: t("Saving to memory"), state: "working" }; ++ case "forget": ++ return { label: t("Updating memory"), state: "working" }; ++ case "recall": ++ return { label: t("Checking memory"), state: "searching" }; ++ default: ++ if (GROUP_PHRASES[name]) return { label: t(GROUP_PHRASES[name]), state: "sending" }; ++ const phrase = TOOL_PHRASES.find(([pattern]) => pattern.test(name))?.[1]; ++ return { label: phrase ? t(phrase) : null, state: toolState(name) }; + } -+ return order.map((id) => ({ id, name: botName(roster, map.get(id)[0]) || id, tasks: map.get(id) })); + } +-function BotAvatar({ bot, size, state = "idle", main, admin, badge = true, live = true, gaze = false, pokeable = false }) { +- const settled = useSettled(state); +- if (bot === void 0) return (0, import_react2.createElement)(BotMark, { size }); +- const badgeKind = !badge ? null : state === "alert" ? "alert" : state !== "idle" ? "working" : admin ? "admin" : main ? "main" : null; +- return (0, import_react2.createElement)( +- "span", +- { className: "bt-mark", style: { width: size, height: size, "--bt-size": `${size}px` } }, +- (0, import_react2.createElement)(BotMark, { bot, size, state: settled ? "done" : state, live, gaze, pokeable }), +- badgeKind === "working" ? (0, import_react2.createElement)("span", { className: "bt-badge bt-badge-working", "aria-label": t("Working"), role: "img" }) : null, +- badgeKind === "alert" ? (0, import_react2.createElement)("span", { className: "bt-badge bt-badge-alert", "aria-label": t("Waiting for your answer"), role: "img" }, "?") : null, +- badgeKind === "admin" ? (0, import_react2.createElement)("span", { className: "bt-badge bt-badge-admin", "aria-label": t("Admin"), role: "img" }, (0, import_react2.createElement)(StarIcon)) : null, +- badgeKind === "main" ? (0, import_react2.createElement)("span", { className: "bt-badge bt-badge-main", "aria-label": t("Main Bot"), role: "img" }, (0, import_react2.createElement)(MainStar)) : null +- ); ++function workPhrase(tool) { ++ if (!tool) return t("Thinking about it"); ++ return toolActivity(tool, () => void 0, () => void 0)?.label ?? t("Using {tool}", { tool }); + } +-function clusterSeats(count, frame) { +- if (count === 1) return [[0, 0, frame]]; +- if (count === 2) return [[0, 0, frame * 2 / 3], [frame / 3, frame / 3, frame * 2 / 3]]; +- if (count === 3) { +- const size = frame * 5 / 9; +- const rest = frame - size; +- return [[rest / 2, 0, size], [0, rest, size], [rest, rest, size]]; +- } +- const half = frame / 2; +- return [[0, 0, half], [half, 0, half], [0, half, half], [half, half, half]]; ++function workText(tool, step) { ++ return step ? t("{action}: {detail}", { action: workPhrase(tool), detail: step }) : `${workPhrase(tool)}…`; + } +-function RoomAvatar({ room: room2, roster, size = 36 }) { +- const members = room2.members.map((id) => roster.byId[id]).filter(Boolean); +- const people = members.length === 1 ? [members[0], null] : members; +- const seat = (bot, seatSize, key) => bot ? (0, import_react2.createElement)(BotMark, { key, bot, size: seatSize }) : (0, import_react2.createElement)("span", { key, className: "bt-cluster-you", style: { width: seatSize * 0.92, height: seatSize * 0.92, margin: seatSize * 0.04 } }); +- if (people.length === 0) return (0, import_react2.createElement)(BotMark, { size }); +- if (size <= 20 && people.length > 1) { +- return (0, import_react2.createElement)( +- "span", +- { className: "bt-stack", style: { "--bt-stack-size": `${size}px` } }, +- people.slice(0, 3).map((bot, index) => seat(bot, size, bot?.id ?? `you-${index}`)), +- people.length > 3 ? (0, import_react2.createElement)("span", { className: "bt-stack-more" }, `+${people.length - 3}`) : null +- ); ++function useLiveNote(actions, sessionId, id, entry) { ++ const key = entry ? JSON.stringify(entry) : ""; ++ (0, import_react10.useEffect)(() => { ++ if (!key) return void 0; ++ actions.noteLive(sessionId, id, JSON.parse(key)); ++ return () => actions.noteLive(sessionId, id, null); ++ }, [sessionId, id, key]); +} -+var upsert = (list, task) => list.some((item) => item.id === task.id) ? list.map((item) => item.id === task.id ? task : item) : [...list, task]; -+var without = (list, id) => list.filter((item) => item.id !== id); -+function useBoardTasks(actions, scope) { -+ const [own, setOwn] = (0, import_react15.useState)([]); -+ const [groupTasks, setGroupTasks] = (0, import_react15.useState)([]); -+ const [archive, setArchive] = (0, import_react15.useState)([]); -+ const key = scope?.kind === "group" ? `group:${scope.roomId}` : `bot:${scope.botId ?? ""}`; -+ (0, import_react15.useEffect)(() => { -+ let stopped = false; -+ const load = () => { -+ const query = scope?.kind === "group" ? { roomId: scope.roomId } : {}; -+ const archiveQuery = scope?.kind === "bot" ? { botId: scope.botId } : query; -+ const job = scope?.kind === "group" ? Promise.resolve(actions?.tasks?.(query)) : Promise.resolve(actions?.tasks?.()); -+ void job.then((list) => { -+ if (stopped || !Array.isArray(list)) return; -+ if (scope?.kind === "group") setGroupTasks(list); -+ else setOwn(list); -+ }).catch(() => void 0); -+ void Promise.resolve(actions?.tasks?.({ ...archiveQuery, archive: true, limit: ARCHIVE_SHOWN })).then((list) => { -+ if (stopped || !Array.isArray(list)) return; -+ setArchive(list.filter((task) => columnOf(task) === "done")); -+ }).catch(() => void 0); ++function GroupReplies({ sessionId, callId, done, content, roster, actions, useUi }) { ++ const [live, setLive] = (0, import_react10.useState)({ replies: [] }); ++ (0, import_react10.useEffect)(() => { ++ if (done) return void 0; ++ let stop = false; ++ let timer; ++ let seen = ""; ++ const tick = async () => { ++ const result = await actions.roomProgress(sessionId); ++ if (stop) return; ++ const entry = (result ?? []).find((item) => item.callId === callId) ?? (result ?? [])[0]; ++ const key = entry ? JSON.stringify(entry.replies) : ""; ++ if (entry && key !== seen) { ++ seen = key; ++ setLive({ replies: entry.replies ?? [] }); ++ } ++ timer = setTimeout(tick, document.hidden ? 5e3 : 1e3); + }; -+ load(); -+ const timer = setInterval(load, POLL_MS3); ++ void tick(); + return () => { -+ stopped = true; -+ clearInterval(timer); ++ stop = true; ++ clearTimeout(timer); + }; -+ }, [key]); -+ const tasks = scope?.kind === "group" ? groupTasks : own.filter((task) => !scope?.botId || task.ownerBotId === scope.botId); -+ const replace = (task) => { -+ const done2 = columnOf(task) === "done"; -+ if (scope?.kind === "group") setGroupTasks((list) => done2 ? without(list, task.id) : upsert(list, task)); -+ else setOwn((list) => done2 ? without(list, task.id) : upsert(list, task)); -+ setArchive((list) => done2 ? [task, ...without(list, task.id)] : without(list, task.id)); - }; -- const item = (label, Icon, run) => (0, import_react6.createElement)("button", { key: label, type: "button", role: "menuitem", onClick: () => { -- close(); -- run(); -- } }, (0, import_react6.createElement)(Icon), label); -- return (0, import_react6.createElement)( -- "div", -- { ref: tools, className: "bt-tools", "data-open": panel !== null || void 0 }, -- react ? (0, import_react6.createElement)("button", { type: "button", className: "bt-tool", "aria-label": t("Add reaction"), title: t("Add reaction"), "aria-expanded": panel === "react", onClick: (event) => open("react", event) }, (0, import_react6.createElement)(SmileIcon)) : null, -- sessionId ? (0, import_react6.createElement)("button", { type: "button", className: "bt-tool", "aria-label": t("Reply"), title: t("Reply"), onClick: () => actions.quote(sessionId, text) }, (0, import_react6.createElement)(ReplyIcon)) : null, -- (0, import_react6.createElement)("button", { type: "button", className: "bt-tool", "aria-label": t("More message actions"), title: t("More"), "aria-haspopup": "menu", "aria-expanded": panel === "more", onClick: (event) => open("more", event) }, copied ? (0, import_react6.createElement)(CheckIcon) : (0, import_react6.createElement)(DotsIcon)), -- panel === "react" ? (0, import_react_dom.createPortal)((0, import_react6.createElement)( -+ const done = scope?.kind === "group" ? archive : archive.filter((task) => !scope?.botId || task.ownerBotId === scope.botId); -+ return { own, groupTasks, tasks, archive: done, replace }; -+} -+function RailButton({ row, selected, meta, onSelect }) { -+ return (0, import_react15.createElement)( -+ "button", -+ { -+ type: "button", -+ className: "bt-board-rail-item", -+ "data-kind": row.kind, -+ "data-id": row.id, -+ "data-selected": selected || void 0, -+ "aria-current": selected ? "true" : void 0, -+ style: row.depth ? { paddingLeft: `${10 + row.depth * 14}px` } : void 0, -+ onClick: () => onSelect(row.kind === "group" ? { kind: "group", roomId: row.id } : { kind: "bot", botId: row.id }) -+ }, -+ (0, import_react15.createElement)("span", { className: "bt-board-rail-name" }, row.name), -+ meta ? (0, import_react15.createElement)("span", { className: "bt-board-rail-meta" }, meta) : null -+ ); -+} -+function BoardCard2({ task, roster, movable, onMove, onDrag, partOf: partOf2 }) { -+ const column = columnOf(task); -+ const priority = priorityOf(task); -+ const progress = progressOf(task); -+ const who = botName(roster, task); -+ const question = task.waitingFor === "input" ? task.inputRequest?.question : ""; -+ const overall = task.kind === "group"; -+ const finished = column === "done" ? finishedAt(task) : void 0; -+ return (0, import_react15.createElement)(BoardCard, { -+ card: taskCard(task), -+ skin: TASK_SKIN, -+ attrs: { -+ "data-task": task.id, -+ "data-column": column, -+ "data-priority": priority, -+ "data-kind": overall ? "group" : void 0 -+ }, -+ idText: shortId(task.id), -+ emptyTitle: t("Untitled task"), -+ topExtra: [ -+ (0, import_react15.createElement)("span", { key: "priority", className: "bt-board-priority", "data-priority": priority }, priorityLabel(priority)), -+ (0, import_react15.createElement)("span", { key: "status", className: "bt-board-status" }, statusLabel(task.status)) -+ ], -+ note: task.note ?? "", -+ meta: [ -+ overall ? (0, import_react15.createElement)("span", { key: "kind", className: "bt-board-kind" }, t("Group task")) : null, -+ partOf2 ? (0, import_react15.createElement)("span", { key: "part", className: "bt-board-part" }, t("Part of {title}", { title: partOf2 })) : null, -+ question ? (0, import_react15.createElement)("span", { key: "wait", className: "bt-board-wait" }, question) : null -+ ], -+ footer: (0, import_react15.createElement)( -+ "span", -+ { className: "bt-board-foot" }, -+ who ? (0, import_react15.createElement)("span", { className: "bt-board-who" }, who) : null, -+ Number.isFinite(finished) && finished > 0 ? (0, import_react15.createElement)("span", { key: "finished", className: "bt-board-when" }, relativeShort(finished)) : null, -+ (0, import_react15.createElement)( -+ "span", -+ { className: "bt-board-progress" }, -+ (0, import_react15.createElement)( -+ "span", -+ { className: "bt-board-progress-bar", role: "progressbar", "aria-valuemin": 0, "aria-valuemax": 100, "aria-valuenow": progress, "aria-label": t("Progress") }, -+ (0, import_react15.createElement)("span", { style: { width: `${progress}%` } }) -+ ), -+ (0, import_react15.createElement)("span", { className: "bt-board-percent" }, t("{percent}%", { percent: progress })) -+ ) -+ ), -+ actions: [ -+ movable && column === "not-started" ? (0, import_react15.createElement)("button", { key: "start", type: "button", className: "bt-soft bt-soft-sm", onClick: () => onMove?.(task.id, "in-progress") }, t("Start")) : null, -+ movable && column === "in-progress" && task.status !== "blocked" ? (0, import_react15.createElement)("button", { key: "complete", type: "button", className: "bt-soft bt-soft-sm", onClick: () => onMove?.(task.id, "done") }, t("Complete")) : null -+ ], -+ draggable: movable, -+ dragValue: task.id, -+ onDragStart: () => onDrag?.(task.id), -+ onDragEnd: () => onDrag?.(null) -+ }); -+} -+function cardProps(task, roster, onMove, onDrag, partOf2, locked) { -+ return { task, roster, movable: !locked && canMoveTask(roster, task), onMove, onDrag, partOf: partOf2 }; -+} -+function BoardColumn({ column, tasks, roster, lanes, dragging, onMove, onDrag, archiveFull, locked }) { -+ const tree = lanes ? columnTree(tasks) : null; -+ const linked = tree ? tree.loose.filter((task) => task.parentId || task.parentTitle) : []; -+ const plain = tree ? tree.loose.filter((task) => !task.parentId && !task.parentTitle) : tasks; -+ return (0, import_react15.createElement)( -+ "section", -+ { -+ className: "bt-board-column", -+ "data-column": column, -+ "aria-label": columnTitle(column), -+ onDragOver: (event) => { -+ if (locked || !dragging) return; -+ event.preventDefault(); -+ }, -+ onDrop: (event) => { -+ if (locked) return; -+ event.preventDefault(); -+ const id = event.dataTransfer?.getData("text/plain"); -+ if (id) onMove?.(id, column); -+ onDrag?.(null); -+ } -+ }, -+ (0, import_react15.createElement)( -+ "h3", -+ { className: "bt-board-column-head" }, -+ (0, import_react15.createElement)("span", null, columnTitle(column)), -+ // #2045: the Done column is one archive page. `tasks.length` is what was drawn, not what the -+ // owner has, so a full page says `20 newest` instead of claiming the page is the total. -+ (0, import_react15.createElement)("span", { className: "bt-board-count" }, column === "done" && archiveFull ? t("{count} newest", { count: tasks.length }) : String(tasks.length)) -+ ), -+ dragging ? (0, import_react15.createElement)("div", { className: "bt-board-drop" }, dropLabel(column)) : null, -+ tasks.length === 0 && !dragging ? (0, import_react15.createElement)("p", { className: "bt-board-empty" }, t("Nothing in this column.")) : null, -+ tree ? tree.nests.map((nest) => (0, import_react15.createElement)( - "div", -- { ref: pop, className: "bt-react-strip", role: "menu", "aria-label": t("Reactions"), style: place }, -- REACTIONS.map((emoji) => (0, import_react6.createElement)("button", { key: emoji, type: "button", role: "menuitem", "aria-label": emoji, onClick: () => { -- close(); -- react(emoji); -- } }, emoji)) -- ), document.body) : null, -- panel === "more" ? (0, import_react_dom.createPortal)((0, import_react6.createElement)( -+ { key: nest.task.id, className: "bt-board-nest" }, -+ (0, import_react15.createElement)(BoardCard2, cardProps(nest.task, roster, onMove, onDrag, void 0, locked)), -+ nest.children.map((child) => (0, import_react15.createElement)( -+ "div", -+ { key: child.id, className: "bt-board-subtask", "data-parent": nest.task.id }, -+ (0, import_react15.createElement)(BoardCard2, cardProps(child, roster, onMove, onDrag, void 0, locked)) -+ )) -+ )) : null, -+ linked.map((task) => (0, import_react15.createElement)(BoardCard2, { key: task.id, ...cardProps(task, roster, onMove, onDrag, task.parentTitle || "", locked) })), -+ lanes ? lanesOf(plain, roster).map((lane) => (0, import_react15.createElement)( - "div", -- { ref: pop, className: "bt-menu", role: "menu", "aria-label": t("Message actions"), style: place }, -- item(t("Copy"), CopyIcon, copy2), -- item(t("Download"), DownloadIcon, download2) -- ), document.body) : null -+ { key: lane.id, className: "bt-board-lane" }, -+ (0, import_react15.createElement)("span", { className: "bt-board-lane-name" }, lane.name), -+ lane.tasks.map((task) => (0, import_react15.createElement)(BoardCard2, { key: task.id, ...cardProps(task, roster, onMove, onDrag, void 0, locked) })) -+ )) : plain.map((task) => (0, import_react15.createElement)(BoardCard2, { key: task.id, ...cardProps(task, roster, onMove, onDrag, void 0, locked) })) - ); - } --function ThinkingRow({ text }) { -- return (0, import_react6.createElement)( -- "details", -- { className: "bt-think" }, -- (0, import_react6.createElement)("summary", null, t("Thought it through")), -- (0, import_react6.createElement)("div", { className: "bt-think-text" }, text) -+function BoardView({ roster, tasks, rail, scope, counts: counts2, dragging, error, draft, onDraft, onSelect, onMove, onDrag, onCreate, archiveFull, locked }) { -+ const columns = boardColumns(tasks); -+ const lanes = scope?.kind === "group"; -+ const selectedBot = scope?.kind === "bot" ? scope.botId : ""; -+ const selectedRoom = scope?.kind === "group" ? scope.roomId : ""; -+ return (0, import_react15.createElement)( ++ }, [done, sessionId, callId]); ++ let replies = live.replies; ++ if (done) { ++ try { ++ replies = JSON.parse(contentText(content)).replies ?? []; ++ } catch { ++ replies = []; ++ } + } +- const seats = clusterSeats(Math.min(people.length, 4), size); +- const overflow = people.length > 4 ? people.length - 3 : 0; +- const gap = size / 14; +- const rings = seats.map(([x, y, s]) => ({ cx: x + s / 2, cy: y + s / 2, r: s * 0.48 + gap })); +- return (0, import_react2.createElement)( +- "span", +- { className: "bt-cluster", style: { width: size, height: size } }, +- seats.map(([x, y, s], index) => { +- const holes2 = rings.slice(index + 1).filter((ring) => Math.hypot(ring.cx - x - s / 2, ring.cy - y - s / 2) < ring.r + s * 0.46).map((ring) => `radial-gradient(circle at ${ring.cx - x}px ${ring.cy - y}px,transparent ${ring.r}px,#000 ${ring.r + 0.5}px)`); +- const style = { left: x, top: y, width: s, height: s }; +- if (holes2.length > 0) Object.assign(style, { WebkitMaskImage: holes2.join(","), maskImage: holes2.join(","), WebkitMaskComposite: "source-in", maskComposite: "intersect" }); +- if (overflow > 0 && index === 3) { +- return (0, import_react2.createElement)("span", { key: "more", className: "bt-cluster-more", style: { ...style, width: s * 0.92, height: s * 0.92, margin: s * 0.04, fontSize: Math.max(8, Math.round(s * 0.36)) } }, `+${overflow}`); ++ if (replies.length === 0) return null; ++ const room2 = roster.roomsById[sessionId]; ++ return (0, import_react10.createElement)( + "div", -+ { className: "bt-board-body" }, -+ (0, import_react15.createElement)( -+ "aside", -+ { className: "bt-board-rail", "aria-label": t("Task board") }, -+ (0, import_react15.createElement)("h3", { className: "bt-board-rail-head" }, t("Your Bots")), -+ rail.bots.length === 0 ? (0, import_react15.createElement)("p", { className: "bt-board-empty" }, t("No tasks yet.")) : rail.bots.map((row) => { -+ const count = counts2?.[row.id] ?? { done: 0, total: 0 }; -+ const capped = archiveFull && (scope?.kind === "group" ? count.done > 0 : count.done >= ARCHIVE_SHOWN); -+ const meta = count.total === 0 ? "" : capped ? t("{count}+ done", { count: count.done }) : t("{done}/{total}", { done: count.done, total: count.total }); -+ return (0, import_react15.createElement)(RailButton, { -+ key: row.id, -+ row, -+ selected: row.id === selectedBot, -+ onSelect, -+ meta -+ }); -+ }), -+ rail.groups.length > 0 ? (0, import_react15.createElement)("h3", { className: "bt-board-rail-head" }, t("Groups you admin")) : null, -+ rail.groups.map((row) => (0, import_react15.createElement)(RailButton, { key: row.id, row, selected: row.id === selectedRoom, onSelect })) -+ ), -+ (0, import_react15.createElement)( -+ "div", -+ { className: "bt-board-main" }, -+ error ? (0, import_react15.createElement)("p", { className: "bt-board-error", role: "alert" }, error) : null, -+ lanes ? (0, import_react15.createElement)("p", { className: "bt-board-hint" }, t("Assign, claim, and progress live on this board: one overall task, a subtask per part, an assignee, a column, and progress. @ only wakes someone.")) : null, -+ scope?.kind === "bot" && onCreate && !locked ? (0, import_react15.createElement)( -+ "form", -+ { className: "bt-board-add", onSubmit: (event) => { -+ event.preventDefault(); -+ onCreate(draft); -+ } }, -+ (0, import_react15.createElement)("input", { "aria-label": t("Task title"), placeholder: t("New task"), value: draft ?? "", onChange: (event) => onDraft?.(event.target.value) }), -+ (0, import_react15.createElement)("button", { type: "submit", className: "bt-soft bt-soft-sm" }, t("Add task")) -+ ) : null, -+ (0, import_react15.createElement)( -+ "div", -+ { className: "bt-board-columns" }, -+ ["not-started", "in-progress", "done"].map((column) => (0, import_react15.createElement)(BoardColumn, { -+ key: column, -+ column, -+ tasks: columns[column], ++ { className: "bt-group-replies" }, ++ replies.map((reply, index) => { ++ const stored = roster.byId[reply.botId]; ++ const resolved = reply.remote ? stored : rosterBot(roster, reply.botId) ?? stored; ++ const bot = resolved ?? { id: reply.botId, name: reply.name, color: reply.color ?? "gray" }; ++ if (reply.kind === "user") { ++ const who = reply.name && reply.name !== "User" ? reply.name : reply.authorId; ++ if (reply.other) { ++ return (0, import_react10.createElement)(PersonMessage, { key: `user-${index}`, name: who, texts: [remoteMessageText(reply.text)], roster, selfId: sessionId, actions, sessionId, msgKey: callId ? `${sessionId}:${callId}:${index}` : void 0, useUi }); ++ } ++ return (0, import_react10.createElement)( ++ "div", ++ { key: `user-${index}`, className: "bt-urow", "data-author": reply.authorId, "data-author-name": who }, ++ who ? (0, import_react10.createElement)("span", { className: "bt-uauthor" }, who) : null, ++ (0, import_react10.createElement)( ++ "div", ++ { className: "bt-ububble" }, ++ (0, import_react10.createElement)(import_dsh_client_ui_primitives.MarkdownText, { text: remoteMessageText(reply.text), labels: mdLabels(), variant: "compact" }) ++ ), ++ room2?.origin === "face-to-face" ? (0, import_react10.createElement)("span", { className: "bt-lead", title: who || void 0 }, (0, import_react10.createElement)(PersonMark, { name: who || void 0 })) : null ++ ); ++ } ++ if (reply.kind === "failed") { ++ return (0, import_react10.createElement)( ++ "div", ++ { key: `${reply.botId}-${index}`, className: "bt-event bt-event-error", role: "status" }, ++ t("{name} could not answer: {reason}", { name: bot.name, reason: failureLabel(reply.code, reply.text) }) ++ ); ++ } ++ if (reply.kind === "lookup") { ++ return (0, import_react10.createElement)( ++ "div", ++ { key: `${reply.botId}-${index}`, className: "bt-event" }, ++ reply.text ? t("{name} looked up its own chat for “{text}”", { name: bot.name, text: reply.text }) : t("{name} looked up its own chat", { name: bot.name }) ++ ); ++ } ++ if (reply.kind === "missed") { ++ return (0, import_react10.createElement)("div", { key: `${reply.botId}-${index}`, className: "bt-event" }, t("{name} didn't answer in time", { name: bot.name })); ++ } ++ if (reply.kind === "resumed") { ++ return (0, import_react10.createElement)("div", { key: `${reply.botId}-${index}`, className: "bt-event" }, t("{name} was cut off by a restart and picked its work back up", { name: bot.name })); ++ } ++ if (reply.kind === "cut") { ++ return (0, import_react10.createElement)("div", { key: `${reply.botId}-${index}`, className: "bt-event" }, t("{name} was cut off by a restart again and stopped", { name: bot.name })); ++ } ++ if (reply.kind === "approval" || reply.kind === "working") { ++ return (0, import_react10.createElement)( ++ "div", ++ { key: `${reply.botId}-${index}`, className: "bt-event" }, ++ (0, import_react10.createElement)( ++ "button", ++ { type: "button", className: "bt-event-whole", onClick: () => actions.showDetails(sessionId) }, ++ (0, import_react10.createElement)(BotMark, { bot, size: 16 }), ++ (0, import_react10.createElement)("span", { className: "bt-event-name" }, t(reply.kind === "working" ? "{name} started on it" : "{name} paused for your approval", { name: bot.name })) ++ ) ++ ); ++ } ++ if (reply.kind === "stopped") { ++ return (0, import_react10.createElement)("div", { key: `${reply.botId}-${index}`, className: "bt-event" }, t("You stopped {name}'s work", { name: bot.name })); ++ } ++ if (reply.kind === "notice" && room2?.origin === "face-to-face" && String(reply.text ?? "").trim() === WAITING_OTHERS_NOTICE) return null; ++ if ((reply.kind === "notice" || reply.kind === "left") && leaveNoticeCoveredByClose(room2, reply.text)) return null; ++ if (reply.kind === "notice" || reply.kind === "joined" || reply.kind === "left" || reply.kind === "started") { ++ return (0, import_react10.createElement)("div", { key: `notice-${index}`, className: "bt-event", "data-flybot": "face-notice" }, remoteMessageText(reply.text)); ++ } ++ const remote = reply.remote || reply.handle && !roster.byId[reply.botId]; ++ if (remote) { ++ return (0, import_react10.createElement)(RemoteMessage, { ++ key: `${reply.botId || reply.handle}-${index}`, ++ source: { remote: { handle: reply.handle || reply.botId }, senderName: reply.name || reply.who, senderSessionId: reply.botId }, ++ text: remoteMessageText(reply.text), + roster, -+ lanes, -+ dragging, -+ onMove, -+ onDrag, -+ archiveFull, -+ locked -+ })) -+ ) -+ ) ++ actions, ++ sessionId, ++ msgKey: callId ? `${sessionId}:${callId}:${index}` : void 0, ++ useUi ++ }); + } +- const bot = people[index]; +- return (0, import_react2.createElement)("span", { key: bot?.id ?? "you", style }, seat(bot, s)); ++ return (0, import_react10.createElement)(GroupMessage, { key: `${reply.botId}-${index}`, bot, text: reply.text, room: room2, roster, actions, msgKey: callId ? `${sessionId}:${callId}:${index}` : void 0, useUi, seq: reply.seq }); + }) ); } --function useFileMentions({ node, openFile, fileMentions, useTurnData }) { -- const tail = useTurnData?.("turn-tail"); -- const location2 = node.location; -- const turn = location2?.kind === "turn" || location2?.kind === "step" ? location2.turn : void 0; -- const seq = node.data.finalNode?.seq; -- return (0, import_react6.useMemo)(() => { -- if (typeof fileMentions !== "function" || turn?.status !== "closed" || seq === void 0) return void 0; -- return tail?.closing?.finalNode.seq === seq ? fileMentions({ turn, seq, openFile }) : void 0; -- }, [fileMentions, openFile, seq, tail, turn]); --} --function AssistantCell({ node, groupPart, sessionId, openFile, fileMentions, useTurnData, useRoster, useUi, useTurnSteps, useTurnContext, actions }) { -- const roster = useRoster((value) => value); -- const data = node.data; -- const steps = useTurnSteps(String(data.turn)); -- const contexts = useTurnContext(String(data.turn)); -- const mentions = useFileMentions({ node, openFile, fileMentions, useTurnData }); -- const hidden = Boolean(roster.roomsById[sessionId]) || isHiddenTurn(roster, sessionId, data.turn); -- const texts2 = groupPart === "reasoning" ? [] : data.blocks.filter((block) => block.kind === "text" && block.text.trim() !== "").map((block) => block.text); -- const running = data.status === "running"; -- useLiveNote(actions, sessionId, `text:${node.key ?? data.seq}`, !hidden && running && texts2.length > 0 ? { text: true } : null); -- if (hidden) return null; -- const reasoning = groupPart === "response" ? "" : turnReasoning(steps, data); -- const relays = relayPart(data, groupPart) ? relaysBefore(contexts, steps, data) : []; -- if (texts2.length === 0 && !reasoning && relays.length === 0) return null; -- const selfId = roster.byId[sessionId]?.id ?? sessionId; -- return (0, import_react6.createElement)( -- import_react6.Fragment, -- null, -- relays.map((relay) => { -- const source = relay.source; -- const sender = roster.byId[source.senderSessionId] ?? { id: source.senderSessionId, name: source.senderName ?? t("a Bot"), color: "gray" }; -- return (0, import_react6.createElement)(EventLine, { -- key: relay.seq, -- prefix: source.role === "stopped" ? t("Stopped by") : source.role === "reply" ? t("Reply from") : t("Message from"), -- bots: [sender], -- onOpen: () => { -- if (sender.id) actions.openExchange(selfId, sender.id); -- }, -- whole: true -- }); -- }), -- reasoning ? (0, import_react6.createElement)(ThinkingRow, { text: reasoning }) : null, -- texts2.length ? (0, import_react6.createElement)(Bubbles, { texts: texts2, roster, selfId, actions, streaming: running, sessionId, msgKey: `${sessionId}:${data.turn}:${node.key ?? data.seq ?? ""}`, useUi, mentions }) : null -+function KanbanRow({ wide, useUi, actions }) { -+ const open = useUi ? useUi((value) => Boolean(value.board)) : false; -+ const label = t("Tasks"); -+ return (0, import_react15.createElement)( -+ "button", -+ { -+ type: "button", -+ className: "bt-row bt-board-row", -+ "aria-label": label, -+ title: wide ? void 0 : label, -+ "aria-haspopup": "dialog", -+ "aria-expanded": open, -+ onClick: () => actions.openBoard() -+ }, -+ (0, import_react15.createElement)("span", { className: "bt-board-mark" }, (0, import_react15.createElement)(BoardIcon)), -+ wide ? (0, import_react15.createElement)("span", { className: "bt-board-label" }, label) : null - ); +- +-// src/client/icons.js +-var import_react3 = require("react"); +-function PlusIcon() { +- return (0, import_react3.createElement)("svg", { width: 18, height: 18, viewBox: "0 0 20 20", "aria-hidden": true }, (0, import_react3.createElement)("path", { d: "M10 3.5v13M3.5 10h13", stroke: "currentColor", strokeWidth: 1.6, strokeLinecap: "round" })); ++var DRAWN = /* @__PURE__ */ new Set(["message_bot", "stop_bot", "create_bot", "update_bot", "group_relay", "create_group", "update_group", "delete_group", "post_to_group", "ask_user", "request_secret", "ask_user_question", "read_group_chat", "remember", "forget"]); ++var CARDS = /* @__PURE__ */ new Set(["ask_user", "request_secret"]); ++function callTree(root) { ++ const calls = []; ++ const seen = /* @__PURE__ */ new Set(); ++ const visit = (call4) => { ++ const id = call4.callId ?? call4; ++ if (seen.has(id)) return; ++ seen.add(id); ++ calls.push(call4); ++ for (const child of call4.subCalls ?? []) visit(child); ++ }; ++ visit(root); ++ return calls; } --function EventLine({ prefix, bots, joiner = t("and"), onOpen, whole = false }) { -- if (whole && bots.length === 1) { -- return (0, import_react6.createElement)( -+function scopeName(roster, scope) { -+ if (scope?.kind === "group") return roster.roomsById?.[scope.roomId]?.name || t("Group"); -+ if (scope?.kind === "bot") return roster.byId?.[scope.botId]?.name || ""; -+ return ""; +-function SearchIcon() { +- return (0, import_react3.createElement)("svg", { width: 16, height: 16, viewBox: "0 0 20 20", "aria-hidden": true }, (0, import_react3.createElement)("circle", { cx: 8.5, cy: 8.5, r: 5.5, stroke: "currentColor", strokeWidth: 1.6, fill: "none" }), (0, import_react3.createElement)("path", { d: "M13 13l4 4", stroke: "currentColor", strokeWidth: 1.6, strokeLinecap: "round" })); ++function useShownCards(actions, sessionId, callIds) { ++ const key = JSON.stringify(callIds); ++ (0, import_react10.useLayoutEffect)(() => { ++ const ids = JSON.parse(key); ++ if (ids.length === 0 || !actions.noteCards) return void 0; ++ actions.noteCards(sessionId, ids, 1); ++ return () => actions.noteCards(sessionId, ids, -1); ++ }, [sessionId, key]); +} -+function KanbanDialog({ roster, actions, leaving = false }) { -+ const locked = typeof roster?.readOnly === "string" && roster.readOnly.trim() !== ""; -+ const rail = boardRail(roster); -+ const [scope, setScope] = (0, import_react15.useState)(() => rail.bots[0] ? { kind: "bot", botId: rail.bots[0].id } : rail.groups[0] ? { kind: "group", roomId: rail.groups[0].id } : null); -+ const [dragging, setDragging] = (0, import_react15.useState)(null); -+ const [error, setError] = (0, import_react15.useState)(""); -+ const [draft, setDraft] = (0, import_react15.useState)(""); -+ const { own, groupTasks, tasks, archive, replace } = useBoardTasks(actions, scope); -+ const boardTasks = boardTasksFor(tasks, archive); -+ const close = (0, import_react15.useCallback)(() => actions.closeBoard(), []); -+ useEscape(close); -+ (0, import_react15.useEffect)(() => { -+ if (typeof window === "undefined") return; -+ if (!window.location.hash.startsWith("#task=")) return; -+ const [kind, id] = window.location.hash.slice("#task=".length).split(":"); -+ window.location.hash = ""; -+ if (id === void 0 || id === "") return; -+ if (kind === "bot" && roster.byId?.[id]) setScope({ kind: "bot", botId: id }); -+ if (kind === "group" && roster.roomsById?.[id]) setScope({ kind: "group", roomId: id }); -+ }, []); -+ const archiveFull = Array.isArray(archive) && archive.length >= ARCHIVE_SHOWN; -+ const counts2 = {}; -+ for (const task of [...scope?.kind === "group" ? groupTasks : own, ...(archive ?? []).slice(0, ARCHIVE_SHOWN)]) { -+ const id = task.ownerBotId; -+ const bucket = counts2[id] ?? (counts2[id] = { done: 0, total: 0 }); -+ if (task.status === "cancelled" || task.status === "failed") continue; -+ bucket.total += 1; -+ if (columnOf(task) === "done") bucket.done += 1; -+ } -+ const move = async (id, column) => { -+ if (locked) return; -+ const task = tasks.find((item) => item.id === id) ?? own.find((item) => item.id === id); -+ if (!task || !canMoveTask(roster, task) || columnOf(task) === column) return; -+ setError(""); -+ try { -+ replace(await actions.taskUpdate({ id, column })); -+ } catch (failure) { -+ setError(failure?.message ?? String(failure)); ++function renderCall(call4, { sessionId, roster, actions, useUi, findBot }) { ++ const tool = toolName(call4); ++ const done = call4.kind === "tool-result"; ++ const { text: arg, value: argValue } = toolArgs(call4); ++ const self = roster.byId[sessionId]; ++ switch (tool) { ++ case "message_bot": { ++ const target = findBot(arg("to")) ?? { name: arg("to") ?? "…", color: "gray" }; ++ if (done && call4.isError) return (0, import_react10.createElement)("div", { className: "bt-event" }, t("Couldn't message {name}", { name: target.name })); ++ return (0, import_react10.createElement)(EventLine, { prefix: t("Messaged"), bots: [target], onOpen: (bot) => bot.id && actions.openExchange(self?.id ?? sessionId, bot.id), whole: true }); ++ } ++ case "stop_bot": { ++ const target = findBot(arg("bot")) ?? { name: arg("bot") ?? "…", color: "gray" }; ++ if (done && call4.isError) return (0, import_react10.createElement)("div", { className: "bt-event" }, t("Couldn't stop {name}", { name: target.name })); ++ return (0, import_react10.createElement)(EventLine, { prefix: t("Stopped"), bots: [target], onOpen: (bot) => bot.id && actions.openExchange(self?.id ?? sessionId, bot.id), whole: true }); ++ } ++ case "create_bot": { ++ const created = findBot(arg("name")); ++ if (!done || created === void 0) return null; ++ return (0, import_react10.createElement)(EventLine, { prefix: t("Created"), bots: [created], onOpen: (bot) => actions.openSession(bot.id) }); ++ } ++ case "update_bot": { ++ const target = findBot(arg("name")) ?? findBot(arg("bot")) ?? (arg("bot") ? void 0 : self); ++ return done && target ? (0, import_react10.createElement)(EventLine, { prefix: t("Updated"), bots: [target], onOpen: (bot) => actions.openSession(bot.id) }) : null; ++ } ++ case "group_relay": ++ return (0, import_react10.createElement)(GroupReplies, { sessionId, callId: call4.callId, done, content: call4.content, roster, actions, useUi }); ++ case "create_group": ++ case "update_group": ++ case "delete_group": ++ case "post_to_group": { ++ const verbs = { ++ create_group: ["Created group", "Couldn't create the group"], ++ update_group: ["Updated group", "Couldn't update the group"], ++ delete_group: ["Deleted group", "Couldn't delete the group"], ++ post_to_group: ["Posted in", "Couldn't post in the group"] ++ }[tool]; ++ if (!done) return null; ++ const result = contentText(call4.content); ++ const named = /^(Created group|Updated|Nothing changed in|Deleted|Posted in) "([^"]+)"/.exec(result); ++ if (call4.isError || !named) return (0, import_react10.createElement)("div", { className: "bt-event", title: result }, t(verbs[1])); ++ const name = named[2]; ++ const room2 = roster.rooms.find((entry) => entry.name === name); ++ return (0, import_react10.createElement)(GroupEvent, { prefix: t(verbs[0]), room: tool === "delete_group" ? void 0 : room2, name, roster, actions }); + } -+ }; -+ const create = async (title) => { -+ if (locked) return; -+ const text2 = String(title ?? "").trim(); -+ if (!text2 || scope?.kind !== "bot") return; -+ setError(""); -+ try { -+ replace(await actions.taskCreate({ botId: scope.botId, title: text2, column: "not-started" })); -+ setDraft(""); -+ } catch (failure) { -+ setError(failure?.message ?? String(failure)); ++ case "ask_user": { ++ const question = roster.questions?.[sessionId]; ++ if (!question || question.callId !== call4.callId) return (0, import_react10.createElement)("span", { className: "bt-q-gone", hidden: true }); ++ return (0, import_react10.createElement)(PendingQuestion, { sessionId, question, actions, roster, inline: true }); + } -+ }; -+ const name = scopeName(roster, scope); -+ return (0, import_react15.createElement)( -+ "div", -+ { className: "bt-mem-layer bt-board-layer", "data-leaving": leaving || void 0 }, -+ (0, import_react15.createElement)("div", { className: "bt-scrim", onClick: close }), -+ (0, import_react15.createElement)( - "div", -- { className: "bt-event" }, -- (0, import_react6.createElement)( -- "button", -- { type: "button", className: "bt-event-whole", title: bots[0].name, onClick: () => onOpen?.(bots[0]) }, -- (0, import_react6.createElement)("span", null, prefix), -- (0, import_react6.createElement)(BotMark, { bot: bots[0], size: 16 }), -- (0, import_react6.createElement)("span", { className: "bt-event-name" }, bots[0].name) -- ) -- ); -+ { className: "bt-mem bt-board", role: "dialog", "aria-modal": true, "aria-labelledby": "bt-board-heading" }, -+ (0, import_react15.createElement)( -+ "div", -+ { className: "bt-mem-head" }, -+ (0, import_react15.createElement)("h2", { id: "bt-board-heading" }, t("Board")), -+ (0, import_react15.createElement)("span", { className: "bt-mem-status" }, name), -+ // #1854: the left sidebar's Projects row is the one entry to a Project board; this header -+ // used to draw a second door (#1701), so the sidebar row was the only way in for the rest. -+ (0, import_react15.createElement)("span", { className: "bt-mem-head-gap" }), -+ (0, import_react15.createElement)("button", { type: "button", className: "bt-icon-btn", "aria-label": t("Close board"), autoFocus: true, onClick: close }, (0, import_react15.createElement)(CloseIcon)) -+ ), -+ (0, import_react15.createElement)(BoardView, { -+ roster, -+ tasks: boardTasks, -+ rail, -+ scope, -+ counts: counts2, -+ dragging, -+ error, -+ draft, -+ archiveFull, -+ locked, -+ onDraft: setDraft, -+ onSelect: setScope, -+ onMove: move, -+ onDrag: setDragging, -+ onCreate: create -+ }) -+ ) -+ ); -+} -+var KANBAN_CSS = ` -+.bt-board-row{min-height:40px;padding:6px 8px;gap:10px} -+.bt-board-mark{flex:none;width:36px;height:28px;display:inline-flex;align-items:center;justify-content:center;color:var(--bt-ink-2)} -+.bt-board-mark svg{width:18px;height:18px} -+.bt-board-label{flex:1 1 auto;min-width:0;font-size:14px;line-height:20px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis} -+.bt-mem.bt-board{width:min(1080px,calc(100vw - 32px));height:min(760px,calc(100vh - 48px))} -+.bt-board-body{flex:1 1 auto;min-height:0;display:flex;gap:0} -+.bt-board-rail{flex:none;width:232px;overflow:auto;border-right:.5px solid var(--bt-line);padding:8px 8px 16px;display:flex;flex-direction:column;gap:2px} -+.bt-board-rail-head{margin:10px 8px 4px;font-size:12px;line-height:16px;font-weight:600;color:var(--bt-ink-3)} -+.bt-board-rail-item{display:flex;align-items:center;gap:8px;width:100%;min-height:36px;padding:6px 10px;border:0;border-radius:10px;background:transparent;color:var(--bt-ink);text-align:left;cursor:pointer} -+.bt-board-rail-item:hover{background:var(--bt-hover)} -+.bt-board-rail-item[data-selected]{background:var(--bt-active,var(--bt-hover))} -+.bt-board-rail-name{flex:1 1 auto;min-width:0;font-size:13.5px;line-height:18px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis} -+.bt-board-rail-meta{flex:none;font-size:12px;line-height:16px;color:var(--bt-ink-3);font-variant-numeric:tabular-nums} -+.bt-board-main{flex:1 1 auto;min-width:0;display:flex;flex-direction:column;gap:10px;padding:12px 14px 16px} -+.bt-board-add{display:flex;gap:8px} -+.bt-board-add input{flex:1 1 auto;min-width:0;height:32px;padding:0 10px;border:.5px solid var(--bt-line-2);border-radius:10px;background:var(--bt-card,var(--bt-main));color:var(--bt-ink);font:inherit} -+.bt-board-columns{flex:1 1 auto;min-height:0;display:flex;gap:10px} -+.bt-board-column{flex:1 1 0;min-width:0;display:flex;flex-direction:column;gap:8px;padding:8px;border-radius:14px;background:var(--bt-hover);overflow:auto} -+.bt-board-column-head{display:flex;align-items:center;gap:6px;margin:0;padding:2px 4px;font-size:13px;line-height:18px;font-weight:600;color:var(--bt-ink-2)} -+.bt-board-count{font-weight:500;color:var(--bt-ink-3);font-variant-numeric:tabular-nums} -+.bt-board-drop{margin:0;padding:18px 8px;border:1px dashed var(--bt-line-2);border-radius:12px;text-align:center;font-size:13px;line-height:18px;color:var(--bt-ink-3)} -+.bt-board-empty{margin:0;padding:8px 4px;font-size:13px;line-height:18px;color:var(--bt-ink-3)} -+.bt-board-hint{margin:0;font-size:13px;line-height:18px;color:var(--bt-ink-2)} -+.bt-board-error{margin:0;font-size:13px;line-height:18px;color:#c21d2e} -+.bt-board-lane{display:flex;flex-direction:column;gap:6px} -+.bt-board-lane-name{font-size:12px;line-height:16px;font-weight:600;color:var(--bt-ink-3);padding:0 2px} -+.bt-board-nest{display:flex;flex-direction:column;gap:6px} -+.bt-board-subtask{margin-left:12px;padding-left:8px;border-left:2px solid var(--bt-line)} -+.bt-board-kind{font-size:11px;line-height:16px;font-weight:600;color:var(--bt-ink-2)} -+.bt-board-part{font-size:12px;line-height:16px;color:var(--bt-ink-3)} -+.bt-board-wait{font-size:12px;line-height:16px;color:var(--bt-ink-2)} -+.bt-board-card{display:flex;flex-direction:column;gap:6px;padding:10px;border:.5px solid var(--bt-line-2);border-radius:12px;background:var(--bt-card,var(--bt-main));cursor:grab} -+.bt-board-card-top{display:flex;align-items:center;gap:6px;min-width:0} -+.bt-board-id{flex:none;font-size:12px;line-height:16px;color:var(--bt-ink-3);font-variant-numeric:tabular-nums} -+.bt-board-priority{flex:none;height:18px;padding:0 6px;border-radius:999px;font-size:11px;line-height:18px;font-weight:600} -+.bt-board-priority[data-priority=urgent]{background:rgba(224,33,53,.12);color:#c21d2e} -+.bt-board-priority[data-priority=high]{background:rgba(217,119,6,.14);color:#b45309} -+.bt-board-priority[data-priority=normal]{background:color-mix(in srgb,var(--bt-accent) 14%,transparent);color:color-mix(in srgb,var(--bt-accent) 80%,var(--bt-ink))} -+.bt-board-priority[data-priority=low]{background:color-mix(in srgb,var(--bt-green) 16%,transparent);color:color-mix(in srgb,var(--bt-green) 70%,var(--bt-ink))} -+.bt-board-status{margin-left:auto;font-size:11.5px;line-height:16px;color:var(--bt-ink-3);white-space:nowrap} -+.bt-board-title{font-size:13.5px;line-height:19px;color:var(--bt-ink)} -+.bt-board-note{font-size:12px;line-height:16px;color:var(--bt-ink-2)} -+.bt-board-foot{display:flex;align-items:center;gap:8px} -+.bt-board-who{flex:none;max-width:40%;font-size:12px;line-height:16px;color:var(--bt-ink-2);white-space:nowrap;overflow:hidden;text-overflow:ellipsis} -+.bt-board-when{flex:none;font-size:12px;line-height:16px;color:var(--bt-ink-3);white-space:nowrap} -+.bt-board-progress{flex:1 1 auto;min-width:0;display:flex;align-items:center;gap:6px} -+.bt-board-progress-bar{flex:1 1 auto;height:4px;border-radius:999px;background:var(--bt-line);overflow:hidden} -+.bt-board-progress-bar>span{display:block;height:100%;background:var(--bt-accent)} -+.bt-board-percent{flex:none;font-size:11.5px;line-height:16px;color:var(--bt-ink-3);font-variant-numeric:tabular-nums} -+.bt-board-card .bt-soft{align-self:flex-start} -+@media (max-width:800px){ -+ .bt-mem.bt-board{width:min(100vw,calc(100vw - 16px));height:100%} -+ .bt-board-body{flex-direction:column} -+ .bt-board-rail{width:auto;max-height:180px;border-right:0;border-bottom:.5px solid var(--bt-line)} -+ .bt-board-columns{overflow:auto} -+ .bt-board-column{min-width:220px} -+} -+@media (max-width:640px){ -+ .bt-mem.bt-board{width:100vw;border-radius:0;border:0} -+ .bt-board-columns{flex-direction:column} -+ .bt-board-column{min-width:0} -+} -+`; -+ -+// src/client/role-labels.js -+var ROLE_LABELS = { owner: "Owner", maintainer: "Maintainer", member: "Member" }; -+function roleText(role) { -+ if (role === null || role === void 0 || role === "") return ""; -+ return t(ROLE_LABELS[role] ?? String(role)); -+} ++ case "request_secret": ++ return (0, import_react10.createElement)(SecretToolCell, { sessionId, callId: call4.callId, name: arg("name"), done, content: call4.content, roster, actions }); ++ case "ask_user_question": { ++ if (!done) return null; ++ let parsed = null; ++ try { ++ parsed = JSON.parse(contentText(call4.content)); ++ } catch { ++ parsed = null; ++ } ++ const answer = parsed?.answers?.[0]; ++ const question = argValue("questions")?.[0]; ++ if (!answer) return null; ++ return (0, import_react10.createElement)("div", { className: "bt-q-inline" }, (0, import_react10.createElement)(SettledQuestion, { question, answer })); ++ } ++ case "read_group_chat": { ++ if (!done) return null; ++ const room2 = roster.rooms.find((entry) => entry.name === arg("group")); ++ return room2 ? (0, import_react10.createElement)(GroupEvent, { prefix: t("Read"), room: room2, roster, actions }) : null; ++ } ++ case "remember": ++ case "forget": ++ return done && !call4.isError ? (0, import_react10.createElement)(MemoryEvent, { sessionId, scope: arg("scope"), result: contentText(call4.content), roster, actions, findBot }) : null; ++ default: ++ return null; ++ } + } +-function PeopleIcon() { +- return (0, import_react3.createElement)( +- "svg", +- { width: 14, height: 14, viewBox: "0 0 20 20", fill: "none", stroke: "currentColor", strokeWidth: 1.5, strokeLinecap: "round", "aria-hidden": true }, +- (0, import_react3.createElement)("circle", { cx: 7.5, cy: 7, r: 3 }), +- (0, import_react3.createElement)("path", { d: "M2 16.5c.6-2.8 2.8-4.5 5.5-4.5s4.9 1.7 5.5 4.5" }), +- (0, import_react3.createElement)("path", { d: "M13 4.3a3 3 0 0 1 0 5.4M15 12.4c1.6.6 2.6 2 3 4.1" }) +- ); ++function ToolCell({ node, sessionId, useRoster, useUi, actions }) { ++ const roster = useRoster((value) => value); ++ const root = node.data.root; ++ const turn = node.location?.turn?.turn ?? root.turn; ++ const hidden = isHiddenTurn(roster, sessionId, turn); ++ const findBot = (nameOrId) => roster.byId[nameOrId] ?? botByName(roster.bots, nameOrId); ++ const calls = hidden ? [] : callTree(root); ++ const drawn = calls.filter((call4) => DRAWN.has(toolName(call4))); ++ const running = root.kind === "tool-result" ? void 0 : calls.findLast((call4) => call4.kind !== "tool-result"); ++ const note = running ? toolActivity(toolName(running), toolArgs(running).text, findBot, roster.byId[sessionId]) : null; ++ useLiveNote(actions, sessionId, `tool:${root.callId ?? node.key}`, note); ++ useShownCards(actions, sessionId, drawn.filter((call4) => CARDS.has(toolName(call4)) && call4.callId).map((call4) => call4.callId)); ++ if (drawn.length === 0) return null; ++ const env = { sessionId, roster, actions, useUi, findBot }; ++ return (0, import_react10.createElement)(import_react10.Fragment, null, drawn.map((call4, index) => (0, import_react10.createElement)(import_react10.Fragment, { key: call4.callId ?? index }, renderCall(call4, env)))); + } +-function ArrowUpIcon() { +- return (0, import_react3.createElement)("svg", { width: 16, height: 16, viewBox: "0 0 20 20", "aria-hidden": true }, (0, import_react3.createElement)("path", { d: "M10 16V4M5 9l5-5 5 5", stroke: "currentColor", strokeWidth: 1.8, fill: "none", strokeLinecap: "round", strokeLinejoin: "round" })); + -+// src/client/projects-nav.js -+var import_react16 = require("react"); -+var import_react_dom3 = require("react-dom"); -+var PROJECTS_NAV_CSS = ` -+.bt-projects-row { display: flex; align-items: center; gap: 8px; } -+.bt-projects-chevron { margin-left: auto; display: inline-flex; transition: transform 120ms ease; } -+.bt-projects-row[aria-expanded="true"] .bt-projects-chevron { transform: rotate(90deg); } -+.bt-projects-list { display: flex; flex-direction: column; margin: 0 0 4px; padding-left: 30px; } -+.bt-project-entry { display: flex; align-items: baseline; gap: 8px; width: 100%; text-align: left; border: 0; background: none; color: inherit; font: inherit; padding: 4px 8px; border-radius: 6px; cursor: pointer; } -+.bt-project-entry:hover, .bt-project-entry:focus-visible { background: var(--bt-hover, rgba(127,127,127,.14)); outline: none; } -+.bt-project-entry[aria-current="true"] { background: var(--bt-hover, rgba(127,127,127,.14)); } -+.bt-project-entry-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } -+.bt-project-entry-role { margin-left: auto; opacity: .62; font-size: .85em; white-space: nowrap; } -+.bt-projects-empty, .bt-projects-note { padding: 2px 8px 6px; opacity: .7; font-size: .85em; } -+.bt-project-menu { position: fixed; z-index: 60; min-width: 168px; padding: 4px; border-radius: 8px; background: var(--bt-menu-bg, #1f2126); border: 1px solid var(--bt-menu-border, rgba(255,255,255,.12)); box-shadow: 0 8px 24px rgba(0,0,0,.32); } -+.bt-project-menu-item { display: flex; align-items: center; gap: 8px; width: 100%; border: 0; background: none; color: inherit; font: inherit; text-align: left; padding: 6px 8px; border-radius: 6px; cursor: pointer; } -+.bt-project-menu-item:hover, .bt-project-menu-item:focus-visible { background: var(--bt-hover, rgba(127,127,127,.14)); outline: none; } -+.bt-project-menu-item[aria-checked="true"]::before { content: "✓"; margin-right: 2px; } -+.bt-project-menu-item:not([aria-checked="true"])::before { content: " "; margin-right: 2px; } -+.bt-project-menu-item[disabled] { opacity: .6; cursor: default; } -+`; -+function projectHash(id) { -+ return `#project=${encodeURIComponent(String(id ?? ""))}`; -+} -+function projectName(project) { -+ const name = typeof project?.name === "string" ? project.name.trim() : ""; -+ return name === "" ? `#${project?.id ?? ""}` : name; -+} -+function projectMenuItems(project) { -+ const current = project?.role ?? null; -+ const choices = Array.isArray(project?.choices) ? project.choices : []; -+ const items = current === null ? [] : [{ role: current, label: roleText(current), current: true, pick: false }]; -+ for (const role of choices) { -+ if (role === current) continue; -+ items.push({ role, label: roleText(role), current: false, pick: true }); - } -- const parts = []; -- parts.push((0, import_react6.createElement)("span", { key: "p" }, prefix)); -- bots.forEach((bot, index) => { -- if (index > 0) parts.push((0, import_react6.createElement)("span", { key: `j${index}` }, joiner)); -- parts.push((0, import_react6.createElement)( -- "button", -- { key: bot.id ?? index, type: "button", onClick: () => onOpen?.(bot) }, -- (0, import_react6.createElement)(BotMark, { bot, size: 16 }), -- bot.name -- )); -- }); -- return (0, import_react6.createElement)("div", { className: "bt-event" }, parts); -+ return items; ++// src/client/awaiting.js ++var AWAIT_REPLY_MS = 10 * 60 * 1e3; ++function awaitingReply(roster, sessionId, now = Date.now()) { ++ const at = roster?.remoteAwaiting?.[sessionId]; ++ if (typeof at !== "number" || roster?.byId?.[sessionId]?.remote === void 0) return false; ++ return now - at < AWAIT_REPLY_MS; } --function GroupMessage({ bot, text, room: room2, roster, actions, msgKey, useUi }) { -- const lead = (0, import_react6.createElement)( -+function roleNote(answer) { -+ if (answer?.changed === true) return t("Role changed."); -+ const reason = typeof answer?.reason === "string" && answer.reason !== "" ? answer.reason : t("refused"); -+ return t("Role unchanged: {reason}", { reason }); -+} -+function ProjectList({ projects, error, note, onOpen, onRole }) { -+ const rows = projects === null ? (0, import_react16.createElement)("div", { className: "bt-projects-note" }, t("Reading Projects…")) : error !== "" ? (0, import_react16.createElement)("div", { className: "bt-projects-note" }, error) : projects.length === 0 ? (0, import_react16.createElement)("div", { className: "bt-projects-empty" }, t("No Projects yet")) : projects.map((project) => (0, import_react16.createElement)( - "button", -- { type: "button", className: "bt-lead", "aria-label": t("Open {name}'s chat", { name: bot.name }), onClick: () => actions.openSession(bot.id) }, -- (0, import_react6.createElement)(BotMark, { bot, size: 22 }) +-function MicIcon({ size = 16 }) { +- return (0, import_react3.createElement)( +- "svg", +- { width: size, height: size, viewBox: "0 0 20 20", fill: "none", stroke: "currentColor", strokeWidth: 1.6, strokeLinecap: "round", "aria-hidden": true }, +- (0, import_react3.createElement)("rect", { x: 7, y: 2.5, width: 6, height: 10, rx: 3, fill: "currentColor", stroke: "none" }), +- (0, import_react3.createElement)("path", { d: "M4.5 9.5a5.5 5.5 0 0 0 11 0M10 15v2.5" }) - ); -- return (0, import_react6.createElement)( -+ { -+ key: String(project.id), -+ type: "button", -+ className: "bt-project-entry", -+ "data-project-id": String(project.id), -+ "aria-current": project.current === true ? "true" : void 0, -+ title: roleText(project.role) === "" ? projectName(project) : `${projectName(project)} · ${roleText(project.role)}`, -+ onClick: () => onOpen?.(project.id), -+ onContextMenu: (event) => { -+ event.preventDefault(); -+ onRole?.(project, event); -+ } -+ }, -+ (0, import_react16.createElement)("span", { className: "bt-project-entry-name" }, projectName(project)), -+ roleText(project.role) === "" ? null : (0, import_react16.createElement)("span", { className: "bt-project-entry-role" }, roleText(project.role)) -+ )); -+ return (0, import_react16.createElement)( - "div", -- { className: "bt-group-msg" }, -- (0, import_react6.createElement)( -- "span", -- { className: "bt-group-name", style: { color: inkText(colorOf(bot)) } }, -- bot.name, -- room2?.admin === bot.id ? (0, import_react6.createElement)("span", { className: "bt-group-role" }, t("Admin")) : null -- ), -- (0, import_react6.createElement)(Bubbles, { texts: [text], roster, selfId: bot.id, actions, sessionId: room2?.id, msgKey, useUi, lead }) -+ { className: "bt-projects-list", role: "group", "aria-label": t("Projects") }, -+ rows, -+ note === "" || note === void 0 ? null : (0, import_react16.createElement)("div", { className: "bt-projects-note", role: "status" }, note) - ); ++ ++// src/client/remote-approval-wait.js ++var REMOTE_APPROVAL_LABEL = "Waiting for their approval"; ++function remoteApprovalPhrase(remote, { tool, inputAt = 0 } = {}) { ++ if (!remote) return null; ++ if (tool && inputAt <= (tool.at ?? 0)) return null; ++ return { key: "remote-approval", label: REMOTE_APPROVAL_LABEL, state: "thinking" }; } --function GroupEvent({ prefix, room: room2, name, roster, actions }) { -- return (0, import_react6.createElement)( -- "div", -- { className: "bt-event" }, -- (0, import_react6.createElement)("span", null, prefix), -- room2 ? (0, import_react6.createElement)("button", { type: "button", onClick: () => actions.openSession(room2.id) }, (0, import_react6.createElement)(RoomAvatar, { room: room2, roster, size: 16 }), room2.name) : name ? (0, import_react6.createElement)("span", null, name) : null -+function Chevron() { -+ return (0, import_react16.createElement)( -+ "svg", -+ { width: 12, height: 12, viewBox: "0 0 20 20", fill: "none", stroke: "currentColor", strokeWidth: 2, strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": true }, -+ (0, import_react16.createElement)("path", { d: "M7.5 4.5L13 10l-5.5 5.5" }) - ); +-function WaveIcon({ size = 16 }) { +- return (0, import_react3.createElement)( +- "svg", +- { width: size, height: size, viewBox: "0 0 20 20", fill: "currentColor", "aria-hidden": true }, +- [[3, 7.5, 5], [6.5, 4.5, 11], [10, 2.5, 15], [13.5, 5.5, 9], [17, 8, 4]].map(([x, y, height]) => (0, import_react3.createElement)("rect", { key: x, x: x - 1, y, width: 2, height, rx: 1 })) +- ); ++ ++// src/client/activity-row-host.js ++var RUNNING_HOST = "[data-chat-flow] [data-chat-running]"; ++var TAIL_HOST = "tail"; ++function findRunningHostIn(root) { ++ if (!root || typeof root.querySelector !== "function") return null; ++ if (typeof root.matches === "function" && root.matches("[data-chat-running]")) return root; ++ if (typeof Element !== "undefined" && root instanceof Element) return root.querySelector("[data-chat-running]"); ++ return root.querySelector(RUNNING_HOST); } --function MemoryEvent({ sessionId, scope, result, roster, actions, findBot }) { -- if (!/^(Saved to|Updated in|Removed from) /.test(result)) return null; -- const self = roster.byId[sessionId]; -- const team = /^(team|团队|共享)$/i.test(String(scope ?? "").trim()); -- const other = team || !scope ? void 0 : findBot(scope); -- const target = other && other.id !== self?.id ? other : self; -- if (!target) return null; -- const label = team ? t("Team memory updated") : target === self ? t("Memory updated") : t("Updated {name}'s memory", { name: target.name }); -- return (0, import_react6.createElement)( -- "div", -- { className: "bt-event" }, -- (0, import_react6.createElement)("button", { type: "button", title: result, onClick: () => actions.openMemory(target.id) }, (0, import_react6.createElement)(MemoryIcon), label) -+function ProjectsMark() { -+ return (0, import_react16.createElement)( -+ "svg", -+ { width: 18, height: 18, viewBox: "0 0 20 20", fill: "none", stroke: "currentColor", strokeWidth: 1.6, strokeLinejoin: "round", "aria-hidden": true }, -+ (0, import_react16.createElement)("path", { d: "M2.5 5.5h6l1.5 2h7.5v8a1 1 0 0 1-1 1h-13a1 1 0 0 1-1-1z" }), -+ (0, import_react16.createElement)("path", { d: "M2.5 5.5v-1a1 1 0 0 1 1-1h4l1.5 2" }) - ); +-function ShareIcon() { +- return (0, import_react3.createElement)( +- "svg", +- { width: 16, height: 16, viewBox: "0 0 20 20", fill: "none", stroke: "currentColor", strokeWidth: 1.6, strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": true }, +- (0, import_react3.createElement)("path", { d: "M10 12.5V3M6.5 6.5L10 3l3.5 3.5M4 11v4.5A1.5 1.5 0 0 0 5.5 17h9a1.5 1.5 0 0 0 1.5-1.5V11" }) +- ); ++ ++// src/client/activity-row.js ++var import_react11 = require("react"); ++var import_react_dom2 = require("react-dom"); ++var PHRASE_HOLD_MS = 800; ++var drawPhrase = (pool) => t(pool[Math.min(pool.length - 1, Math.floor(Math.random() * pool.length))]); ++function elapsedLabel(ms) { ++ const minutes = Math.floor(ms / 6e4); ++ if (minutes < 60) return `${Math.max(1, minutes)}m`; ++ const hours = Math.floor(minutes / 60); ++ return minutes % 60 === 0 ? `${hours}h` : `${hours}h ${minutes % 60}m`; } --function PartLine({ time }) { -- return (0, import_react6.createElement)("div", { className: "bt-part-line", role: "separator" }, Number.isFinite(time) ? (0, import_react6.createElement)("span", null, dayLabel(time)) : null); +-function CloseIcon() { +- return (0, import_react3.createElement)("svg", { width: 16, height: 16, viewBox: "0 0 20 20", "aria-hidden": true }, (0, import_react3.createElement)("path", { d: "M5 5l10 10M15 5L5 15", stroke: "currentColor", strokeWidth: 1.6, strokeLinecap: "round" })); ++var REMOTE_GRACE_MS = 1500; ++function useAwaitingReply(roster, sessionId) { ++ const at = roster?.remoteAwaiting?.[sessionId]; ++ const [, tick] = (0, import_react11.useState)(0); ++ const shown = awaitingReply(roster, sessionId); ++ (0, import_react11.useEffect)(() => { ++ if (!shown) return void 0; ++ const timer = setTimeout(() => tick((n) => n + 1), Math.max(0, at + AWAIT_REPLY_MS - Date.now()) + 50); ++ return () => clearTimeout(timer); ++ }, [shown, at]); ++ return shown; + } +-var ChevronLeftIcon = () => (0, import_react3.createElement)("svg", { width: 16, height: 16, viewBox: "0 0 20 20", fill: "none", stroke: "currentColor", strokeWidth: 1.6, strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": true }, (0, import_react3.createElement)("path", { d: "M12.5 4.5L7 10l5.5 5.5" })); +-var ChevronRightIcon = () => (0, import_react3.createElement)("svg", { width: 14, height: 14, viewBox: "0 0 20 20", fill: "none", stroke: "currentColor", strokeWidth: 1.6, strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": true }, (0, import_react3.createElement)("path", { d: "M7.5 4.5L13 10l-5.5 5.5" })); +-var lineIcon = (paths) => function LineIcon() { +- return (0, import_react3.createElement)( +- "svg", +- { width: 15, height: 15, viewBox: "0 0 20 20", fill: "none", stroke: "currentColor", strokeWidth: 1.5, strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": true }, +- paths.map((d, index) => (0, import_react3.createElement)("path", { key: index, d })) +- ); ++function findHost(where) { ++ if (where !== TAIL_HOST) return findRunningHostIn(document); ++ const flow = document.querySelector("[data-conversation-scroll] [data-chat-flow]") ?? document.querySelector("[data-chat-flow]"); ++ if (!flow) return null; ++ const running = findRunningHostIn(flow); ++ if (running) return running; ++ const tails = flow.querySelectorAll(":scope > * [data-turn-tail], :scope > [data-turn-tail]"); ++ return tails.length ? tails[tails.length - 1] : null; ++} ++var lastTurnOnScreen = (kind) => { ++ let top = -1; ++ for (const node of document.querySelectorAll(`[data-chat-flow-kind${kind ? `="${kind}"` : ""}][data-chat-turn]`)) top = Math.max(top, Number(node.dataset.chatTurn)); ++ return top; + }; +-var CLOUD = "M6.5 15.25H5.75a3.25 3.25 0 0 1-.55-6.45 4.75 4.75 0 0 1 9.2-1.1 3.75 3.75 0 0 1 .1 7.55H13.5"; +-function CloudDownloadIcon({ done = false }) { +- return (0, import_react3.createElement)( +- "svg", +- { width: 16, height: 16, viewBox: "0 0 20 20", fill: "none", stroke: "currentColor", strokeWidth: 1.5, strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": true }, +- (0, import_react3.createElement)("path", { d: CLOUD }), +- done ? (0, import_react3.createElement)("path", { d: "M7.75 13.25l1.75 1.75 3-3.25" }) : (0, import_react3.createElement)("g", { className: "bt-cloud-arrow" }, (0, import_react3.createElement)("path", { d: "M10 9.75v7" }), (0, import_react3.createElement)("path", { d: "M7.75 14.5L10 16.75l2.25-2.25" })) +- ); -} --var scrollParent = (node) => { -- for (let at = node.parentElement; at; at = at.parentElement) { -- if (/(auto|scroll)/.test(getComputedStyle(at).overflowY)) return at; -- } -- return null; --}; --function EarlierParts({ sessionId, roster, actions }) { -- const [view, setView] = (0, import_react6.useState)({ items: [], cursor: void 0, loading: true, failed: false, startedAt: null }); -- const top = (0, import_react6.useRef)(null); -- const busy = (0, import_react6.useRef)(false); -- const alive = (0, import_react6.useRef)(true); -- const restore = (0, import_react6.useRef)(null); -- const load = (cursor) => { -- if (busy.current) return; -- busy.current = true; -- setView((current) => ({ ...current, loading: true, failed: false })); -- actions.earlier(sessionId, cursor).then((page) => { -- busy.current = false; -- if (!alive.current) return; -- const scroller = top.current && scrollParent(top.current); -- if (scroller) restore.current = { scroller, fromEnd: scroller.scrollHeight - scroller.scrollTop }; -- setView((current) => ({ items: [...page.items, ...current.items], cursor: page.cursor, loading: false, failed: false, startedAt: page.startedAt ?? current.startedAt })); -- }, () => { -- busy.current = false; -- if (alive.current) setView((current) => ({ ...current, loading: false, failed: true })); -- }); -- }; -- (0, import_react6.useEffect)(() => { -- alive.current = true; -- load(void 0); -+function ProjectRoleMenu({ project, x, y, onPick, onClose }) { -+ const ref = (0, import_react16.useRef)(null); -+ (0, import_react16.useEffect)(() => { -+ const onDown = (event) => { -+ if (!ref.current?.contains(event.target)) onClose(); -+ }; -+ const onKey = (event) => { -+ if (event.key === "Escape") onClose(); -+ }; -+ window.addEventListener("mousedown", onDown, true); -+ window.addEventListener("keydown", onKey, true); - return () => { -- alive.current = false; -+ window.removeEventListener("mousedown", onDown, true); -+ window.removeEventListener("keydown", onKey, true); +-var SmileIcon = lineIcon(["M10 3.5a6.5 6.5 0 1 1 0 13 6.5 6.5 0 0 1 0-13z", "M7.3 11.6a3.4 3.4 0 0 0 5.4 0", "M7.75 8.25h.01M12.25 8.25h.01"]); +-var ReplyIcon = lineIcon(["M8 5L4 9l4 4", "M4 9h7.5a4.5 4.5 0 0 1 4.5 4.5V15"]); +-var DotsIcon = lineIcon(["M5 10h.01M10 10h.01M15 10h.01"]); +-var CheckIcon = lineIcon(["M4.5 10.5l3.5 3.5 7.5-8"]); +-var ArrowDownIcon = lineIcon(["M10 4v12", "M5 11l5 5 5-5"]); +-var DownloadIcon = lineIcon(["M10 3.5v9", "M6.5 9.5L10 13l3.5-3.5", "M4 16h12"]); +-var GaugeIcon = lineIcon(["M3.5 13.5a6.5 6.5 0 1 1 13 0", "M10 13.5l3-4", "M10 13.5h.01"]); +-var PlugIcon = lineIcon(["M7 3v4M13 3v4", "M5 7h10v2.5a5 5 0 0 1-10 0z", "M10 14.5V17"]); +-var CalendarIcon = lineIcon(["M5.5 5h9A1.5 1.5 0 0 1 16 6.5v8a1.5 1.5 0 0 1-1.5 1.5h-9A1.5 1.5 0 0 1 4 14.5v-8A1.5 1.5 0 0 1 5.5 5z", "M4 8.75h12", "M7.25 3.5v3M12.75 3.5v3"]); +-var ConnectorIcon = lineIcon(["M7 10a2.5 2.5 0 1 1-5 0 2.5 2.5 0 0 1 5 0z", "M18 10a2.5 2.5 0 1 1-5 0 2.5 2.5 0 0 1 5 0z", "M7 10h6"]); +-var SwitchIcon = lineIcon(["M4 7h9.5M10.5 3.5L14 7l-3.5 3.5", "M16 13H6.5M9.5 9.5L6 13l3.5 3.5"]); +-var PaperclipIcon = lineIcon(["M15.5 9.5l-5.6 5.6a3.4 3.4 0 0 1-4.8-4.8l6.1-6.1a2.25 2.25 0 0 1 3.2 3.2l-6 6a1.1 1.1 0 0 1-1.6-1.6l5.5-5.5"]); +-var ClockIcon = lineIcon(["M10 3.5a6.5 6.5 0 1 1 0 13 6.5 6.5 0 0 1 0-13z", "M10 6.5V10l2.5 1.5"]); +-var GearIcon = lineIcon(["M10 7.4a2.6 2.6 0 1 1 0 5.2 2.6 2.6 0 0 1 0-5.2z", "M7.85 4.4l.29-1.87a7.7 7.7 0 0 1 3.72 0l.29 1.87a6 6 0 0 1 1.63.94l1.76-.69a7.7 7.7 0 0 1 1.86 3.23l-1.47 1.18a6 6 0 0 1 0 1.88l1.47 1.18a7.7 7.7 0 0 1-1.86 3.23l-1.76-.69a6 6 0 0 1-1.63.94l-.29 1.87a7.7 7.7 0 0 1-3.72 0l-.29-1.87a6 6 0 0 1-1.63-.94l-1.76.69a7.7 7.7 0 0 1-1.86-3.23l1.47-1.18a6 6 0 0 1 0-1.88L2.6 7.88a7.7 7.7 0 0 1 1.86-3.23l1.76.69a6 6 0 0 1 1.63-.94z"]); +-var PinIcon = lineIcon(["M12.5 3l4.5 4.5-3 1.5-2.5 2.5.5 3.5-1.5 1.5L7 13 3.5 16.5", "M7 13l-3.5-3.5L5 8l3.5.5L11 6l1.5-3"]); +-function PinMarkIcon() { +- return (0, import_react3.createElement)( +- "svg", +- { width: 12, height: 12, viewBox: "0 0 20 20", fill: "currentColor", "aria-hidden": true }, +- (0, import_react3.createElement)( +- "g", +- { transform: "rotate(45 10 10)" }, +- (0, import_react3.createElement)("path", { d: "M8 2.5h4a.9.9 0 0 1 0 1.8h-.4v4.1c0 1.4 1.1 2.6 2.6 3.1l.7.3v1.5H5.1v-1.5l.7-.3c1.5-.5 2.6-1.7 2.6-3.1V4.3H8a.9.9 0 0 1 0-1.8z" }), +- (0, import_react3.createElement)("path", { d: "M9.35 13.3h1.3v3.6L10 18l-.65-1.1z" }) +- ) +- ); +-} +-var UnreadIcon = lineIcon(["M10 3.5a4.5 4.5 0 0 0-4.5 4.5v3L4 13.5h12L14.5 11V8", "M8.3 16a1.8 1.8 0 0 0 3.4 0", "M15.5 3.2a1.6 1.6 0 1 1 0 3.2 1.6 1.6 0 0 1 0-3.2z"]); +-var PencilIcon = lineIcon(["M9 4H6a2 2 0 0 0-2 2v8a2 2 0 0 0 2 2h8a2 2 0 0 0 2-2v-3", "M14.3 3.7a1.6 1.6 0 0 1 2.3 2.3L10.5 12l-3 .8.8-3z"]); +-var InfoIcon = lineIcon(["M10 3.5a6.5 6.5 0 1 1 0 13 6.5 6.5 0 0 1 0-13z", "M10 9.25v4", "M10 6.6h.01"]); +-var StarLineIcon = lineIcon(["M10 3l2.1 4.3 4.7.7-3.4 3.3.8 4.7L10 13.8 5.8 16l.8-4.7L3.2 8l4.7-.7z"]); +-var CopyIcon = lineIcon(["M8.5 7h6A1.5 1.5 0 0 1 16 8.5v6a1.5 1.5 0 0 1-1.5 1.5h-6A1.5 1.5 0 0 1 7 14.5v-6A1.5 1.5 0 0 1 8.5 7z", "M4 12.5V5.5A1.5 1.5 0 0 1 5.5 4H13"]); +-var HideIcon = lineIcon(["M3 3l14 14", "M8.6 5.2A7.6 7.6 0 0 1 10 5c4.5 0 7 5 7 5a12.6 12.6 0 0 1-2 2.6M12.2 14.5A6.8 6.8 0 0 1 10 15c-4.5 0-7-5-7-5a12.4 12.4 0 0 1 3-3.4", "M8.5 8.5a2 2 0 0 0 3 3"]); +-var TrashIcon = lineIcon(["M3.5 5.5h13", "M8 5.5V4a1 1 0 0 1 1-1h2a1 1 0 0 1 1 1v1.5", "M5 5.5l.7 10A1.5 1.5 0 0 0 7.2 17h5.6a1.5 1.5 0 0 0 1.5-1.5l.7-10", "M8.5 9v4.5M11.5 9v4.5"]); +-var DuplicateIcon = lineIcon(["M7 7h8.5v9.5H7z", "M4.5 13V3.5H13", "M11.25 9.5v4.5M9 11.75h4.5"]); +-var SlidersIcon = lineIcon(["M4 6.5h5M12.5 6.5H16M4 13.5h4M11.5 13.5H16", "M10.5 4.5v4M9.5 11.5v4"]); +-var MemoryIcon = lineIcon(["M5.5 3.5h8A1.5 1.5 0 0 1 15 5v10a1.5 1.5 0 0 1-1.5 1.5h-8z", "M5.5 3.5v13", "M8.5 7.5h3.5M8.5 10.5h2.5", "M12 3.5v4l1-.8 1 .8"]); +-var FolderIcon = lineIcon(["M3 6A1.5 1.5 0 0 1 4.5 4.5h3.2l1.8 2h6A1.5 1.5 0 0 1 17 8v6.5a1.5 1.5 0 0 1-1.5 1.5h-11A1.5 1.5 0 0 1 3 14.5z"]); +-var ChatsIcon = lineIcon(["M3.5 5.5A1.5 1.5 0 0 1 5 4h7a1.5 1.5 0 0 1 1.5 1.5V10A1.5 1.5 0 0 1 12 11.5H8L5 14v-2.5A1.5 1.5 0 0 1 3.5 10z", "M13.5 7.5H15A1.5 1.5 0 0 1 16.5 9v4.5A1.5 1.5 0 0 1 15 15v2l-2.5-2h-3A1.5 1.5 0 0 1 8 13.5V13"]); +-var SETTINGS_ICONS = { general: GearIcon, bots: PeopleIcon, groups: ChatsIcon, usage: GaugeIcon }; +- +-// src/client/questions.js +-var import_react4 = require("react"); +-var keyLetter = (index) => String.fromCharCode(65 + index); +-var optionsOf = (question) => (question?.options ?? []).map((option) => typeof option === "string" ? { label: option } : option); +-var isTypingTarget = (node) => node instanceof HTMLInputElement || node instanceof HTMLTextAreaElement || node instanceof HTMLSelectElement || node instanceof HTMLElement && node.isContentEditable; +-function useOptionKeys(count, onPick, ownRef) { +- const pick2 = (0, import_react4.useRef)(onPick); +- pick2.current = onPick; +- (0, import_react4.useEffect)(() => { +- if (count === 0) return void 0; +- const onKey = (event) => { +- if (event.defaultPrevented || event.metaKey || event.ctrlKey || event.altKey || event.shiftKey || event.key.length !== 1) return; +- if (isTypingTarget(event.target) || isTypingTarget(document.activeElement)) return; +- if (document.querySelector('[role="dialog"], [aria-modal="true"]') !== null) return; +- const index = event.key.toUpperCase().charCodeAt(0) - 65; +- if (index >= 0 && index < count) { +- event.preventDefault(); +- pick2.current(index); +- } else if (index === count && ownRef.current) { +- event.preventDefault(); +- ownRef.current.focus(); +- } ++function useReplyAfterSend(remote, sending) { ++ const sendTurn = (0, import_react11.useRef)(-1); ++ const wasSending = (0, import_react11.useRef)(false); ++ const [replied, setReplied] = (0, import_react11.useState)(false); ++ (0, import_react11.useEffect)(() => { ++ if (!remote) return void 0; ++ if (sending) { ++ wasSending.current = true; ++ sendTurn.current = -1; ++ setReplied(false); ++ return void 0; ++ } ++ if (wasSending.current) { ++ wasSending.current = false; ++ sendTurn.current = lastTurnOnScreen(); ++ } ++ if (sendTurn.current < 0) return void 0; ++ let frame = 0; ++ const check = () => { ++ frame = 0; ++ if (lastTurnOnScreen("assistant-step") > sendTurn.current) setReplied(true); }; -- }, [sessionId]); -- (0, import_react6.useLayoutEffect)(() => { -- const pending = restore.current; -- restore.current = null; -- if (pending) pending.scroller.scrollTop = pending.scroller.scrollHeight - pending.fromEnd; -- }, [view.items]); -- (0, import_react6.useEffect)(() => { -- const node = top.current; -- if (!node || view.loading || view.failed || !view.cursor || typeof IntersectionObserver !== "function") return void 0; -- const observer = new IntersectionObserver((entries2) => { -- if (entries2.some((entry) => entry.isIntersecting)) load(view.cursor); -- }, { root: scrollParent(node), rootMargin: "600px 0px 0px 0px" }); -- observer.observe(node); -- return () => observer.disconnect(); -- }, [view.cursor, view.loading, view.failed]); -- const selfId = roster.byId[sessionId]?.id; -- const rows = []; -- for (const item of view.items) { -- const last = rows.at(-1); -- if (item.kind === "bot" && last?.kind === "bot") last.texts.push(item.text); -- else rows.push(item.kind === "bot" ? { kind: "bot", texts: [item.text] } : item); +- document.addEventListener("keydown", onKey, true); +- return () => document.removeEventListener("keydown", onKey, true); +- }, [count, ownRef]); ++ const observer = new MutationObserver(() => { ++ if (!frame) frame = requestAnimationFrame(check); ++ }); ++ observer.observe(document.querySelector("[data-conversation-scroll]") ?? document.body, { childList: true, subtree: true }); ++ check(); ++ return () => { ++ cancelAnimationFrame(frame); ++ observer.disconnect(); ++ }; ++ }, [remote, sending]); ++ return replied; + } +-function QuestionView({ question, onAnswer, onDismiss, ownAnswer = false, raised = false }) { +- const [own, setOwn] = (0, import_react4.useState)(""); +- const [picked, setPicked] = (0, import_react4.useState)([]); +- const ownRef = (0, import_react4.useRef)(null); +- const titleId = (0, import_react4.useId)(); +- const options = optionsOf(question); +- const multi = question.multiSelect === true; +- const custom = own.trim(); +- const canSubmit = multi ? picked.length > 0 || custom !== "" : custom !== ""; +- const choose = (label) => { +- if (multi) { +- setPicked((current) => current.includes(label) ? current.filter((item) => item !== label) : [...current, label]); +- return; +- } +- onAnswer({ selected: [label] }); +- }; +- const submit = () => { +- if (!canSubmit) return; +- onAnswer({ selected: multi ? picked : [], ...custom ? { custom } : {} }); +- }; +- useOptionKeys(options.length, (index) => choose(options[index].label), ownRef); +- const ownLetter = options.length > 0 ? keyLetter(options.length) : void 0; +- const field = ownAnswer ? (0, import_react4.createElement)("textarea", { +- ref: ownRef, +- rows: 1, +- value: own, +- autoComplete: "off", +- spellCheck: false, +- className: "bt-q-input", +- "aria-label": t("Your own answer"), +- placeholder: t("Type your own answer"), +- "aria-keyshortcuts": ownLetter?.toLowerCase(), +- onChange: (event) => setOwn(event.target.value), +- onKeyDown: (event) => { +- if (event.key !== "Enter" || event.shiftKey || event.nativeEvent.isComposing) return; +- event.preventDefault(); +- submit(); ++function useRunningHost(active, where = RUNNING_HOST) { ++ const [host, setHost] = (0, import_react11.useState)(null); ++ (0, import_react11.useEffect)(() => { ++ if (!active) { ++ setHost(null); ++ return void 0; + } +- }) : null; +- const rows = options.map((option, optionIndex) => { +- const selected = multi && picked.includes(option.label); +- return (0, import_react4.createElement)( +- "button", +- { +- key: option.label, +- type: "button", +- className: "bt-q-opt", +- "aria-pressed": multi ? selected : void 0, +- "aria-keyshortcuts": keyLetter(optionIndex).toLowerCase(), +- onClick: () => choose(option.label) +- }, +- (0, import_react4.createElement)("span", { className: "bt-key", "aria-hidden": true }, keyLetter(optionIndex)), +- (0, import_react4.createElement)( +- "span", +- { className: "bt-q-body" }, +- (0, import_react4.createElement)("span", { className: "bt-q-label" }, option.label), +- option.description ? (0, import_react4.createElement)("span", { className: "bt-q-desc" }, option.description) : null +- ), +- selected && !raised ? (0, import_react4.createElement)("span", { className: "bt-q-check", title: t("Selected") }, (0, import_react4.createElement)(CheckIcon)) : null +- ); +- }); +- if (raised && field) { +- rows.push((0, import_react4.createElement)( +- "label", +- { key: "own", className: "bt-q-opt bt-q-ownrow", "data-filled": custom !== "" ? "" : void 0 }, +- ownLetter ? (0, import_react4.createElement)("span", { className: "bt-key", "aria-hidden": true }, ownLetter) : null, +- (0, import_react4.createElement)("span", { className: "bt-q-body" }, field) +- )); - } -- return (0, import_react6.createElement)( -+ }, [onClose]); -+ const items = projectMenuItems(project); -+ return (0, import_react_dom3.createPortal)((0, import_react16.createElement)( - "div", -- { className: "bt-earlier", role: "feed", "aria-label": t("Earlier messages"), "aria-busy": view.loading }, -- (0, import_react6.createElement)( +- let foot = null; +- if (raised && multi) { +- foot = (0, import_react4.createElement)( - "div", -- { ref: top, className: "bt-earlier-top" }, -- view.loading ? (0, import_react6.createElement)(Shimmer, null, t("Loading earlier messages…")) : view.failed ? (0, import_react6.createElement)("button", { type: "button", className: "bt-soft", onClick: () => load(view.cursor) }, t("Couldn’t load earlier messages · Retry")) : view.cursor ? (0, import_react6.createElement)("button", { type: "button", className: "bt-soft", onClick: () => load(view.cursor) }, t("Load earlier")) : null -+ { -+ ref, -+ className: "bt-project-menu", -+ role: "menu", -+ "aria-label": t("My role"), -+ style: { left: `${x}px`, top: `${y}px` }, -+ "data-project-id": String(project?.id ?? "") -+ }, -+ (0, import_react16.createElement)("div", { className: "bt-projects-note" }, projectName(project)), -+ items.length === 0 ? (0, import_react16.createElement)("div", { className: "bt-projects-note" }, t("No role to switch here.")) : items.map((item) => (0, import_react16.createElement)("button", { -+ key: item.role, -+ type: "button", -+ role: "menuitemradio", -+ className: "bt-project-menu-item", -+ "aria-checked": item.current ? "true" : "false", -+ disabled: !item.pick, -+ onClick: () => { -+ if (item.pick) onPick(item.role); -+ else onClose(); -+ } -+ }, item.label)) -+ ), document.body); -+} -+function ProjectsNav({ wide = true, request, onOpen } = {}) { -+ const read = request ?? call; -+ const open = (0, import_react16.useCallback)((id) => { -+ if (onOpen) { -+ onOpen(id); -+ return; -+ } -+ if (typeof window !== "undefined") window.location.hash = projectHash(id); -+ }, [onOpen]); -+ const [expanded, setExpanded] = (0, import_react16.useState)(false); -+ const [projects, setProjects] = (0, import_react16.useState)(null); -+ const [error, setError] = (0, import_react16.useState)(""); -+ const [note, setNote] = (0, import_react16.useState)(""); -+ const [menu, setMenu] = (0, import_react16.useState)(null); -+ const load = (0, import_react16.useCallback)(async () => { -+ try { -+ const listing = await read("projects"); -+ const list = Array.isArray(listing?.projects) ? listing.projects : []; -+ setProjects(list); -+ setError(""); -+ } catch (failure) { -+ setProjects([]); -+ setError(failure?.message ?? String(failure)); -+ } -+ }, [read]); -+ (0, import_react16.useEffect)(() => { -+ if (!expanded) return; -+ void load(); -+ }, [expanded, load]); -+ const toggle = () => { -+ setNote(""); -+ if (!wide) { -+ void open(""); -+ return; -+ } -+ setExpanded((value) => !value); -+ }; -+ const listed = expanded && wide; -+ return (0, import_react16.createElement)( -+ "div", -+ { className: "bt-projects" }, -+ (0, import_react16.createElement)( -+ "button", -+ { -+ type: "button", -+ className: "bt-row bt-projects-row", -+ "aria-expanded": listed ? "true" : "false", -+ "aria-label": t("Projects"), -+ title: wide ? void 0 : t("Projects"), -+ onClick: toggle -+ }, -+ (0, import_react16.createElement)("span", { className: "bt-projects-mark" }, (0, import_react16.createElement)(ProjectsMark)), -+ wide ? (0, import_react16.createElement)("span", { className: "bt-projects-label" }, t("Projects")) : null, -+ wide ? (0, import_react16.createElement)("span", { className: "bt-projects-chevron", "aria-hidden": true }, (0, import_react16.createElement)(Chevron)) : null - ), -- rows.map((row, index) => { -- const key = rows.length - index; -- switch (row.kind) { -- case "divider": -- return (0, import_react6.createElement)(PartLine, { key, time: row.time }); -- case "user": -- return (0, import_react6.createElement)("div", { key, className: "bt-urow" }, (0, import_react6.createElement)( -- "div", -- { className: "bt-ububble" }, -- row.text ? (0, import_react6.createElement)(import_dsh_client_ui_primitives.MarkdownText, { text: row.text, labels: mdLabels(), variant: "compact" }) : null, -- row.images ? (0, import_react6.createElement)("span", { className: "bt-uimages" }, row.images === 1 ? t("1 image") : t("{count} images", { count: row.images })) : null -- )); -- case "answer": -- return (0, import_react6.createElement)(AnsweredCell, { key, node: { data: row } }); -- case "bot": -- return (0, import_react6.createElement)(Bubbles, { key, texts: row.texts, roster, selfId, actions }); -- case "event": { -- if (row.role === "report") return (0, import_react6.createElement)(GroupEvent, { key, prefix: t("Replies from"), room: roster.roomsById[row.roomId], name: t("a group chat"), roster, actions }); -- const other = roster.byId[row.botId] ?? { id: row.botId, name: row.name ?? t("a Bot"), color: "gray" }; -- const prefix = { stopped: t("Stopped by"), reply: t("Reply from") }[row.role] ?? t("Message from"); -- return (0, import_react6.createElement)(EventLine, { key, prefix, bots: [other], onOpen: () => { -- if (selfId && other.id) actions.openExchange(selfId, other.id); -- }, whole: true }); -+ listed ? (0, import_react16.createElement)(ProjectList, { projects, error, note, onOpen: open, onRole: (project, event) => setMenu({ project, x: event.clientX, y: event.clientY }) }) : null, -+ menu === null ? null : (0, import_react16.createElement)(ProjectRoleMenu, { -+ project: menu.project, -+ x: menu.x, -+ y: menu.y, -+ onClose: () => setMenu(null), -+ onPick: async (role) => { -+ const project = menu.project; -+ setMenu(null); -+ try { -+ const answer = await read("project-role", { id: project.id, role }); -+ setNote(roleNote(answer)); -+ if (answer?.changed === true) void load(); -+ } catch (failure) { -+ setNote(failure?.message ?? String(failure)); - } -- default: -- return null; +- { className: "bt-q-foot" }, +- (0, import_react4.createElement)("button", { type: "button", className: "bt-q-btn", onClick: onDismiss }, t("Skip")), +- (0, import_react4.createElement)("button", { type: "submit", className: "bt-q-submit", disabled: !canSubmit }, t("Submit")) +- ); +- } else if ((raised || multi) && canSubmit) { +- foot = (0, import_react4.createElement)("div", { className: "bt-q-foot" }, (0, import_react4.createElement)("button", { type: "submit", className: "bt-q-submit" }, t("Submit"))); +- } +- return (0, import_react4.createElement)( +- "form", +- { +- className: raised ? "bt-q bt-q-raised" : "bt-q", +- "aria-labelledby": titleId, +- onSubmit: (event) => { +- event.preventDefault(); +- submit(); ++ let frame = 0; ++ const find = () => { ++ frame = 0; ++ const node = findHost(where); ++ setHost((prev) => prev === node ? prev : node); ++ }; ++ const observer = new MutationObserver(() => { ++ if (!frame) frame = requestAnimationFrame(find); ++ }); ++ observer.observe(document.querySelector("[data-conversation-scroll]") ?? document.body, { childList: true, subtree: true }); ++ find(); ++ return () => { ++ cancelAnimationFrame(frame); ++ observer.disconnect(); ++ }; ++ }, [active, where]); ++ return host; ++} ++function useRowEntrances(replacing) { ++ const check = (0, import_react11.useRef)(replacing); ++ check.current = replacing; ++ (0, import_react11.useEffect)(() => { ++ const seen = /* @__PURE__ */ new WeakSet(); ++ const started = Date.now(); ++ let column = null; ++ let frame = 0; ++ const settle = (event) => { ++ if (event.target !== event.currentTarget || !/^bt-row/.test(event.animationName)) return; ++ delete event.currentTarget.dataset.btEnter; ++ event.currentTarget.removeEventListener("animationend", settle); ++ }; ++ const scan = () => { ++ frame = 0; ++ const next = document.querySelector("[data-chat-flow]"); ++ if (next !== column) { ++ observer.disconnect(); ++ column = next; ++ if (column) observer.observe(column, { childList: true, subtree: true }); ++ } ++ if (!column) return; ++ const items = [...column.querySelectorAll(":scope > [data-chat-flow-key]")]; ++ let last = -1; ++ items.forEach((item, index) => { ++ if (seen.has(item)) last = index; ++ }); ++ const fresh = items.filter((item) => !seen.has(item) && item.offsetHeight > 0); ++ for (const item of fresh) seen.add(item); ++ if (last < 0 || Date.now() - started < 800 || fresh.length > 3) return; ++ for (const item of fresh) { ++ if (items.indexOf(item) < last) continue; ++ const reply = [void 0, "assistant-step", "tool-call"].includes(item.dataset.chatFlowKind); ++ item.dataset.btEnter = reply && check.current() ? "after-collapse" : "new"; ++ item.addEventListener("animationend", settle); } -- }), -- (0, import_react6.createElement)(PartLine, { time: view.startedAt }) -+ }) - ); +- }, +- (0, import_react4.createElement)( +- "div", +- { className: "bt-q-head" }, +- (0, import_react4.createElement)( +- "div", +- { className: "bt-q-text" }, +- (0, import_react4.createElement)("p", { className: "bt-q-title", id: titleId }, question.header ? `${question.header} · ` : "", question.question), +- question.detail ? (0, import_react4.createElement)("div", { className: "bt-q-detail" }, question.detail) : null, +- raised && multi ? (0, import_react4.createElement)("div", { className: "bt-q-detail" }, t("Choose all that apply")) : null +- ), +- (0, import_react4.createElement)("button", { type: "button", className: "bt-q-x", "aria-label": t("Dismiss question"), title: t("Dismiss"), onClick: onDismiss }, (0, import_react4.createElement)(CloseIcon)) +- ), +- rows.length > 0 ? (0, import_react4.createElement)("div", { className: "bt-q-list", role: "group" }, rows) : null, +- field && !raised ? (0, import_react4.createElement)( +- "div", +- { className: "bt-q-custom" }, +- (0, import_react4.createElement)("label", { className: "bt-q-field" }, field), +- !multi && canSubmit ? (0, import_react4.createElement)("button", { type: "submit", className: "bt-q-submit" }, t("Submit")) : null +- ) : null, +- foot +- ); ++ }; ++ const observer = new MutationObserver(() => { ++ if (!frame) frame = requestAnimationFrame(scan); ++ }); ++ scan(); ++ return () => { ++ cancelAnimationFrame(frame); ++ observer.disconnect(); ++ }; ++ }, []); } --function TriggerCell({ node, sessionId, useRoster, useSessions, useTurnClock, useUi, actions }) { -- const roster = useRoster((value) => value); -- const data = node.data; -- const source = data.source !== null && typeof data.source === "object" ? data.source : {}; -- const selfId = roster.byId[sessionId]?.id ?? sessionId; -- const deferred = source.kind === "bot" && source.deferred === true; -- const relayed = isRelayContext(data); -- const turn = node.location?.turn?.turn ?? data.turn; -- (0, import_react6.useLayoutEffect)(() => { -- if (relayed) actions.noteTurnPeer(sessionId, turn, source.senderSessionId); -- }, [relayed, sessionId, turn, source.senderSessionId]); -- const order = useSessions((list) => { -- const outline = list.projectionsBySession?.[sessionId]?.values?.turnOutline ?? list.byId[sessionId]?.projectionValues?.turnOutline; -- return Array.isArray(outline) ? outline.map((entry) => entry.turn).join(",") : ""; -- }); -- const peers = useTurnClock((state) => { -- if (!relayed) return ""; -- const turns = state[sessionId] ?? {}; -- return Object.keys(turns).filter((key) => turns[key].peer !== void 0).map((key) => `${key}:${turns[key].peer}`).join(","); -- }); -- const visit = useUi((state) => state.visits?.[sessionId]); -- const hiddenTurns = roster.exchangeTurns[sessionId]; -- const unreadOpening = useTurnClock((state) => { -- const first = openingTurn(state[sessionId], order, hiddenTurns); -- return first?.turn === turn && unseenSince(visit, first.start) ? clockLabel(first.start) : ""; -- }); -- const body = triggerBody(); -- return unreadOpening ? (0, import_react6.createElement)(import_react6.Fragment, null, (0, import_react6.createElement)("div", { className: "bt-open-new" }, (0, import_react6.createElement)(NewDivider), (0, import_react6.createElement)("div", { className: "bt-time-sep", role: "separator" }, unreadOpening)), body) : body; -- function triggerBody() { -- if (source.kind === "bot") { -- if (source.handoff === true) return roster.roomsById[sessionId] ? null : (0, import_react6.createElement)(EarlierParts, { sessionId, roster, actions }); -- if (source.role === "kickoff" || source.role === "room") return null; -- if (source.role === "secret" || deferred) return null; -- if (source.role === "post") { -- if (source.failed !== void 0 || source.resumed === true || source.stopped === true) return null; -- const room2 = roster.roomsById[sessionId]; -- const poster = roster.byId[source.senderSessionId] ?? { id: source.senderSessionId, name: source.senderName ?? t("a Bot"), color: "gray" }; -- return (0, import_react6.createElement)(GroupMessage, { bot: poster, text: stripHeader(contentText(data.content)), room: room2, roster, actions, msgKey: `${sessionId}:${data.turn}:post`, useUi }); -- } -- if (source.role === "report") { -- return (0, import_react6.createElement)(GroupEvent, { prefix: t("Replies from"), room: roster.roomsById[source.roomId], name: t("a group chat"), roster, actions }); -- } -- if (source.role === "resume" || source.resumed === true) return (0, import_react6.createElement)("div", { className: "bt-event" }, t("Picked up its work after a restart")); -- if (source.role === "retry" || source.retried === true) return (0, import_react6.createElement)("div", { className: "bt-event" }, t("Asked to make the tool call it wrote as text")); -- const run = relayed ? commRunOf( -- order.split(",").filter(Boolean).map(Number), -- Object.fromEntries(peers.split(",").filter(Boolean).map((pair) => [Number(pair.slice(0, pair.indexOf(":"))), pair.slice(pair.indexOf(":") + 1)])), -- turn, -- hiddenTurns -- ) : null; -- if (run === "folded") return null; -- const sender = roster.byId[source.senderSessionId] ?? { id: source.senderSessionId, name: source.senderName ?? t("a Bot"), color: "gray" }; -- const count = run?.count ?? (isHiddenTurn(roster, sessionId, turn) ? 2 : 1); -- const bots = [sender]; -- for (const peer of (run?.peerIds ?? []).map((id) => roster.byId[id]).filter(Boolean)) { -- if (!bots.some((bot) => bot.id === peer.id)) bots.push(peer); -- } -- return (0, import_react6.createElement)(EventLine, { -- prefix: source.role === "stopped" ? t("Stopped by") : count > 1 ? t("{count} messages with", { count }) : t("Message from"), -- bots, -- onOpen: (bot) => actions.openExchange(selfId, bot.id), -- whole: true -- }); -- } -- if (source.kind === "schedule") { -- const prompts = reminderPrompts(contentText(data.content)); -- return (0, import_react6.createElement)("div", { className: "bt-event" }, prompts.length > 0 ? t("Scheduled task: {text}", { text: prompts.join(" · ").slice(0, 80) }) : t("Routine started")); +-function SettledQuestion({ question, answer }) { +- const options = optionsOf(question); +- const prompt = question?.question ?? ""; +- const chosen = [...answer.selected ?? [], ...answer.custom ? [answer.custom] : []]; +- if (chosen.length === 0) { +- return (0, import_react4.createElement)( +- "div", +- { className: "bt-q bt-q-settled bt-q-dismissed", role: "group", "aria-label": prompt }, +- (0, import_react4.createElement)( +- "div", +- { className: "bt-q-head" }, +- (0, import_react4.createElement)("div", { className: "bt-q-text" }, (0, import_react4.createElement)("p", { className: "bt-q-title" }, prompt)), +- (0, import_react4.createElement)("span", { className: "bt-q-badge" }, t("Dismissed")) +- ) +- ); +- } +- return (0, import_react4.createElement)( ++function ActivityIndicator({ sessionId, useRoster, useSessionStatus, useLive, useSession, actions }) { ++ const roster = useRoster((value) => value); ++ const running = useSessionStatus((map) => map.get(sessionId)?.running === true); ++ const liveKey = useLive((state) => JSON.stringify(state[sessionId] ?? {})); ++ const pendingInput = useSession((snapshot) => Math.max(0, ...(snapshot?.pendingSubmissions ?? []).filter((sub) => sub.placement !== "queued").map((sub) => sub.time))); ++ const bot = roster.byId[sessionId]; ++ const room2 = roster.roomsById[sessionId]; ++ const entries2 = (0, import_react11.useMemo)(() => Object.values(JSON.parse(liveKey)), [liveKey]); ++ const streaming = entries2.some((entry) => entry.text); ++ const tools = entries2.filter((entry) => !entry.text).sort((a, b) => a.at - b.at); ++ const relaying = tools.some((entry) => entry.relay); ++ const awaiting = useAwaitingReply(roster, sessionId); ++ const sendRun = (0, import_react11.useRef)(false); ++ if (!running) sendRun.current = false; ++ else if (pendingInput > 0) sendRun.current = true; ++ const replied = useReplyAfterSend(bot?.remote !== void 0, running && sendRun.current); ++ const visible = bot?.remote !== void 0 ? !streaming && (running ? sendRun.current : awaiting && !replied) : running && !streaming && !relaying && (bot !== void 0 || room2 !== void 0); ++ const lastRun = (0, import_react11.useRef)({ running, send: false }); ++ const bridging = bot?.remote !== void 0 && !streaming && lastRun.current.running && lastRun.current.send && !running; ++ lastRun.current = { running, send: sendRun.current }; ++ const steady = bridging; ++ const tool = tools.at(-1); ++ const inputRef = (0, import_react11.useRef)(0); ++ const inputAt = Math.max(inputRef.current, pendingInput); ++ inputRef.current = inputAt; ++ const phrase = visible ? remoteApprovalPhrase(bot?.remote !== void 0, { tool, inputAt }) : null; ++ const want = !visible ? null : phrase ? { ...phrase, label: t(phrase.label) } : inputAt > (tool?.at ?? 0) ? { key: `input:${inputAt}`, pool: PHRASES.thinking, state: "thinking" } : tool ? { key: tool.label ? `tool:${tool.label}` : "working", label: tool.label, pool: PHRASES.working, state: tool.state ?? "working" } : { key: "thinking", pool: PHRASES.thinking, state: "thinking" }; ++ const [shown, setShown] = (0, import_react11.useState)(null); ++ const wantKey = want?.key ?? null; ++ const markState = (0, import_react11.useRef)("thinking"); ++ if (want) markState.current = want.state; ++ (0, import_react11.useEffect)(() => { ++ if (wantKey === null || shown?.key === wantKey) return void 0; ++ const make = () => setShown({ key: wantKey, text: want.label ?? drawPhrase(want.pool), at: Date.now() }); ++ const wait = shown ? shown.at + PHRASE_HOLD_MS - Date.now() : 0; ++ if (wait <= 0) { ++ make(); ++ return void 0; ++ } ++ const timer = setTimeout(make, wait); ++ return () => clearTimeout(timer); ++ }, [wantKey, shown]); ++ const [phase, setPhase] = (0, import_react11.useState)(null); ++ const endedAt2 = (0, import_react11.useRef)(0); ++ const shift = (0, import_react11.useRef)(null); ++ const leaving = (0, import_react11.useRef)(false); ++ leaving.current = phase !== null && !visible; ++ (0, import_react11.useEffect)(() => { ++ if (visible) { ++ setPhase("in"); ++ return void 0; ++ } ++ let timer; ++ const leave = () => { ++ setPhase((current) => current === "in" ? "out" : current); ++ timer = setTimeout(() => { ++ shift.current = document.querySelector("[data-conversation-scroll]")?.scrollTop ?? null; ++ setPhase((current) => current === "out" ? null : current); ++ }, 140); ++ }; ++ if (steady) timer = setTimeout(leave, REMOTE_GRACE_MS); ++ else leave(); ++ return () => clearTimeout(timer); ++ }, [visible]); ++ (0, import_react11.useLayoutEffect)(() => { ++ if (phase !== null) return; ++ endedAt2.current = Date.now(); ++ setShown(null); ++ const before = shift.current; ++ shift.current = null; ++ const scroller = document.querySelector("[data-conversation-scroll]"); ++ if (before === null || !scroller || reducedMotion()) return; ++ const delta = before - scroller.scrollTop; ++ if (delta > 0) scroller.querySelector("[data-chat-flow]")?.animate([{ transform: `translateY(${-delta}px)` }, { transform: "none" }], { duration: 140, easing: "cubic-bezier(.23,1,.32,1)" }); ++ }, [phase]); ++ (0, import_react11.useEffect)(() => { ++ if (!running) actions.clearLive(sessionId); ++ }, [running, sessionId]); ++ useRowEntrances(() => leaving.current || Date.now() - endedAt2.current < 300); ++ const [now, setNow] = (0, import_react11.useState)(Date.now); ++ (0, import_react11.useEffect)(() => { ++ if (phase !== "in") return void 0; ++ const timer = setInterval(() => setNow(Date.now()), 1e4); ++ return () => clearInterval(timer); ++ }, [phase]); ++ const remote = bot?.remote !== void 0; ++ const host = useRunningHost(running || remote && phase !== null, running || !remote ? RUNNING_HOST : TAIL_HOST); ++ const drawn = (0, import_react11.useRef)(false); ++ const carry = drawn.current; ++ (0, import_react11.useEffect)(() => { ++ drawn.current = Boolean(host && phase !== null && shown); ++ }, [host, phase, shown]); ++ if (!host || phase === null || !shown) return null; ++ const elapsed = now - shown.at >= 6e4 ? elapsedLabel(now - shown.at) : null; ++ return (0, import_react_dom2.createPortal)((0, import_react11.createElement)( + "div", +- { className: "bt-q bt-q-settled", role: "group", "aria-label": prompt }, +- prompt ? (0, import_react4.createElement)("div", { className: "bt-q-head" }, (0, import_react4.createElement)("div", { className: "bt-q-text" }, (0, import_react4.createElement)("p", { className: "bt-q-title" }, prompt))) : null, +- (0, import_react4.createElement)("div", { className: "bt-q-list", role: "group", "aria-label": t("Your answer") }, chosen.map((label, index) => { +- const optionIndex = options.findIndex((option) => option.label === label); +- return (0, import_react4.createElement)( +- "div", +- { key: `${label}-${index}`, className: "bt-q-opt" }, +- optionIndex >= 0 ? (0, import_react4.createElement)("span", { className: "bt-key", "aria-hidden": true }, keyLetter(optionIndex)) : null, +- (0, import_react4.createElement)("span", { className: "bt-q-body" }, (0, import_react4.createElement)("span", { className: "bt-q-label" }, label)), +- (0, import_react4.createElement)("span", { className: "bt-q-check", title: t("Selected") }, (0, import_react4.createElement)(CheckIcon)) +- ); +- })) +- ); ++ { className: "bt-typing", "data-exiting": phase === "out" ? "" : void 0, "data-carry": carry && phase !== "out" ? "" : void 0, "data-remote": remote ? "" : void 0, "data-flybot": shown.key === "remote-approval" ? "remote-approval" : void 0, "aria-hidden": true }, ++ bot ? (0, import_react11.createElement)(BotMark, { bot, size: 28, state: markState.current, live: true, pokeable: true }) : (0, import_react11.createElement)(RoomAvatar, { room: room2, roster, size: 28 }), ++ (0, import_react11.createElement)( ++ "span", ++ { className: "bt-typing-text" }, ++ (0, import_react11.createElement)( ++ "span", ++ { key: `${shown.key}:${shown.at}`, className: "bt-typing-label" }, ++ (0, import_react11.createElement)(Shimmer, null, `${shown.text}…`), ++ elapsed ? (0, import_react11.createElement)("span", { className: "bt-typing-time" }, `· ${elapsed}`) : null ++ ) ++ ) ++ ), host); + } +-function QuestionCard({ matched }) { +- const pending = matched; +- const questions = pending.questions ?? []; +- const [index, setIndex] = (0, import_react4.useState)(0); +- const [answers, setAnswers] = (0, import_react4.useState)([]); +- const question = questions[index]; +- if (question === void 0) return null; +- const onAnswer = (answer) => { +- const next = [...answers, { id: question.id, ...answer }]; +- if (index + 1 < questions.length) { +- setAnswers(next); +- setIndex(index + 1); +- return; - } -- if (source.kind === "user-question-reply") return null; -- return (0, import_react6.createElement)("div", { className: "bt-event" }, contentText(data.content).slice(0, 80) || t("Update")); -- } --} --function NewDivider() { -- return (0, import_react6.createElement)("div", { className: "bt-new-sep", role: "separator", "aria-label": t("New messages") }, (0, import_react6.createElement)("span", null, t("New"))); --} --function TurnProcessCell({ node, sessionId, turnProcess, useRoster, useUi, useTurnClock, actions }) { -- const open = turnProcess === void 0 || !turnProcess.foldable || turnProcess.open; -- (0, import_react6.useEffect)(() => { -- if (!open) turnProcess.setOpen(true); -- }, [open, turnProcess]); -- const turn = node?.location?.turn?.turn ?? node?.data?.turn; -- const start = node?.location?.turn?.start?.time; -- const hidden = useRoster((roster) => isHiddenTurn(roster, sessionId, turn)); -- (0, import_react6.useEffect)(() => { -- actions.noteTurnTime(sessionId, turn, "first", start); -- }, [sessionId, turn, start]); -- const visit = useUi((state) => state.visits?.[sessionId]); -- const fresh = useTurnClock((state) => { -- const own = state[sessionId]?.[turn]; -- return !hidden && typeof visit?.seen === "number" && own?.first !== void 0 && own.first <= visit.seen && own.end !== void 0 && own.end > visit.seen && own.end <= visit.entered; -- }); -- return fresh ? (0, import_react6.createElement)(NewDivider) : null; +- pending.answer({ answers: next }); +- }; +- return (0, import_react4.createElement)(QuestionView, { key: index, question, onAnswer, onDismiss: () => pending.dismiss(), ownAnswer: true, raised: true }); + -+// src/client/sidebar.js -+var import_react17 = require("react"); -+var import_react_dom4 = require("react-dom"); -+var import_dsh_client_ui_primitives2 = require("@deepseek-ai/dsh-client-ui-primitives"); -+function useMarkedUnread(id, active, marked, actions) { -+ (0, import_react17.useEffect)(() => { -+ if (active && marked) actions.clearUnread(id); -+ }, [active, marked]); ++// src/client/dsh-offer.js ++var APP_OFFER = /* @__PURE__ */ new Set(["available", "downloading", "verifying", "installing", "ready"]); ++var bare = (value) => String(value ?? "").trim().replace(/^v/i, "").split("+")[0]; ++var sameVersion = (a, b) => bare(a) !== "" && bare(a) === bare(b); ++function showsDshRow(dsh, app) { ++ if (!dsh?.verified || !dsh.current || sameVersion(dsh.verified, dsh.current)) return false; ++ if (dsh.kind === "npm") return true; ++ return dsh.kind === "app" && APP_OFFER.has(app?.phase) && sameVersion(app.version, dsh.verified); + } +-function PendingQuestion({ sessionId, question, actions, inline }) { +- const [answered, setAnswered] = (0, import_react4.useState)(null); +- if (answered === question.id) return null; +- const onAnswer = (answer) => { +- setAnswered(question.id); +- void actions.answerQuestion(sessionId, question.id, { selected: answer.selected ?? [], custom: answer.custom ?? "" }).catch(() => setAnswered(null)); +- }; +- const onDismiss = () => { +- setAnswered(question.id); +- void actions.dismissQuestion(sessionId); +- }; +- return (0, import_react4.createElement)( +- "div", +- { className: inline ? "bt-q-inline" : "bt-q-dock" }, +- (0, import_react4.createElement)(QuestionView, { key: question.id, question, onAnswer, onDismiss, ownAnswer: question.allowCustom === true, raised: !inline }) +- ); ++ ++// src/client/overlay-hooks.js ++var import_react12 = require("react"); ++var escapeLayers = []; ++var onEscapeKey = (event) => { ++ if (event.key === "Escape") escapeLayers.at(-1)?.current(); ++}; ++function useEscape(onClose) { ++ const ref = (0, import_react12.useRef)(onClose); ++ ref.current = onClose; ++ (0, import_react12.useEffect)(() => { ++ if (escapeLayers.length === 0) window.addEventListener("keydown", onEscapeKey); ++ escapeLayers.push(ref); ++ return () => { ++ escapeLayers.splice(escapeLayers.indexOf(ref), 1); ++ if (escapeLayers.length === 0) window.removeEventListener("keydown", onEscapeKey); ++ }; ++ }, []); + } +-function QuestionDock({ sessionId, useRoster, useCards, useSession, actions }) { +- const question = useRoster((value) => value.questions?.[sessionId]); +- const drawn = useCards?.((state) => state[sessionId]?.[question?.callId] > 0) === true; +- const loading = useSession?.(historyLoading) === true; +- if (question === void 0 || question.callId && (drawn || loading)) return null; +- return (0, import_react4.createElement)(PendingQuestion, { sessionId, question, actions, inline: false }); ++function useDialogFocus(ref) { ++ (0, import_react12.useEffect)(() => { ++ const before = document.activeElement; ++ ref.current?.focus?.({ preventScroll: true }); ++ return () => { ++ if (before && typeof before.focus === "function" && before.isConnected) before.focus({ preventScroll: true }); ++ }; ++ }, []); + } +-function AnsweredCell({ node }) { +- const data = node.data ?? {}; +- const questions = Array.isArray(data.questions) ? data.questions : []; +- if (questions.length === 0) { +- return data.text ? (0, import_react4.createElement)("div", { className: "bt-urow" }, (0, import_react4.createElement)("div", { className: "bt-ububble" }, data.text)) : null; +- } +- const answers = Array.isArray(data.answers) ? data.answers : []; +- return (0, import_react4.createElement)("div", { className: "bt-q-inline" }, questions.map((question) => (0, import_react4.createElement)(SettledQuestion, { +- key: question.id, +- question, +- answer: answers.find((answer) => answer.id === question.id) ?? { selected: [] } +- }))); ++function usePaneLeft() { ++ const [paneLeft, setPaneLeft] = (0, import_react12.useState)(280); ++ (0, import_react12.useLayoutEffect)(() => { ++ const column = document.querySelector('[class*="_footArea"]')?.parentElement; ++ if (column) setPaneLeft(Math.round(column.getBoundingClientRect().right)); ++ }, []); ++ return paneLeft; + } + +-// src/client/secret-card.js +-var import_react5 = require("react"); +-var MIN_VALUE = 4; +-var NONE = {}; +-var KeyIcon = () => (0, import_react5.createElement)( +- "svg", +- { width: 15, height: 15, viewBox: "0 0 20 20", fill: "none", stroke: "currentColor", strokeWidth: 1.5, strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": true }, +- (0, import_react5.createElement)("path", { d: "M7 9.5a3.5 3.5 0 1 1 0 .01z" }), +- (0, import_react5.createElement)("path", { d: "M10.4 10.6L17 10.6M14.5 10.6v2.4M16.6 10.6v1.8" }) +-); +-var LockIcon = () => (0, import_react5.createElement)( +- "svg", +- { width: 14, height: 14, viewBox: "0 0 20 20", fill: "none", stroke: "currentColor", strokeWidth: 1.5, strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": true }, +- (0, import_react5.createElement)("path", { d: "M6.5 9V7a3.5 3.5 0 0 1 7 0v2" }), +- (0, import_react5.createElement)("rect", { x: 4.5, y: 9, width: 11, height: 8, rx: 2 }) +-); +-var WarnIcon = () => (0, import_react5.createElement)( +- "svg", +- { width: 14, height: 14, viewBox: "0 0 20 20", fill: "none", stroke: "currentColor", strokeWidth: 1.5, strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": true }, +- (0, import_react5.createElement)("path", { d: "M10 3.5l7 12.5H3z" }), +- (0, import_react5.createElement)("path", { d: "M10 8.5v3.5M10 14.2h.01" }) +-); +-var MASK_BY_CSS = typeof CSS !== "undefined" && typeof CSS.supports === "function" && CSS.supports("-webkit-text-security", "disc"); +-var insecure = () => typeof location !== "undefined" && location.protocol === "http:" && !/^(localhost|127(?:\.\d{1,3}){3}|\[::1\])$/.test(location.hostname); +-var scopeLabel = (scope, roster) => { +- if (scope === "all") return t("All Bots"); +- const names = (scope ?? []).map((id) => roster.byId[id]?.name).filter(Boolean); +- return names.length === 0 ? t("No Bot") : names.join(t(" and ")); ++// src/client/member-status.js ++var import_react13 = require("react"); ++var NO_WORK = { bots: {}, rooms: {}, approvals: [] }; ++var ROWS_MAX = 4; ++var stepIn = (work, botId, roomId, tool) => { ++ const own = work.bots?.[botId]; ++ return own?.roomId === roomId && own.tool === tool ? own.step : void 0; + }; +-var when = (time) => Number.isFinite(time) ? new Date(time).toLocaleString(dateLocale(), { month: "short", day: "numeric", hour: "2-digit", minute: "2-digit" }) : ""; +-function MaskedInput({ value, onChange, onEnter, label, autoFocus = false }) { +- return (0, import_react5.createElement)("input", { +- className: "bt-secret-input", +- type: MASK_BY_CSS ? "text" : "password", +- "data-masked": MASK_BY_CSS ? "" : void 0, +- value, +- autoFocus, +- autoComplete: MASK_BY_CSS ? "off" : "new-password", +- autoCorrect: "off", +- autoCapitalize: "off", +- spellCheck: false, +- "data-1p-ignore": "", +- "data-lpignore": "true", +- "data-bwignore": "", +- "data-form-type": "other", +- "aria-label": label, +- placeholder: t("Paste or type the key"), +- onChange: (event) => onChange(event.target.value), +- onKeyDown: (event) => { +- if (event.key !== "Enter" || event.nativeEvent.isComposing) return; +- event.preventDefault(); +- onEnter?.(); +- } ++var workIn = (room2, work, id) => { ++ const local = (work.rooms?.[room2.id] ?? []).find((entry) => entry.botId === id); ++ if (local) return { ...local, roomId: room2.id, step: stepIn(work, id, room2.id, local.tool) }; ++ const own = work.bots?.[id]; ++ return own !== void 0 && own.roomId === void 0 && own.fromRoomId === room2.id ? { ...own, roomId: room2.id, handed: true } : void 0; ++}; ++var askedIn = (room2, work, id, doing) => (work.approvals ?? []).filter((entry) => entry.botId === id && (entry.roomId === room2.id || entry.roomId === void 0 && doing?.handed === true)); ++function memberWork(room2, work = NO_WORK) { ++ return room2.members.map((id) => { ++ const doing = workIn(room2, work, id); ++ const approvals = askedIn(room2, work, id, doing); ++ return { id, approvals, doing, here: doing !== void 0, state: approvals.length > 0 ? "ask" : doing ? "work" : "idle" }; ++ }); ++} ++function busyMembers(room2, work, roster) { ++ const busy = memberWork(room2, work).filter((entry) => entry.state !== "idle" && roster.byId[entry.id]); ++ return [...busy.filter((entry) => entry.state === "ask"), ...busy.filter((entry) => entry.state === "work")].map((entry) => ({ ...entry, bot: roster.byId[entry.id] })); ++} ++function splitBusy(busy) { ++ const here = busy.filter((entry) => entry.state === "work" && entry.here); ++ const rows = [...here.filter((entry) => entry.doing.speaking === true), ...here.filter((entry) => entry.doing.speaking !== true)].slice(0, ROWS_MAX); ++ return { rows, rest: busy.filter((entry) => !rows.includes(entry)) }; ++} ++function botGroupWork(botId, work, roster) { ++ return Object.entries(work.rooms ?? {}).flatMap(([roomId, entries2]) => { ++ const entry = entries2.find((item) => item.botId === botId); ++ const room2 = roster.roomsById[roomId]; ++ return entry && room2 ? [{ room: room2, tool: entry.tool, step: stepIn(work, botId, roomId, entry.tool), since: entry.since }] : []; + }); + } +-function ScopeSwitch({ value, onChange, bot }) { +- const options = [["all", t("All Bots")], ["bot", t("Only {name}", { name: bot.name })]]; +- return (0, import_react5.createElement)( +- "div", +- { className: "bt-secret-scope", role: "radiogroup", "aria-label": t("Who may use it") }, +- (0, import_react5.createElement)("span", { className: "bt-secret-glide", "data-at": value === "all" ? 0 : 1, "aria-hidden": true }), +- options.map(([id, text]) => (0, import_react5.createElement)("button", { +- key: id, +- type: "button", +- role: "radio", +- "aria-checked": value === id, +- className: "bt-secret-seg", +- onClick: () => onChange(id) +- }, text)) ++var moreRooms = (count) => count === 1 ? t("1 more room") : t("{count} more rooms", { count }); ++function summaryText(busy) { ++ const asks = busy.filter((entry) => entry.state === "ask"); ++ const workers = busy.filter((entry) => entry.state === "work"); ++ const names = asks.slice(0, 2).map((entry) => entry.bot.name); ++ const waiting = asks.length === 0 ? "" : asks.length === 1 ? t("{name} is waiting for your approval", { name: names[0] }) : asks.length === 2 ? t("{first} and {second} are waiting for your approval", { first: names[0], second: names[1] }) : t("{first}, {second} and {count} more are waiting for your approval", { first: names[0], second: names[1], count: asks.length - 2, total: asks.length }); ++ const first = workers[0]; ++ const doing = first ? `${first.bot.name} · ${workText(first.doing.tool, first.doing.step)}${workers.length > 1 ? ` +${workers.length - 1}` : ""}` : ""; ++ return { waiting, doing }; ++} ++function Face({ entry, size }) { ++ return (0, import_react13.createElement)( ++ "span", ++ { className: "bt-mw-face", "data-ask": entry.state === "ask" || void 0 }, ++ (0, import_react13.createElement)(BotMark, { bot: entry.bot, size, state: entry.state === "ask" ? "alert" : toolState(entry.doing?.tool ?? "") }) + ); } --function Nothing() { -- return null; -+var PinMark = () => (0, import_react17.createElement)("span", { className: "bt-pin", "aria-hidden": true }, (0, import_react17.createElement)(PinMarkIcon)); -+var useApproval = (useWork, match) => useWork ? useWork((work) => (work.approvals ?? []).some(match)) : false; -+var useGroupWork = (useWork, pick2) => useWork ? useWork((work) => pick2(work.rooms ?? {}).join(",")) : ""; -+var workKeys = (text2) => text2 === "" ? [] : text2.split(",").map((key) => { -+ const [id, tool] = key.split("|"); -+ return { id, tool: tool || void 0 }; -+}); -+var workLine = (lead, tool, more) => (0, import_react17.createElement)(Shimmer, null, `${lead} · ${workPhrase(tool)}…${more > 0 ? ` +${more}` : ""}`); -+function BotRow({ bot, roster, actions, useSessions, useSessionStatus, useSessionRetainInfo, useActivity, useWork, wide, marked, asRow = false }) { -+ const id = bot.id; -+ const part = currentPart(bot); -+ const preview = useSessions((list) => nameMentions(previewOf(list, part, roster.exchangeTurns[part], unsentReplyTurns(bot, part), botNames(roster)), roster)); -+ const running = useSessionStatus((map) => map.get(part)?.running === true); -+ const active = useSessionRetainInfo(part, (info) => (info?.retainedBy?.mainView ?? 0) > 0); -+ const unread = useSessionStatus((map) => map.get(part)?.completionUnread === true || map.get(part)?.pendingInteraction !== void 0) || marked && !active; -+ useMarkedUnread(id, active, marked, actions); -+ const main = isMainOf(roster, id); -+ const tile = roster.mainBotId === id && !asRow; -+ const approval = useApproval(useWork, (entry) => entry.botId === id); -+ const waiting = approval || (bot.pendingCount ?? 0) > 0; -+ const alert = roster.questions?.[part] !== void 0 || waiting; -+ const doing = useActivity((map) => map[id]); -+ const groups = workKeys(useGroupWork(useWork, (rooms) => Object.entries(rooms).flatMap(([roomId, entries2]) => entries2.filter((entry) => entry.botId === id).map((entry) => `${roomId}|${entry.tool ?? ""}`)))); -+ const away = !running && groups.length > 0; -+ const state = botState(running || away, doing, alert); -+ const shownAway = away ? awayGroups(groups, roster.roomsById) : []; -+ const awayLine = shownAway.length > 0 ? workLine(roster.roomsById[shownAway[0].id]?.name ?? t("a group chat"), shownAway[0].tool, shownAway.length - 1) : null; -+ if (!isShownRemoteChat(bot) && !active && !main) return null; -+ const onContextMenu = (event) => { -+ event.preventDefault(); -+ actions.openMenu({ kind: "bot", id, x: event.clientX, y: event.clientY }); -+ }; -+ const pinned = bot.pinned === true && !tile; -+ const remote = Boolean(bot.remote); -+ const shown = remote ? friendBotTitle(bot) : bot.name; -+ const secondLine = awayLine || preview || (remote ? bot.remote?.gone ? t("no longer shared") : t("Friend's Bot") : ""); -+ const label = [shown, main ? t("Main Bot") : "", pinned ? t("Pinned") : "", running || away ? t("Working") : "", waiting ? t("Waiting for your approval") : alert ? t("Waiting for your answer") : "", unread ? t("Unread activity") : ""].filter(Boolean).join(", "); -+ if (tile && wide) { -+ return (0, import_react17.createElement)("div", { className: "bt-tile-wrap" }, (0, import_react17.createElement)( -+ "button", -+ { -+ type: "button", -+ className: "bt-tile", -+ "aria-label": label, -+ "aria-current": active ? "page" : void 0, -+ "data-bot-id": id, -+ title: preview || bot.name, -+ onClick: () => actions.openSession(id), -+ onContextMenu -+ }, -+ (0, import_react17.createElement)(BotAvatar, { bot, size: 60, state, main: true }), -+ (0, import_react17.createElement)("span", { className: "bt-tile-name" }, shown), -+ // The Main Bot's tile gets the same unread dot as a row (#420); a wait shows on its avatar. -+ unread && !waiting && !active ? (0, import_react17.createElement)("span", { className: "bt-unread bt-tile-unread", "aria-hidden": true }) : null, -+ bot.role ? (0, import_react17.createElement)("span", { className: "bt-chip" }, bot.role) : null -+ )); -+ } -+ const unreadMark = wide && waiting && !active ? (0, import_react17.createElement)("span", { className: "bt-unread bt-unread-wait", "aria-hidden": true }) : wide && unread && !active ? (0, import_react17.createElement)("span", { className: "bt-unread", "aria-hidden": true }) : null; -+ const row = (0, import_react17.createElement)( +-function SecretHead({ bot, title, detail, name, onClose }) { +- return (0, import_react5.createElement)( ++var elapsedSince = (now, at) => typeof at === "number" && now - at >= 6e4 ? elapsedLabel(now - at) : ""; ++function WorkRow({ bot, tool, step, label, at, now, onOpen }) { ++ const text2 = `${label} · ${workText(tool, step)}`; ++ const elapsed = elapsedSince(now, at); ++ return (0, import_react13.createElement)( + "button", -+ { -+ type: "button", -+ className: "bt-row", -+ "aria-label": label, -+ "aria-current": active ? "page" : void 0, -+ // An own Bot's hover text names its model (#366); a friend's Bot runs on its owner's model. -+ "data-bot-id": id, -+ title: remote ? shown : botRowTitle(shown, modelOf(bot, roster)?.name, wide), -+ onClick: () => actions.openSession(id), -+ onContextMenu -+ }, -+ (0, import_react17.createElement)(BotAvatar, { bot, size: 36, state, main }), -+ pinned && !wide ? (0, import_react17.createElement)(PinMark) : null, -+ wide ? (0, import_react17.createElement)( -+ "span", -+ { className: "bt-row-body" }, -+ (0, import_react17.createElement)( -+ "span", -+ { className: "bt-row-name" }, -+ (0, import_react17.createElement)("span", { className: "bt-row-title" }, shown), -+ bot.remote ? (0, import_react17.createElement)(RemoteMark, { remote: bot.remote }) : null, -+ pinned ? (0, import_react17.createElement)(PinMark) : null -+ ), -+ secondLine ? (0, import_react17.createElement)("span", { className: "bt-row-preview" }, secondLine) : null -+ ) : null, -+ unreadMark ++ { type: "button", className: "bt-mw-row", "aria-label": [text2, elapsed].filter(Boolean).join(" · "), onClick: onOpen }, ++ (0, import_react13.createElement)(BotMark, { bot, size: 22, state: toolState(tool ?? ""), live: true }), ++ (0, import_react13.createElement)("span", { className: "bt-mw-row-text" }, (0, import_react13.createElement)(Shimmer, null, text2)), ++ elapsed ? (0, import_react13.createElement)("span", { className: "bt-mw-row-time" }, `· ${elapsed}`) : null + ); -+ const settingsLabel = bot.remote ? t("Bot settings") : t("Edit Bot"); -+ const settings = wide ? (0, import_react17.createElement)("button", { -+ type: "button", -+ className: "bt-row-settings", -+ "aria-label": settingsLabel, -+ title: settingsLabel, -+ onFocus: (event) => keepSidebarStill(event.currentTarget), -+ onClick: (event) => { -+ event.stopPropagation(); -+ keepSidebarStill(event.currentTarget); -+ actions.openSettings("bots", id); -+ } -+ }, (0, import_react17.createElement)(GearIcon)) : null; -+ if (settings) return (0, import_react17.createElement)("div", { className: "bt-row-wrap", "data-bot-id": id }, row, settings); -+ return row; +} -+function keepSidebarStill(node) { -+ const reset = () => { -+ for (let at = node?.parentElement; at; at = at.parentElement) if (at.scrollLeft > 0) at.scrollLeft = 0; -+ const page = typeof document === "undefined" ? null : document.scrollingElement; -+ if (page && page.scrollLeft > 0) page.scrollLeft = 0; -+ }; -+ reset(); -+ if (typeof requestAnimationFrame === "function") requestAnimationFrame(() => requestAnimationFrame(reset)); - } --var SEPARATOR_GAP_MS = 30 * 60 * 1e3; --function clockLabel(time, now = Date.now()) { -- const date = new Date(time); -- const midnight = (value) => new Date(value.getFullYear(), value.getMonth(), value.getDate()).getTime(); -- const days = Math.round((midnight(new Date(now)) - midnight(date)) / 864e5); -- const clock4 = date.toLocaleTimeString(dateLocale(), { hour: "numeric", minute: "2-digit" }); -- if (days === 0) return t("Today {time}", { time: clock4 }); -- if (days === 1) return t("Yesterday {time}", { time: clock4 }); -- const sameYear = date.getFullYear() === new Date(now).getFullYear(); -- return `${date.toLocaleDateString(dateLocale(), { weekday: "short", month: "short", day: "numeric", ...sameYear ? {} : { year: "numeric" } })} ${clock4}`; -+function RemoteMark({ remote }) { -+ return (0, import_react17.createElement)( -+ "span", -+ { className: "bt-row-remote", title: remote.gone ? `${remote.handle} · ${t("no longer shared")}` : remote.handle, "aria-label": t("Friend's Bot") }, -+ (0, import_react17.createElement)( -+ "svg", -+ { width: 11, height: 11, viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: 2, strokeLinecap: "round", "aria-hidden": true }, -+ (0, import_react17.createElement)("circle", { cx: 12, cy: 12, r: 9 }), -+ (0, import_react17.createElement)("path", { d: "M3 12h18M12 3a14 14 0 0 1 0 18M12 3a14 14 0 0 0 0 18" }) -+ ) ++function SummaryLine({ busy, now, onOpen }) { ++ const { waiting, doing } = summaryText(busy); ++ const elapsed = doing ? elapsedSince(now, busy.find((entry) => entry.state === "work")?.doing.since) : ""; ++ return (0, import_react13.createElement)( ++ "button", ++ { type: "button", className: "bt-mw-row bt-mw-line", "aria-label": [waiting, doing, elapsed].filter(Boolean).join(" · "), onClick: onOpen }, ++ (0, import_react13.createElement)("span", { className: "bt-mw-stack", "aria-hidden": true }, busy.slice(0, 4).map((entry) => (0, import_react13.createElement)(Face, { key: entry.id, entry, size: 16 }))), ++ (0, import_react13.createElement)( ++ "span", ++ { className: "bt-mw-row-text" }, ++ waiting ? (0, import_react13.createElement)("span", { className: "bt-mw-ask" }, waiting) : null, ++ waiting && doing ? " · " : null, ++ doing ? (0, import_react13.createElement)(Shimmer, null, doing) : null ++ ), ++ elapsed ? (0, import_react13.createElement)("span", { className: "bt-mw-row-time" }, `· ${elapsed}`) : null + ); ++} ++function MemberDock({ sessionId, useRoster, useWork, actions }) { ++ const roster = useRoster((value) => value); ++ const work = useWork((value) => value); ++ const room2 = roster.roomsById[sessionId]; ++ const bot = room2 ? void 0 : roster.byId[sessionId]; ++ const ownChat = bot !== void 0 && work.bots?.[bot.id] !== void 0 && work.bots[bot.id].roomId === void 0; ++ const busy = room2 ? busyMembers(room2, work, roster) : []; ++ const groups = bot !== void 0 && !ownChat ? botGroupWork(bot.id, work, roster) : []; ++ const shown = busy.length > 0 || groups.length > 0; ++ const now = useNow(shown); ++ const ref = (0, import_react13.useRef)(null); ++ useFlowColumn(ref, shown, sessionId); ++ if (!shown) return null; ++ if (room2) { ++ const { rows: rows2, rest } = splitBusy(busy); ++ const open = () => actions.showDetails(sessionId); ++ return (0, import_react13.createElement)( ++ "div", ++ { ref, className: "bt-mw-dock-stack" }, ++ rows2.map((entry) => (0, import_react13.createElement)(WorkRow, { key: entry.id, bot: entry.bot, tool: entry.doing.tool, step: entry.doing.step, label: entry.bot.name, at: entry.doing.since, now, onOpen: open })), ++ rest.length > 0 ? (0, import_react13.createElement)(SummaryLine, { busy: rest, now, onOpen: open }) : null ++ ); ++ } ++ const rows = groups.slice(0, ROWS_MAX); ++ const more = groups.length - rows.length; ++ return (0, import_react13.createElement)( + "div", +- { className: "bt-q-head bt-secret-head" }, +- (0, import_react5.createElement)("span", { className: "bt-secret-who" }, (0, import_react5.createElement)(BotMark, { bot, size: 28 }), (0, import_react5.createElement)("span", { className: "bt-secret-lock" }, (0, import_react5.createElement)(LockIcon))), +- (0, import_react5.createElement)( ++ { ref, className: "bt-mw-dock-stack" }, ++ rows.map((item) => (0, import_react13.createElement)(WorkRow, { ++ key: item.room.id, ++ bot, ++ tool: item.tool, ++ step: item.step, ++ label: t("in {name}", { name: item.room.name }), ++ at: item.since, ++ now, ++ onOpen: () => actions.openSession(item.room.id) ++ })), ++ more > 0 ? (0, import_react13.createElement)( + "div", +- { className: "bt-q-text" }, +- (0, import_react5.createElement)("p", { className: "bt-q-title" }, title), +- (0, import_react5.createElement)("div", { className: "bt-q-detail" }, (0, import_react5.createElement)("code", { className: "bt-secret-name" }, name), detail ? ` · ${detail}` : "") +- ), +- onClose ? (0, import_react5.createElement)("button", { type: "button", className: "bt-q-x", "aria-label": t("Cancel the request"), title: t("Cancel"), onClick: onClose }, (0, import_react5.createElement)(CloseIcon)) : null ++ { className: "bt-mw-row bt-mw-line bt-mw-static" }, ++ (0, import_react13.createElement)("span", { className: "bt-mw-row-text" }, moreRooms(more)) ++ ) : null + ); } --function nextTurnStart(turns, turn) { -- let next; -- for (const key in turns) { -- const candidate = Number(key); -- if (candidate > turn && turns[key].first !== void 0 && (next === void 0 || candidate < next)) next = candidate; -- } -- return next === void 0 ? void 0 : turns[next].first; -+function isPastFaceRoom(room2) { -+ if (room2?.origin !== "face-to-face") return false; -+ const status = room2.face?.status; -+ if (status === "closed" || status === "left") return true; -+ const expires = room2.face?.expiresAt; -+ return status === "candidate" && expires && Number.isFinite(Date.parse(expires)) && Date.parse(expires) <= Date.now(); - } --function openingTurn(turns, firstTurns, hiddenTurns) { -- if (!turns || !firstTurns) return null; -- const start = nextTurnStart(turns, -Infinity); -- if (start === void 0) return null; -- const turn = Number(Object.keys(turns).filter((key) => turns[key].first === start)[0]); -- const listed = firstTurns.split(",").map(Number); -- const earlier = listed.filter((entry) => entry < turn); -- return listed.includes(turn) && earlier.every((entry) => hiddenTurns?.includes(entry)) ? { turn, start } : null; -+function FaceMark() { -+ return (0, import_react17.createElement)( -+ "span", -+ { className: "bt-row-remote bt-row-face", title: t("Face-to-face"), "aria-label": t("Face-to-face") }, -+ (0, import_react17.createElement)( -+ "svg", -+ { width: 11, height: 11, viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: 2, strokeLinecap: "round", "aria-hidden": true }, -+ (0, import_react17.createElement)("circle", { cx: 9, cy: 8, r: 3 }), -+ (0, import_react17.createElement)("circle", { cx: 16, cy: 9, r: 2.5 }), -+ (0, import_react17.createElement)("path", { d: "M4 18a5 5 0 0 1 10 0M14 18a4 4 0 0 1 6 0" }) -+ ) -+ ); +-function SecretCard({ bot, request, roster, actions }) { +- const [value, setValue] = (0, import_react5.useState)(""); +- const [scope, setScope] = (0, import_react5.useState)("all"); +- const [replacing, setReplacing] = (0, import_react5.useState)(false); +- const [busy, setBusy] = (0, import_react5.useState)(false); +- const [error, setError] = (0, import_react5.useState)(""); +- const noteId = (0, import_react5.useId)(); +- const secret = (roster.secrets ?? []).find((entry) => entry.name === request.name); +- const run = (promise) => { ++function useFlowColumn(ref, active, key) { ++ (0, import_react13.useLayoutEffect)(() => { ++ const node = ref.current; ++ const flow = document.querySelector("[data-chat-flow]"); ++ if (!active || !node || !flow) return void 0; ++ const place = () => { ++ node.style.marginInlineStart = "0px"; ++ const column = flow.getBoundingClientRect(); ++ node.style.marginInlineStart = `${Math.round(column.left - node.getBoundingClientRect().left)}px`; ++ node.style.width = `${Math.round(column.width)}px`; ++ }; ++ place(); ++ const observer = new ResizeObserver(place); ++ for (const target of [flow, flow.parentElement, node.parentElement]) if (target) observer.observe(target); ++ return () => observer.disconnect(); ++ }, [active, key]); ++} ++function useNow(active) { ++ const [now, setNow] = (0, import_react13.useState)(Date.now); ++ (0, import_react13.useEffect)(() => { ++ if (!active) return void 0; ++ const timer = setInterval(() => setNow(Date.now()), 3e4); ++ return () => clearInterval(timer); ++ }, [active]); ++ return now; ++} ++var since = (now, at) => typeof at === "number" && now - at >= 6e4 ? ` · ${elapsedLabel(now - at)}` : ""; ++var alwaysChoices = (approval, bot) => bot?.remote ? [] : [ ++ ["always-bot", "Always allow for this Bot"], ++ ...approval.roomId ? [["always-room", "Always allow for this group"]] : [] ++]; ++function ApprovalCard({ approval, bot, actions }) { ++ const [busy, setBusy] = (0, import_react13.useState)(false); ++ const answer = (choice) => { + setBusy(true); +- setError(""); +- return Promise.resolve(promise).then(() => setValue(""), (failure) => setError(failure?.message ?? String(failure))).finally(() => setBusy(false)); +- }; +- const cancel = () => run(actions.secretCancel(request.id)); +- const long = value.trim().length >= MIN_VALUE; +- const save = () => { +- if (long && !busy) void run(actions.secretSet({ requestId: request.id, value, scope })); +- }; +- const allow = () => { +- if (!busy) void run(actions.secretAllow(request.id)); ++ void Promise.resolve(actions.answerApproval(approval.id, choice)).catch((error) => actions.reportError(error, "approval")).finally(() => setBusy(false)); + }; +- const filling = request.kind === "fill" || replacing; +- const warn = insecure() ? (0, import_react5.createElement)("div", { className: "bt-secret-warn", role: "note" }, (0, import_react5.createElement)(WarnIcon), t("This page is not on HTTPS: the key crosses the network unencrypted.")) : null; +- return (0, import_react5.createElement)( ++ return (0, import_react13.createElement)( + "div", +- { className: "bt-q bt-secret", role: "group", "aria-describedby": noteId, "data-kind": request.kind }, +- (0, import_react5.createElement)(SecretHead, { +- bot, +- title: request.kind === "allow" ? t("Let {bot} use {name}?", { bot: bot.name, name: request.name }) : t("{name} needs a key", { name: bot.name }), +- detail: request.purpose, +- name: request.name, +- onClose: busy ? void 0 : cancel +- }), +- request.kind === "allow" ? (0, import_react5.createElement)("div", { className: "bt-secret-saved" }, (0, import_react5.createElement)(KeyIcon), t("Saved for {scope}", { scope: scopeLabel(secret?.scope, roster) })) : null, +- filling ? (0, import_react5.createElement)( +- "div", +- { className: "bt-secret-body" }, +- (0, import_react5.createElement)("label", { className: "bt-q-field bt-secret-field" }, (0, import_react5.createElement)(MaskedInput, { value, onChange: setValue, onEnter: save, label: t("Value of {name}", { name: request.name }), autoFocus: replacing })), +- request.kind === "fill" ? (0, import_react5.createElement)(ScopeSwitch, { value: scope, onChange: setScope, bot }) : null +- ) : null, +- (0, import_react5.createElement)( ++ { className: "bt-mw-card", role: "group", "aria-label": t("{name} is waiting for your approval", { name: bot.name }) }, ++ (0, import_react13.createElement)("div", { className: "bt-mw-tool" }, approval.tool), ++ approval.detail?.text ? (0, import_react13.createElement)("div", { className: "bt-mw-text" }, approval.detail.text) : null, ++ approval.reason ? (0, import_react13.createElement)("div", { className: "bt-mw-reason" }, approval.reason) : null, ++ approval.detail?.code ? (0, import_react13.createElement)("code", { className: "bt-mw-code", title: approval.detail.code }, approval.detail.code) : null, ++ approval.answerable ? (0, import_react13.createElement)( + "div", +- { className: "bt-secret-note", id: noteId }, +- t("{bot} never sees the value. It reaches {bot}'s shell as ", { bot: bot.name }), +- (0, import_react5.createElement)("code", null, `$DSH_SECRET_${request.name}`), +- ".", +- filling && value !== "" && !long ? t(" At least {count} characters.", { count: MIN_VALUE }) : "" +- ), +- warn, +- error ? (0, import_react5.createElement)("div", { className: "bt-secret-error", role: "alert" }, error) : null, +- (0, import_react5.createElement)( ++ { className: "bt-mw-actions" }, ++ (0, import_react13.createElement)("button", { type: "button", className: "bt-q-btn", disabled: busy, onClick: () => answer("reject") }, t("Reject")), ++ (0, import_react13.createElement)("button", { type: "button", className: "bt-q-submit", disabled: busy, onClick: () => answer("allow") }, t("Allow once")), ++ ...alwaysChoices(approval, bot).map(([choice, label]) => (0, import_react13.createElement)("button", { key: choice, type: "button", className: "bt-q-btn", "data-choice": choice, disabled: busy, onClick: () => answer(choice) }, t(label))) ++ ) : (0, import_react13.createElement)( + "div", +- { className: "bt-q-foot" }, +- request.kind === "allow" && !replacing ? (0, import_react5.createElement)("button", { type: "button", className: "bt-q-btn", disabled: busy, onClick: () => setReplacing(true) }, t("Replace value")) : null, +- (0, import_react5.createElement)("button", { type: "button", className: "bt-q-btn", disabled: busy, onClick: cancel }, t("Cancel")), +- filling ? (0, import_react5.createElement)("button", { type: "button", className: "bt-q-submit", disabled: !long || busy, onClick: save }, busy ? t("Saving…") : t("Save")) : (0, import_react5.createElement)("button", { type: "button", className: "bt-q-submit", disabled: busy, onClick: allow }, busy ? t("Allowing…") : t("Allow")) ++ { className: "bt-mw-actions" }, ++ (0, import_react13.createElement)("button", { type: "button", className: "bt-q-btn", onClick: () => actions.openSession(bot.id) }, t("Open {name}'s chat", { name: bot.name })) + ) + ); } --var unseenSince = (visit, time) => typeof visit?.seen === "number" && time > visit.seen && time <= visit.entered; --function TurnTailCell({ turn: location2, sessionId, useTurnClock, useUi, actions }) { -- const turn = location2.turn; -- const time = location2.end.time; -- (0, import_react6.useEffect)(() => { -- actions.noteTurnTime(sessionId, turn, "end", time); -- }, [sessionId, turn, time]); -- const label = useTurnClock((state) => { -- const start = nextTurnStart(state[sessionId] ?? {}, turn); -- return start !== void 0 && start - time >= SEPARATOR_GAP_MS ? clockLabel(start) : ""; -- }); -- const visit = useUi((state) => state.visits?.[sessionId]); -- const fresh = useTurnClock((state) => { -- if (typeof visit?.seen !== "number" || time > visit.seen) return false; -- const start = nextTurnStart(state[sessionId] ?? {}, turn); -- return start !== void 0 && start > visit.seen && start <= visit.entered; -- }); -- if (!label && !fresh) return null; -- return (0, import_react6.createElement)( -- import_react6.Fragment, -- null, -- label ? (0, import_react6.createElement)("div", { className: "bt-time-sep", role: "separator" }, label) : null, -- fresh ? (0, import_react6.createElement)(NewDivider) : null -+function RoomRow({ room: room2, roster, actions, useSessions, useSessionStatus, useSessionRetainInfo, useWork, wide, marked }) { -+ const id = room2.id; -+ const fallback = useSessions((list) => nameMentions(previewOf(list, id, [], [], botNames(roster)), roster)); -+ const preview = nameMentions(visibleText(room2.preview), roster) || fallback; -+ const active = useSessionRetainInfo(id, (info) => (info?.retainedBy?.mainView ?? 0) > 0); -+ const unread = useSessionStatus((map) => map.get(id)?.completionUnread === true) || marked && !active; -+ useMarkedUnread(id, active, marked, actions); -+ const approval = useApproval(useWork, (entry) => entry.roomId === id); -+ const question = roster.questions?.[id] !== void 0; -+ const members = workKeys(useGroupWork(useWork, (rooms) => (rooms[id] ?? []).map((entry) => `${entry.botId}|${entry.tool ?? ""}`))); -+ const first = roster.byId[members[0]?.id]; -+ const workingLine = first ? workLine(first.name, members[0].tool, members.length - 1) : null; -+ if (room2.hidden && !active) return null; -+ const pinned = room2.pinned === true; -+ const title = roomTitle(room2); -+ const from = room2.sharedFrom ? sharedRoomTitle(room2.sharedFrom.inviterName) : ""; -+ const hover = from && from !== title ? `${title} · ${from}` : title; -+ const waiting = approval || (room2.pendingCount ?? 0) > 0; -+ const alert = question || waiting; -+ const face = room2.origin === "face-to-face"; -+ const label = [hover, face ? t("Face-to-face") : "", pinned ? t("Pinned") : "", members.length > 0 ? t("Working") : "", waiting ? t("Waiting for your approval") : alert ? t("Waiting for your answer") : ""].filter(Boolean).join(", "); -+ return (0, import_react17.createElement)( -+ "button", -+ { -+ type: "button", -+ className: "bt-row", -+ "aria-label": label, -+ "aria-current": active ? "page" : void 0, -+ // The full title on hover. The row wraps the number-first form so the second -+ // name is not cut to "SelfB…" (#1102, #304, #458). -+ title: hover, -+ "data-room-id": id, -+ onClick: () => actions.openSession(id), -+ onContextMenu: (event) => { -+ event.preventDefault(); -+ actions.openMenu({ kind: "room", id, x: event.clientX, y: event.clientY }); -+ } -+ }, -+ (0, import_react17.createElement)(RoomAvatar, { room: room2, roster, size: 36 }), -+ pinned && !wide ? (0, import_react17.createElement)(PinMark) : null, -+ wide ? (0, import_react17.createElement)( -+ "span", -+ { className: "bt-row-body" }, -+ (0, import_react17.createElement)("span", { className: "bt-row-name" }, (0, import_react17.createElement)("span", { className: face ? "bt-row-title bt-row-title-face" : "bt-row-title" }, face ? faceRowTitle(title) : title), face ? (0, import_react17.createElement)(FaceMark) : room2.sharedFrom ? (0, import_react17.createElement)("span", { className: "bt-tag", title: from }, t("Shared")) : null, pinned ? (0, import_react17.createElement)(PinMark) : null), -+ workingLine ? (0, import_react17.createElement)("span", { className: "bt-row-preview" }, workingLine) : preview ? (0, import_react17.createElement)("span", { className: "bt-row-preview" }, preview) : null -+ ) : null, -+ wide && alert && !active ? (0, import_react17.createElement)("span", { className: "bt-unread bt-unread-wait", "aria-hidden": true }) : wide && unread && !active ? (0, import_react17.createElement)("span", { className: "bt-unread", "aria-hidden": true }) : null +-function SettledSecret({ bot, request, roster }) { +- const canceled = request.status === "canceled"; +- const text = canceled ? t("{bot} asked for {name}", { bot: bot.name, name: request.name }) : request.status === "allowed" ? t("Allowed {bot} to use {name}", { bot: bot.name, name: request.name }) : t("Saved {name}", { name: request.name }); +- return (0, import_react5.createElement)( ++function MemberWork({ entry, bot, room: room2, actions, now }) { ++ const [stopping, setStopping] = (0, import_react13.useState)(false); ++ if (entry.state === "ask") { ++ return (0, import_react13.createElement)( ++ "div", ++ { className: "bt-mw-member" }, ++ (0, import_react13.createElement)("div", { className: "bt-mw-state bt-mw-ask" }, `${t("Waiting for your approval")}${since(now, entry.approvals[0].at)}`), ++ entry.approvals.map((approval) => (0, import_react13.createElement)(ApprovalCard, { key: approval.id, approval, bot, actions })) ++ ); ++ } ++ if (entry.state !== "work") return null; ++ const stop = () => { ++ setStopping(true); ++ void Promise.resolve(actions.stopMemberWork(room2.id, bot.id)).catch((error) => actions.reportError(error, "stop")).finally(() => setStopping(false)); ++ }; ++ return (0, import_react13.createElement)( + "div", +- { className: "bt-q bt-q-settled bt-secret bt-secret-done", role: "group", "aria-label": text, "data-status": request.status }, +- (0, import_react5.createElement)( ++ { className: "bt-mw-member" }, ++ (0, import_react13.createElement)( + "div", +- { className: "bt-q-head" }, +- (0, import_react5.createElement)("span", { className: "bt-secret-who" }, (0, import_react5.createElement)(BotMark, { bot, size: 22 })), +- (0, import_react5.createElement)("div", { className: "bt-q-text" }, (0, import_react5.createElement)( +- "p", +- { className: "bt-q-title" }, +- text, +- canceled ? null : (0, import_react5.createElement)("span", { className: "bt-secret-scope-tag" }, ` · ${scopeLabel(request.scope, roster)}`) +- )), +- canceled ? (0, import_react5.createElement)("span", { className: "bt-q-badge" }, t("Canceled")) : (0, import_react5.createElement)("span", { className: "bt-q-check bt-secret-check", title: t("Done") }, (0, import_react5.createElement)(CheckIcon)) ++ { className: "bt-mw-state bt-mw-work" }, ++ (0, import_react13.createElement)("span", null, (0, import_react13.createElement)(Shimmer, null, workText(entry.doing.tool, entry.doing.step)), since(now, entry.doing.since)), ++ entry.here && entry.doing.handed !== true ? (0, import_react13.createElement)("button", { type: "button", className: "bt-mw-stop", disabled: stopping, onClick: stop }, t("Stop")) : null + ) ); } --function Shimmer({ children }) { -- return (0, import_react6.createElement)("span", { className: "bt-shimmer" }, children); -+function AgentSessionRow({ session, agent, actions, renaming, index }) { -+ const input = (0, import_react17.useRef)(null); -+ (0, import_react17.useLayoutEffect)(() => { -+ if (!renaming) return; -+ input.current?.focus(); -+ input.current?.select(); -+ }, [renaming]); -+ if (renaming) { -+ const commit = (keep) => { -+ const value = input.current?.value ?? ""; -+ if (!keep || value.trim() === "" || value.trim() === session.title) { -+ actions.endAgentRename(); -+ return; +-function RequestView({ bot, request, roster, actions }) { +- return request.status === "pending" ? (0, import_react5.createElement)(SecretCard, { key: `${request.id}:${request.kind}`, bot, request, roster, actions }) : (0, import_react5.createElement)(SettledSecret, { bot, request, roster }); +-} +-function SecretToolCell({ sessionId, callId, name, done, content, roster, actions }) { +- const bot = roster.byId[sessionId]; +- if (!bot) return null; +- const request = (roster.secretRequests?.[bot.id] ?? []).find((entry) => entry.callId === callId && (entry.sessionId ?? sessionId) === sessionId); +- if (request) return (0, import_react5.createElement)("div", { className: "bt-q-inline" }, (0, import_react5.createElement)(RequestView, { bot, request, roster, actions })); +- if (done && /already saved/.test(contentText(content))) { +- return (0, import_react5.createElement)("div", { className: "bt-event" }, (0, import_react5.createElement)("span", { className: "bt-secret-event" }, (0, import_react5.createElement)(KeyIcon), t("{bot} is using {name}", { bot: bot.name, name: name ?? t("a saved key") }))); +- } +- return null; ++function useMemberWork(room2, useWork) { ++ const work = useWork?.((value) => value) ?? NO_WORK; ++ const entries2 = room2 ? memberWork(room2, work) : []; ++ const now = useNow(entries2.some((entry) => entry.state !== "idle")); ++ return { entries: entries2, now }; ++} ++var MEMBER_CSS = ` ++.bt-mw-dock-stack{box-sizing:border-box;display:flex;flex-direction:column;align-items:flex-start;gap:2px;width:var(--dsh-chat-content-width,min(960px,100%));margin:0 auto 6px;pointer-events:none} ++.bt-mw-row{pointer-events:auto;display:flex;align-items:center;gap:10px;min-width:0;max-width:calc(100% + 6px);height:32px;margin-inline-start:-6px;padding:0 12px 0 6px;border:0;border-radius:16px;background:none;color:var(--bt-ink-2);font:inherit;font-size:14px;cursor:pointer;animation:bt-mw-in .32s cubic-bezier(.23,1,.32,1) backwards;transition:background-color .16s ease-out} ++.bt-mw-row:hover{background:var(--bt-hover)} ++.bt-mw-static{cursor:default;padding-inline-start:38px} ++.bt-mw-static:hover{background:none} ++.bt-mw-row>.bt-mark{flex:none} ++.bt-mw-row-text{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} ++.bt-mw-row-time{flex:none;font-size:12px;color:var(--bt-ink-3);white-space:nowrap} ++.bt-mw-work{display:flex;align-items:center;justify-content:space-between;gap:8px} ++.bt-mw-stop{flex:none;height:22px;padding:0 10px;border:1px solid var(--bt-line,rgba(0,0,0,.12));border-radius:11px;background:none;color:var(--bt-ink-2);font:inherit;font-size:12px;cursor:pointer} ++.bt-mw-stop:hover{background:var(--bt-hover)} ++.bt-mw-stop:disabled{opacity:.5;cursor:default} ++.bt-mw-line{gap:8px} ++.bt-mw-stack{display:inline-flex;align-items:center;flex:none;line-height:0} ++.bt-mw-stack>*+*{margin-left:-4px} ++.bt-mw-face{position:relative;display:inline-flex;flex:none;border-radius:50%} ++.bt-mw-stack .bt-mw-face{box-shadow:0 0 0 1.5px var(--bt-main,#fff)} ++.bt-mw-face[data-ask]::after{content:"";position:absolute;right:-2px;bottom:-2px;width:7px;height:7px;border-radius:50%;background:#e3a008;box-shadow:0 0 0 1.5px var(--bt-main,#fff)} ++.bt-mw-ask{color:#b7791f;flex:none;white-space:nowrap} ++body[data-ds-dark-theme] .bt-mw-ask{color:#f0b84a} ++.bt-mw-member{padding:0 6px 8px 42px;display:flex;flex-direction:column;gap:6px} ++.bt-mw-state{font-size:12px;line-height:16px;color:var(--bt-ink-2);margin-top:-4px} ++.bt-mw-card{display:flex;flex-direction:column;gap:4px;padding:10px 12px;border-radius:12px;background:var(--bt-hover);font-size:12px;line-height:17px;color:var(--bt-ink-2)} ++.bt-mw-tool{font-weight:600;color:var(--bt-ink)} ++.bt-mw-text{color:var(--bt-ink);overflow-wrap:anywhere} ++.bt-mw-reason{overflow-wrap:anywhere;display:-webkit-box;-webkit-line-clamp:3;-webkit-box-orient:vertical;overflow:hidden} ++.bt-mw-code{display:block;font:11px/16px ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;color:var(--bt-ink);white-space:pre-wrap;overflow-wrap:anywhere;max-height:120px;overflow:auto} ++.bt-mw-actions{display:flex;flex-wrap:wrap;justify-content:flex-end;gap:6px;margin-top:4px} ++.bt-mw-actions>button{height:28px;padding:0 12px;font-size:12px} ++.bt-mw-actions>button:disabled{opacity:.5;cursor:default} ++@keyframes bt-mw-in{from{opacity:0;transform:translateY(4px)}} ++@media (prefers-reduced-motion:reduce){.bt-mw-row{animation:none}} ++`; ++ ++// src/shared/task-board.js ++var COLUMNS = ["not-started", "in-progress", "done"]; ++var PRIORITIES = ["urgent", "high", "normal", "low"]; ++var COLUMN_OF = { ++ open: "not-started", ++ claimed: "not-started", ++ working: "in-progress", ++ blocked: "in-progress", ++ "result-ready": "in-progress", ++ accepted: "done", ++ cancelled: "done", ++ failed: "done" ++}; ++function columnOf(task) { ++ return COLUMN_OF[task?.status] ?? "not-started"; ++} ++function occupiesSlot(task) { ++ return task?.status === "claimed" || task?.status === "working"; ++} ++function priorityOf(task) { ++ return PRIORITIES.includes(task?.priority) ? task.priority : "normal"; ++} ++var PRIORITY_RANK = { urgent: 0, high: 1, normal: 2, low: 3 }; ++function workBucket(task) { ++ if (task?.status === "open" || task?.status === "claimed" || task?.status === "working") return 0; ++ if (task?.status === "blocked" || task?.status === "result-ready") return 1; ++ return 2; ++} ++function compareForWork(a, b) { ++ const byBucket = workBucket(a) - workBucket(b); ++ if (byBucket !== 0) return byBucket; ++ const byPriority = (PRIORITY_RANK[priorityOf(a)] ?? 2) - (PRIORITY_RANK[priorityOf(b)] ?? 2); ++ if (byPriority !== 0) return byPriority; ++ const byWorking = (a?.status === "working" ? 0 : 1) - (b?.status === "working" ? 0 : 1); ++ if (byWorking !== 0) return byWorking; ++ return (b?.updatedAt ?? 0) - (a?.updatedAt ?? 0); ++} ++function sameRole(a, b) { ++ const left = String(a ?? "").trim().toLowerCase(); ++ const right = String(b ?? "").trim().toLowerCase(); ++ return left !== "" && left === right; ++} ++function compareForQueue(a, b) { ++ const byPriority = (PRIORITY_RANK[priorityOf(a)] ?? 2) - (PRIORITY_RANK[priorityOf(b)] ?? 2); ++ if (byPriority !== 0) return byPriority; ++ return (a?.createdAt ?? 0) - (b?.createdAt ?? 0); ++} ++function progressOf(task) { ++ const value = task?.progress; ++ if (typeof value !== "number" || !Number.isFinite(value)) return columnOf(task) === "done" && task?.status !== "cancelled" && task?.status !== "failed" ? 100 : 0; ++ return Math.max(0, Math.min(100, Math.round(value))); ++} ++ ++// src/client/inbox.js ++var import_react14 = require("react"); ++var KEEP_MS = 7 * 864e5; ++var POLL_MS3 = 3e4; ++var DONE_MAX = 20; ++var FEEDBACK_MS = 5e3; ++var line = (d) => (0, import_react14.createElement)("path", { d }); ++var icon = (...paths) => function StatusIcon() { ++ return (0, import_react14.createElement)("svg", { width: 14, height: 14, viewBox: "0 0 20 20", fill: "none", stroke: "currentColor", strokeWidth: 1.8, strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": true }, paths.map(line)); ++}; ++var RING = "M10 3.25a6.75 6.75 0 1 1 0 13.5 6.75 6.75 0 0 1 0-13.5z"; ++var InboxIcon = icon("M3.5 11.5l1.9-6.1A1.5 1.5 0 0 1 6.83 4.35h6.34a1.5 1.5 0 0 1 1.43 1.05l1.9 6.1", "M3.5 11.5v3.25a1.5 1.5 0 0 0 1.5 1.5h10a1.5 1.5 0 0 0 1.5-1.5V11.5", "M3.5 11.5h3.75l1 1.75h3.5l1-1.75h3.75"); ++var AskIcon = icon(RING, "M10 6.75v3.75", "M10 13.25h.01"); ++var HeldIcon = icon(RING, "M10 6.5V10l2.25 1.5"); ++var QuestionIcon = icon(RING, "M8.1 8a2 2 0 1 1 2.65 1.9c-.45.18-.75.6-.75 1.1v.25", "M10 13.5h.01"); ++var ResultIcon = icon(RING, "M7 10.25l2 2 4-4.25"); ++var FailedIcon = icon(RING, "M7.75 7.75l4.5 4.5M12.25 7.75l-4.5 4.5"); ++var RunIcon = icon(RING, "M8.5 7.25v5.5l4.25-2.75z"); ++var DoneIcon = icon("M4.5 10.5l3.5 3.5 7.5-8"); ++var CancelledIcon = icon(RING, "M5.25 14.75l9.5-9.5"); ++var STATUSES = { ++ approval: { label: "Needs approval", tone: "ask", Icon: AskIcon }, ++ held: { label: "Waiting to send", tone: "ask", Icon: HeldIcon }, ++ input: { label: "Needs your answer", tone: "ask", Icon: QuestionIcon }, ++ result: { label: "Result ready", tone: "ask", Icon: ResultIcon }, ++ failed: { label: "Failed", tone: "danger", Icon: FailedIcon }, ++ running: { label: "Working", tone: "run", Icon: RunIcon }, ++ task: { label: "In progress", tone: "run", Icon: RunIcon }, ++ done: { label: "Done", tone: "done", Icon: DoneIcon }, ++ cancelled: { label: "Cancelled", tone: "muted", Icon: CancelledIcon } ++}; ++var GROUPS = [ ++ { id: "needs", title: "Needs you", empty: "Nothing needs you right now." }, ++ { id: "progress", title: "In progress", empty: "Nothing is running." }, ++ { id: "done", title: "Done", empty: "Nothing finished in the last 7 days." } ++]; ++var sharedTasks = []; ++var taskListeners = /* @__PURE__ */ new Set(); ++function publishTasks(list) { ++ sharedTasks = Array.isArray(list) ? list : []; ++ for (const listener of taskListeners) listener(sharedTasks); ++} ++function useTasks(actions) { ++ const [tasks, setTasks] = (0, import_react14.useState)(sharedTasks); ++ (0, import_react14.useEffect)(() => { ++ taskListeners.add(setTasks); ++ let stopped = false; ++ let timer; ++ const load = () => Promise.resolve(actions?.tasks?.({ archive: true })).then((list) => { ++ if (!stopped && list !== void 0) publishTasks(list); ++ }).catch(() => { ++ }).finally(() => { ++ if (!stopped) timer = setTimeout(load, POLL_MS3); ++ }); ++ load(); ++ return () => { ++ stopped = true; ++ clearTimeout(timer); ++ taskListeners.delete(setTasks); ++ }; ++ }, []); ++ return tasks; ++} ++var workflowsOf = (settled) => settled?.phase === "ready" && Array.isArray(settled.pack?.workflows) ? settled.pack.workflows : []; ++var sharedWorkflows = []; ++var workflowListeners = /* @__PURE__ */ new Set(); ++var workflowPoll = null; ++function publishWorkflows(settled) { ++ sharedWorkflows = workflowsOf(settled); ++ for (const listener of workflowListeners) listener(sharedWorkflows); ++} ++function useWorkflows(actions) { ++ const [workflows, setWorkflows] = (0, import_react14.useState)(sharedWorkflows); ++ (0, import_react14.useEffect)(() => { ++ workflowListeners.add(setWorkflows); ++ if (workflowPoll === null && typeof actions?.workflowState === "function") { ++ const load = () => Promise.resolve(actions.workflowState()).then(publishWorkflows, () => { ++ }); ++ load(); ++ workflowPoll = setInterval(load, POLL_MS3); ++ } ++ return () => { ++ workflowListeners.delete(setWorkflows); ++ if (workflowListeners.size === 0 && workflowPoll !== null) { ++ clearInterval(workflowPoll); ++ workflowPoll = null; + } -+ void (async () => { -+ try { -+ await actions.renameAgentSession(agent.id, session.id, value); -+ actions.endAgentRename(); -+ } catch (error) { -+ actions.reportError(error, "agent-rename"); -+ } -+ })(); + }; -+ return (0, import_react17.createElement)( -+ "div", -+ { className: "bt-agent-sub-row bt-agent-edit", style: { "--i": index } }, -+ (0, import_react17.createElement)("input", { -+ ref: input, -+ defaultValue: session.title, -+ "aria-label": t("Rename"), -+ onKeyDown: (event) => { -+ if (event.nativeEvent.isComposing) return; -+ if (event.key === "Enter") commit(true); -+ else if (event.key === "Escape") commit(false); -+ }, -+ onBlur: () => commit(true) -+ }) -+ ); ++ }, []); ++ return workflows; ++} ++var ownBot = (roster, id) => { ++ const bot = roster.byId?.[id]; ++ if (bot === void 0 || bot.remote) return void 0; ++ const me = roster.self?.memberId; ++ return me && bot.ownerId && bot.ownerId !== me ? void 0 : bot; ++}; ++var people = (roster, botId) => ({ assigner: roster.self?.memberId ?? "me", owner: roster.self?.memberId ?? "me", executingBot: botId ?? null }); ++var roomName = (roster, id) => roster.roomsById?.[id]?.name; ++var placeOf = (roster, roomId) => roomId && roomName(roster, roomId) ? t("in {name}", { name: roomName(roster, roomId) }) : ""; ++var lastWhy = (task) => [...task.actors ?? []].reverse().find((entry) => typeof entry.why === "string" && entry.why !== "")?.why ?? ""; ++var chatOfTask = (task) => task.origin?.roomId ?? task.origin?.sessionId ?? task.ownerBotId; ++var ownAskChat = (roster, bot, text2) => { ++ if (bot === void 0 || typeof text2 !== "string" || text2 === "") return void 0; ++ for (const [key, question] of Object.entries(roster.questions ?? {})) { ++ if (question == null || typeof question !== "object" || roster.roomsById?.[key]) continue; ++ if (question.question !== text2) continue; ++ if (key === bot.id || key === currentPart(bot)) return bot.id; ++ } ++ return void 0; ++}; ++var placeKey = (botId, roomId) => `${botId}|${roomId ?? ""}`; ++var partOf = (roster, chat) => roster.roomsById?.[chat] || !roster.byId?.[chat] ? chat : currentPart(roster.byId[chat]); ++var runningIn = (work, botId, roomId) => roomId === void 0 ? work.bots?.[botId]?.roomId === void 0 ? work.bots?.[botId] : void 0 : (work.rooms?.[roomId] ?? []).find((entry) => entry.botId === botId) ?? (work.bots?.[botId]?.roomId === roomId ? work.bots[botId] : void 0); ++function latestActivity(card, { roster, work, sessions }) { ++ const options = []; ++ if (card.kind === "task") options.push(lastWhy(card.task)); ++ if (card.kind === "run" || card.kind === "task") { ++ const entry = card.executingBot ? runningIn(work, card.executingBot, roster.roomsById?.[card.chat] ? card.chat : void 0) : void 0; ++ if (entry) options.push(`${workPhrase(entry.tool)}…`); ++ } ++ if (sessions?.byId && card.chat) options.push(previewOf(sessions, partOf(roster, card.chat), [], [], botNames(roster))); ++ return options.find((text2) => typeof text2 === "string" && text2 !== "" && text2 !== card.title && text2 !== card.reason) ?? ""; ++} ++function toolCalls(card, { roster, work }) { ++ const botId = card.bot?.id; ++ if (botId === void 0) return 0; ++ const roomId = roster.roomsById?.[card.chat] ? card.chat : void 0; ++ const running = runningIn(work, botId, roomId); ++ if (running !== void 0) return running.calls ?? 0; ++ let newest; ++ for (const entry of work.ended ?? []) { ++ if (entry.botId !== botId || (entry.roomId ?? "") !== (roomId ?? "")) continue; ++ if ((entry.at ?? 0) >= (newest?.at ?? -Infinity)) newest = entry; ++ } ++ return newest?.calls ?? 0; ++} ++var endedAt = (run) => [run?.finishedAt, run?.updatedAt, run?.at].find((at) => typeof at === "number"); ++function inboxModel({ roster, work = {}, tasks = [], sessions, workflows = [], now = Date.now() }) { ++ const needs = []; ++ const progress = []; ++ const done = []; ++ const fresh = (at) => typeof at === "number" && now - at <= KEEP_MS; ++ const waitingAt = /* @__PURE__ */ new Set(); ++ for (const approval of work.approvals ?? []) { ++ const bot = ownBot(roster, approval.botId); ++ if (bot === void 0) continue; ++ waitingAt.add(`${approval.botId}|${approval.roomId ?? ""}`); ++ needs.push({ ++ id: `approval:${approval.id}`, ++ kind: "approval", ++ status: "approval", ++ bot, ++ at: approval.at, ++ title: [approval.tool, approval.detail?.text].filter(Boolean).join(" · ") || t("A tool call"), ++ place: placeOf(roster, approval.roomId), ++ reason: approval.reason ?? "", ++ chat: approval.roomId ?? bot.id, ++ approval, ++ ...people(roster, bot.id) ++ }); ++ } ++ for (const [key, question] of Object.entries(roster.questions ?? {})) { ++ if (question == null || typeof question !== "object") continue; ++ const room2 = roster.roomsById?.[key]; ++ const bot = room2 ? ownBot(roster, question.botId) : (roster.bots ?? []).find((candidate) => ownBot(roster, candidate.id) && (candidate.id === key || currentPart(candidate) === key)); ++ if (bot === void 0) continue; ++ waitingAt.add(room2 ? `${bot.id}|${room2.id}` : `${bot.id}|`); ++ needs.push({ ++ id: `question:${question.id ?? key}`, ++ kind: "question", ++ status: "input", ++ bot, ++ ...room2 ? { room: room2 } : {}, ++ at: question.askedAt, ++ title: question.question ?? "", ++ reason: question.detail ?? "", ++ place: room2 ? placeOf(roster, key) : "", ++ chat: room2 ? key : bot.id, ++ sessionId: key, ++ question, ++ ...people(roster, bot.id) ++ }); ++ } ++ const held = (chat, count, extra) => needs.push({ ++ id: `held:${chat}`, ++ kind: "held", ++ status: "held", ++ chat, ++ at: void 0, ++ title: count === 1 ? t("1 message waits for your approval") : t("{count} messages wait for your approval", { count }), ++ hint: t("Approve it in the chat"), ++ ...extra ++ }); ++ for (const bot of roster.bots ?? []) { ++ const count = bot.pendingCount ?? 0; ++ if (count <= 0) continue; ++ if (bot.remote) held(bot.id, count, { bot, place: "", ...people(roster, null) }); ++ else if (ownBot(roster, bot.id)) held(bot.id, count, { bot, place: "", ...people(roster, bot.id) }); ++ } ++ for (const room2 of roster.rooms ?? []) { ++ const count = room2.pendingCount ?? 0; ++ if (count > 0) held(room2.id, count, { room: room2, place: placeOf(roster, room2.id), ...people(roster, null) }); ++ } ++ const seen = /* @__PURE__ */ new Set(); ++ const running = (botId, entry, roomId) => { ++ const bot = ownBot(roster, botId); ++ const key = `${botId}|${roomId ?? ""}`; ++ if (bot === void 0 || seen.has(key) || waitingAt.has(key)) return; ++ seen.add(key); ++ progress.push({ ++ id: `run:${key}`, ++ kind: "run", ++ status: "running", ++ bot, ++ at: entry.since, ++ since: entry.since, ++ title: `${workPhrase(entry.tool)}…`, ++ place: placeOf(roster, roomId), ++ chat: roomId ?? bot.id, ++ ...people(roster, bot.id) ++ }); ++ }; ++ for (const [botId, entry] of Object.entries(work.bots ?? {})) running(botId, entry, entry.roomId); ++ for (const [roomId, entries2] of Object.entries(work.rooms ?? {})) for (const entry of entries2) running(entry.botId, entry, roomId); ++ const lastEnded = /* @__PURE__ */ new Map(); ++ for (const entry of work.ended ?? []) { ++ const key = placeKey(entry.botId, entry.roomId); ++ if ((entry.at ?? 0) >= (lastEnded.get(key)?.at ?? -Infinity)) lastEnded.set(key, entry); ++ } ++ 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({ ++ id: `turn:${entry.id}`, ++ kind: "turn", ++ status: "failed", ++ bot, ++ at: entry.at, ++ title: asked || t("The last turn did not finish"), ++ reason: entry.failed, ++ place: placeOf(roster, entry.roomId), ++ chat, ++ ...people(roster, bot.id) ++ }); + } -+ return (0, import_react17.createElement)( -+ "button", -+ { -+ type: "button", -+ className: "bt-agent-sub-row", -+ "aria-current": session.active ? "page" : void 0, -+ style: { "--i": index }, -+ onClick: () => actions.openAgentSession(session.id), -+ onContextMenu: (event) => { -+ event.preventDefault(); -+ event.stopPropagation(); -+ actions.openMenu({ kind: "agent-session", id: session.id, agentId: agent.id, x: event.clientX, y: event.clientY }); -+ } -+ }, -+ (0, import_react17.createElement)("span", { className: "bt-agent-sub-title" }, session.title), -+ (0, import_react17.createElement)("span", { className: "bt-agent-sub-time" }, session.at > 0 ? relativeShort(session.at) : "") -+ ); - } --var PHRASES = { -- thinking: ["Thinking about it", "Weighing the options", "Working out a plan", "Sorting out the details"], -- working: ["On it", "Busy with it", "Moving ahead", "Taking care of it", "Making headway"] --}; --var TOOL_PHRASES = [ -- [/web_?search|search_?web/i, "Searching online"], -- [/fetch|browse|browser|web/i, "Reading a page"], -- [/shell|bash|pwsh|powershell|exec|command|terminal/i, "Running shell commands"], -- [/^(read|view|cat)\b|read_/i, "Opening a file"], -- [/grep|glob|list_dir|^ls$|find|search/i, "Looking through files"], -- [/write|edit|patch|apply|replace/i, "Writing changes"], -- [/image|photo|draw/i, "Working on an image"] --]; --var GROUP_PHRASES = { create_group: "Creating a group", update_group: "Updating the group", delete_group: "Deleting the group", post_to_group: "Posting in the group" }; --function toolActivity(name, arg, findBot, self) { -- switch (name) { -- case "ask_user": -- return null; -- case "request_secret": -- return null; -- case "list_secrets": -- return { label: t("Checking the keys"), state: "searching" }; -- case "ask_user_question": -- return { label: t("Waiting for your answer"), state: "alert" }; -- case "group_relay": -- return { relay: true, state: "orbit" }; -- case "message_bot": { -- const target = findBot(arg("to")); -- return { label: target ? t("Asking {name}", { name: target.name }) : t("Asking another Bot"), state: "sending" }; -- } -- case "stop_bot": { -- const target = findBot(arg("bot")); -- return { label: target ? t("Stopping {name}", { name: target.name }) : t("Stopping another Bot"), state: "sending" }; -- } -- case "create_bot": -- return { label: t("Creating {name}", { name: arg("name") ?? t("a Bot") }), state: "sending" }; -- case "update_bot": { -- const target = findBot(arg("name")) ?? findBot(arg("bot")) ?? (arg("bot") ? void 0 : self); -- return { label: t("Updating {name}", { name: target?.name ?? t("a Bot") }), state: "sending" }; -+function AgentRow({ agent, actions, useSessions, useSessionStatus, useUi, wide, marked }) { -+ const info = useSessions((list) => JSON.stringify(agent.sessions.map((id) => { -+ const row = list.byId[id]; -+ return { -+ id, -+ // A blank Session's title is the workspace folder name; show the label instead. -+ title: row?.blank === true ? "" : row?.displayTitle ?? row?.title ?? "", -+ at: row?.updatedAt ?? 0, -+ running: row?.running === true, -+ active: (row?.retainedBy?.mainView ?? 0) > 0 ++ for (const task of tasks) { ++ const bot = ownBot(roster, task.ownerBotId); ++ if (bot === void 0) continue; ++ const askedIn2 = task.waitingFor === "input" ? ownAskChat(roster, bot, task.inputRequest?.question) : void 0; ++ const card = { ++ id: `task:${task.id}`, ++ kind: "task", ++ bot, ++ at: task.updatedAt, ++ title: task.title || t("Untitled task"), ++ place: placeOf(roster, task.origin?.roomId), ++ chat: askedIn2 ?? chatOfTask(task), ++ task, ++ ...people(roster, task.workBotId ?? bot.id) + }; -+ }).sort((a, b) => b.at - a.at))); -+ const statusInfo = useSessionStatus((map) => JSON.stringify(agent.sessions.map((id) => ({ -+ id, -+ running: map.get(id)?.running === true, -+ unread: map.get(id)?.completionUnread === true -+ })))); -+ const statuses = Object.fromEntries(JSON.parse(statusInfo).map((entry) => [entry.id, entry])); -+ const sessions = JSON.parse(info).map((session) => ({ -+ ...session, -+ running: statuses[session.id]?.running ?? session.running, -+ unread: statuses[session.id]?.unread ?? false, -+ title: session.title === "" ? t("New session") : session.title -+ })); -+ const renaming = useUi((value) => value.renameAgent); -+ const active = sessions.some((session) => session.active); -+ const running = sessions.some((session) => session.running); -+ const unread = sessions.some((session) => session.unread) || marked && !active; -+ useMarkedUnread(agent.id, active, marked, actions); -+ const preview = sessions[0]?.title ?? t("New session"); -+ const label = ["DSH Agent", running ? t("Working") : "", unread ? t("Unread activity") : ""].filter(Boolean).join(", "); -+ const open = active && wide; -+ return (0, import_react17.createElement)( -+ "div", -+ { className: "bt-agent" }, -+ (0, import_react17.createElement)( -+ "button", -+ { -+ type: "button", -+ className: "bt-row", -+ "aria-label": label, -+ "aria-current": active ? "page" : void 0, -+ "data-agent-id": agent.id, -+ title: wide ? void 0 : "DSH Agent", -+ onClick: () => actions.openAgent(agent.id), -+ onContextMenu: (event) => { -+ event.preventDefault(); -+ actions.openMenu({ kind: "agent", id: agent.id, x: event.clientX, y: event.clientY }); -+ } -+ }, -+ (0, import_react17.createElement)("span", { className: `bt-agent-ava${running ? " bt-run" : ""}` }, (0, import_react17.createElement)(import_dsh_client_ui_primitives2.FishLogo, { size: 22 })), -+ wide ? (0, import_react17.createElement)( -+ "span", -+ { className: "bt-row-body" }, -+ (0, import_react17.createElement)("span", { className: "bt-row-name" }, "DSH Agent"), -+ preview ? (0, import_react17.createElement)("span", { className: "bt-row-preview" }, preview) : null -+ ) : null, -+ wide && unread && !active ? (0, import_react17.createElement)("span", { className: "bt-unread", "aria-hidden": true }) : null -+ ), -+ (0, import_react17.createElement)( -+ "div", -+ { className: "bt-agent-sub", "data-open": open || void 0 }, -+ (0, import_react17.createElement)( -+ "div", -+ { className: "bt-agent-sub-in" }, -+ (0, import_react17.createElement)( -+ "button", -+ { type: "button", className: "bt-agent-sub-row bt-agent-new", style: { "--i": 0 }, onClick: () => { -+ void actions.newAgentSession(agent.id).catch((error) => actions.reportError(error, "agent-session")); -+ } }, -+ (0, import_react17.createElement)(PlusIcon), -+ (0, import_react17.createElement)("span", { className: "bt-agent-sub-title" }, t("New session")) -+ ), -+ sessions.map((session, index) => (0, import_react17.createElement)(AgentSessionRow, { -+ key: session.id, -+ session, -+ agent, -+ actions, -+ index: index + 1, -+ renaming: renaming === session.id -+ })) -+ ) -+ ) -+ ); ++ if (task.status === "failed") { ++ if (fresh(task.updatedAt)) needs.push({ ...card, status: "failed", reason: lastWhy(task) }); ++ } else if (task.status === "result-ready") needs.push({ ...card, status: "result", reason: task.result?.summary ?? "" }); ++ else if (task.status === "blocked") needs.push({ ...card, status: task.waitingFor === "input" ? "input" : "approval", reason: task.inputRequest?.question ?? "" }); ++ else { ++ const column = columnOf(task); ++ if (column === "in-progress") progress.push({ ...card, status: "task", since: startedAtOf(task) }); ++ else if (column === "done" && fresh(task.updatedAt)) done.push({ ...card, status: task.status === "cancelled" ? "cancelled" : "done" }); ++ } ++ } ++ for (const graph of Array.isArray(workflows) ? workflows : []) { ++ if (graph === null || typeof graph !== "object") continue; ++ const seenRuns = /* @__PURE__ */ new Set(); ++ let latest; ++ for (const run of [...graph.runs ?? [], ...graph.history ?? []]) { ++ if (run === null || typeof run !== "object" || run.status !== "failed" || seenRuns.has(run.id)) continue; ++ seenRuns.add(run.id); ++ const at2 = endedAt(run); ++ if (!fresh(at2)) continue; ++ if (latest === void 0 || at2 > endedAt(latest)) latest = run; ++ } ++ if (latest === void 0) continue; ++ const at = endedAt(latest); ++ const name = typeof graph.name === "string" && graph.name.trim() !== "" ? graph.name.trim() : String(graph.id ?? t("A workflow")); ++ const nodeName = typeof latest.failure?.nodeName === "string" ? latest.failure.nodeName.trim() : ""; ++ const reason = typeof latest.failure?.text === "string" && latest.failure.text !== "" ? latest.failure.text : typeof latest.failure?.code === "string" ? latest.failure.code : ""; ++ const caller = typeof latest.task?.callerBotId === "string" ? ownBot(roster, latest.task.callerBotId) : void 0; ++ needs.push({ ++ id: `workflow:${graph.id ?? name}:${latest.id}`, ++ kind: "workflow", ++ status: "failed", ++ at, ++ workflow: { id: graph.id ?? "", name }, ++ run: { id: latest.id }, ++ title: nodeName === "" ? name : `${name} · ${nodeName}`, ++ reason, ++ ...caller === void 0 ? {} : { bot: caller } ++ }); ++ } ++ if (sessions?.byId) { ++ for (const bot of roster.bots ?? []) { ++ if (ownBot(roster, bot.id) === void 0 || work.bots?.[bot.id] !== void 0 || waitingAt.has(`${bot.id}|`)) continue; ++ if (lastEnded.get(placeKey(bot.id))?.failed !== void 0) continue; ++ const part = currentPart(bot); ++ const at = sessions.byId[part]?.updatedAt; ++ if (!fresh(at)) continue; ++ const text2 = previewOf(sessions, part, [], [], botNames(roster)); ++ if (text2 === "") continue; ++ done.push({ id: `chat:${bot.id}`, kind: "chat", status: "done", bot, at, title: text2, place: "", chat: bot.id, ...people(roster, bot.id) }); ++ } ++ } ++ const newest = (a, b) => (b.at ?? Infinity) - (a.at ?? Infinity); ++ for (const card of [...needs, ...progress, ...done]) { ++ card.activity = latestActivity(card, { roster, work, sessions }); ++ card.calls = toolCalls(card, { roster, work }); ++ } ++ const settled = done.sort(newest); ++ return { needs: needs.sort(newest), progress: progress.sort(newest), done: settled.slice(0, DONE_MAX), doneAll: settled, doneTotal: settled.length }; +} -+var GLIDE = springEasing(1e3, 63); -+function useListGlide(listRef, layout, key) { -+ const tops = (0, import_react17.useRef)({ layout, map: /* @__PURE__ */ new Map() }); -+ (0, import_react17.useLayoutEffect)(() => { -+ const list = listRef.current; -+ if (!list) return; -+ const next = /* @__PURE__ */ new Map(); -+ const still = reducedMotion() || tops.current.layout !== layout; -+ for (const row of list.querySelectorAll("[data-bot-id],[data-room-id],[data-agent-id]")) { -+ const id = row.dataset.botId ?? row.dataset.roomId ?? row.dataset.agentId; -+ const top = row.offsetTop; -+ next.set(id, top); -+ const before = tops.current.map.get(id); -+ if (!still && before !== void 0 && Math.abs(before - top) > 0.5) { -+ row.animate([{ transform: `translateY(${before - top}px)` }, { transform: "none" }], GLIDE); -+ } - } -- case "list_bots": -- return { label: t("Checking the team"), state: "searching" }; -- case "read_group_chat": -- return { label: t("Reading {name}", { name: arg("group") ?? t("a group chat") }), state: "searching" }; -- case "read_own_chat": -- return { label: t("Looking back through the chat"), state: "searching" }; -- case "remember": -- return { label: t("Saving to memory"), state: "working" }; -- case "forget": -- return { label: t("Updating memory"), state: "working" }; -- case "recall": -- return { label: t("Checking memory"), state: "searching" }; -- default: -- if (GROUP_PHRASES[name]) return { label: t(GROUP_PHRASES[name]), state: "sending" }; -- const phrase = TOOL_PHRASES.find(([pattern]) => pattern.test(name))?.[1]; -- return { label: phrase ? t(phrase) : null, state: toolState(name) }; -- } -+ tops.current = { layout, map: next }; -+ }, [layout, key]); - } --function workPhrase(tool) { -- if (!tool) return t("Thinking about it"); -- return toolActivity(tool, () => void 0, () => void 0)?.label ?? t("On it"); -+var NodeActions = class extends import_react17.Component { -+ constructor(props) { -+ super(props); -+ this.state = { failed: false }; ++var needsCount = (roster, work, tasks, now, workflows = []) => inboxModel({ roster, work, tasks, now, workflows }).needs.length; ++function startedAtOf(task) { ++ const started = (task.actors ?? []).filter((actor) => actor.status === "working" && typeof actor.at === "number"); ++ return started.length ? started.at(-1).at : task.updatedAt; ++} ++function visibleNeeds(model, answered) { ++ return { ...model, needs: model.needs.filter((card) => !answered?.[card.id]) }; ++} ++function inboxAnswer(actions, card, choice) { ++ if (card?.kind === "question") { ++ const reply = choice !== null && typeof choice === "object" ? choice : {}; ++ const selected = Array.isArray(reply.selected) ? reply.selected.filter((item) => typeof item === "string" && item !== "") : []; ++ const custom = typeof reply.custom === "string" ? reply.custom.trim() : ""; ++ if (selected.length === 0 && custom === "") return void 0; ++ return Promise.resolve(actions.answerQuestion(card.sessionId, card.question.id, { selected, custom })); + } -+ static getDerivedStateFromError() { -+ return { failed: true }; ++ if (card?.kind === "task" && card.status === "input") { ++ const text2 = typeof choice === "string" ? choice.trim() : ""; ++ const questionId = card.task?.inputRequest?.id; ++ if (text2 === "" || typeof questionId !== "string") return void 0; ++ return Promise.resolve(actions.answerTaskInput(card.task.id, questionId, text2)); + } -+ componentDidCatch(error) { -+ console.error("flybot: nodeActions failed", error); ++ if (card?.kind === "task" && card.status === "result") { ++ if (choice === "accept") return Promise.resolve(actions.acceptTaskResult(card.task.id)); ++ const note = choice !== null && typeof choice === "object" ? String(choice.note ?? "").trim() : ""; ++ if (note === "") return void 0; ++ return Promise.resolve(actions.sendBackTask(card.task.id, note)); + } -+ render() { -+ return this.state.failed ? null : (0, import_react17.createElement)(this.props.component, { bot: this.props.bot }); ++ if (card?.kind === "approval" && card.approval?.answerable === true) { ++ return Promise.resolve(actions.answerApproval(card.approval.id, choice)); ++ } ++ return void 0; ++} ++var answerSlots = /* @__PURE__ */ new Map(); ++var answerSlot = (id) => { ++ let slot = answerSlots.get(id); ++ if (slot === void 0) { ++ slot = { text: "", picked: [] }; ++ answerSlots.set(id, slot); + } ++ return slot; +}; -+function TreeNode({ node, rowProps, markedUnread, collapsed, toggle, actions }) { -+ const { bot, depth, children } = node; -+ const closed = collapsed[bot.id] === true; -+ return (0, import_react17.createElement)( -+ import_react17.Fragment, -+ null, -+ (0, import_react17.createElement)( -+ "div", -+ { className: "bt-tree-item", "data-depth": depth, style: { paddingLeft: `${depth * 16}px` } }, -+ children.length ? (0, import_react17.createElement)( -+ "button", -+ { type: "button", className: "bt-tree-toggle", "aria-expanded": !closed, "aria-label": `${closed ? t("Expand") : t("Collapse")} ${bot.name}`, onClick: () => toggle(bot.id) }, -+ (0, import_react17.createElement)("span", { className: "bt-tree-chev", "aria-hidden": true }, "▾") -+ ) : (0, import_react17.createElement)("span", { className: "bt-tree-toggle bt-tree-leaf", "aria-hidden": true }), -+ (0, import_react17.createElement)(BotRow, { ...rowProps, bot, asRow: true, marked: markedUnread[bot.id] === true }), -+ typeof actions === "function" ? (0, import_react17.createElement)(NodeActions, { component: actions, bot }) : null -+ ), -+ closed ? null : children.map((child) => (0, import_react17.createElement)(TreeNode, { key: child.bot.id, node: child, rowProps, markedUnread, collapsed, toggle, actions })) -+ ); - } --function useLiveNote(actions, sessionId, id, entry) { -- const key = entry ? JSON.stringify(entry) : ""; -- (0, import_react6.useEffect)(() => { -- if (!key) return void 0; -- actions.noteLive(sessionId, id, JSON.parse(key)); -- return () => actions.noteLive(sessionId, id, null); -- }, [sessionId, id, key]); -+function TeamSidebar(props) { -+ const { wide, useRoster, useUi, useSessions, actions } = props; -+ const listRef = (0, import_react17.useRef)(null); -+ const roster = useRoster((value) => value); -+ const markedUnread = useUi((value) => value.unread); -+ props.useRegistry((value) => value); -+ const updated = useSessions((list) => { -+ const stamps = {}; -+ for (const id of list.ids) stamps[id] = list.byId[id]?.updatedAt ?? 0; -+ return JSON.stringify(stamps); -+ }); -+ const order = (0, import_react17.useMemo)(() => { -+ const stamps = JSON.parse(updated); -+ const entries2 = [ -+ // Own Bots, friends' Bots, groups and face rooms share one list. A remote chat -+ // still uses BotRow's globe; gone / hidden / home-level remotes stay out. -+ ...roster.bots.filter((bot) => bot.id !== roster.mainBotId && isShownRemoteChat(bot)).map((bot) => ({ kind: "bot", item: bot, at: stamps[currentPart(bot)] ?? bot.createdAt })), -+ ...roster.rooms.filter((room2) => !isPastFaceRoom(room2)).map((room2) => ({ kind: "room", item: room2, at: stamps[room2.id] ?? room2.createdAt })), -+ // The Agent sorts by its most recent Session, falling back to when it was added. -+ ...(roster.agents ?? []).map((agent) => ({ kind: "agent", item: agent, at: Math.max(0, ...agent.sessions.map((id) => stamps[id] ?? 0)) || agent.createdAt })) -+ ]; -+ const foreign = (entry) => entry.kind === "bot" && entry.item.remote || entry.kind === "room" && (entry.item.origin === "face-to-face" || entry.item.sharedFrom); -+ return entries2.sort((a, b) => Number(Boolean(b.item.pinned)) - Number(Boolean(a.item.pinned)) || Number(foreign(a)) - Number(foreign(b)) || b.at - a.at); -+ }, [roster, updated]); -+ const main = roster.byId[roster.mainBotId]; -+ const readOnlySaid = typeof roster.readOnly === "string" && roster.readOnly.trim() !== ""; -+ const structure = useSidebarStructure(); -+ const [collapsed, setCollapsed] = (0, import_react17.useState)({}); -+ const toggle = (id) => setCollapsed((value) => ({ ...value, [id]: !value[id] })); -+ const inTree = (entry) => entry.kind === "bot" && !entry.item.remote; -+ const listed = (entry) => inTree(entry) && structure?.include?.(entry.item.id) !== false; -+ const tree = structure && wide && main ? botTree([main, ...order.filter(listed).map((entry) => entry.item)], main.id, (id) => structure.parentOf(id)) : null; -+ useListGlide(listRef, wide, order.map((entry) => entry.item.id).join(",")); -+ useLandOnMainBot(roster, useSessions, actions); -+ const rowProps = { roster, actions, useSessions, useUi, useSessionStatus: props.useSessionStatus, useSessionRetainInfo: props.useSessionRetainInfo, useActivity: props.useActivity, useWork: props.useWork, wide }; -+ const typeToSearch = (event) => { -+ if (event.defaultPrevented || event.key.length !== 1 || event.key === " " || event.metaKey || event.ctrlKey || event.altKey || event.nativeEvent.isComposing) return; -+ if (event.target instanceof Element && event.target.closest("input,textarea,[contenteditable]")) return; -+ event.preventDefault(); -+ actions.openPalette(event.key); ++var resultSlots = /* @__PURE__ */ new Map(); ++var resultSlot = (id) => { ++ let slot = resultSlots.get(id); ++ if (slot === void 0) { ++ slot = { open: false, text: "" }; ++ resultSlots.set(id, slot); ++ } ++ return slot; ++}; ++function ResultActions({ card, busy, onAnswer }) { ++ const [, redraw] = (0, import_react14.useState)(0); ++ if (!(card.kind === "task" && card.status === "result")) return null; ++ const slot = resultSlot(card.id); ++ const send = () => { ++ const note = slot.text.trim(); ++ if (busy || note === "") return; ++ onAnswer(card, { note }); + }; -+ return (0, import_react17.createElement)( -+ "div", -+ { className: wide ? "bt-side" : "bt-side bt-rail", onKeyDown: typeToSearch }, -+ wide ? (0, import_react17.createElement)( -+ "div", -+ { className: "bt-side-top" }, -+ (0, import_react17.createElement)("span", { className: "bt-brand" }, (0, import_react17.createElement)(BrandMark, { size: 22 }), (0, import_react17.createElement)(BrandName)), -+ (0, import_react17.createElement)(UpdateButton, { useUpdate: props.useUpdate, actions }), -+ (0, import_react17.createElement)("button", { type: "button", className: "bt-round", "aria-label": t("Search"), title: t("Search"), onClick: () => actions.openPalette() }, (0, import_react17.createElement)(SearchIcon)), -+ (0, import_react17.createElement)("button", { type: "button", className: "bt-round", "aria-label": t("New…"), title: t("New…"), onClick: () => actions.openNewChat("new") }, (0, import_react17.createElement)(PlusIcon)) -+ ) : (0, import_react17.createElement)( -+ import_react17.Fragment, -+ null, -+ (0, import_react17.createElement)(UpdateButton, { useUpdate: props.useUpdate, actions }), -+ (0, import_react17.createElement)("button", { type: "button", className: "bt-round", "aria-label": t("New…"), title: t("New…"), onClick: () => actions.openNewChat("new") }, (0, import_react17.createElement)(PlusIcon)) -+ ), -+ wide && readOnlySaid ? (0, import_react17.createElement)("div", { className: "bt-hint bt-read-only", role: "status" }, hostText(roster.readOnly)) : null, -+ !wide && readOnlySaid ? (0, import_react17.createElement)("div", { className: "bt-hint bt-read-only", role: "status", title: hostText(roster.readOnly), "aria-label": hostText(roster.readOnly) }, hostText(roster.readOnly)) : null, -+ (0, import_react17.createElement)(InboxRow, { wide, useRoster, useWork: props.useWork, useUi, actions }), -+ (0, import_react17.createElement)(KanbanRow, { wide, useUi, actions }), -+ (0, import_react17.createElement)(ProjectsNav, { wide }), -+ (0, import_react17.createElement)( -+ "div", -+ { ref: listRef, className: "bt-list", role: "region", "aria-label": t("Bot list") }, -+ tree ? (0, import_react17.createElement)("div", { className: "bt-tree", role: "group", "aria-label": t("Bot tree") }, tree.map((node) => (0, import_react17.createElement)(TreeNode, { key: node.bot.id, node, rowProps, markedUnread, collapsed, toggle, actions: structure?.nodeActions }))) : main ? (0, import_react17.createElement)(BotRow, { ...rowProps, bot: main, marked: markedUnread[main.id] === true }) : roster.ready ? null : (0, import_react17.createElement)("div", { className: "bt-hint" }, t("Setting up your Main Bot…")), -+ order.filter((entry) => !(tree && inTree(entry)) && !(inTree(entry) && structure?.include?.(entry.item.id) === false)).map((entry) => entry.kind === "bot" ? (0, import_react17.createElement)(BotRow, { key: entry.item.id, ...rowProps, bot: entry.item, marked: markedUnread[entry.item.id] === true }) : entry.kind === "room" ? (0, import_react17.createElement)(RoomRow, { key: entry.item.id, ...rowProps, room: entry.item, marked: markedUnread[entry.item.id] === true }) : (0, import_react17.createElement)(AgentRow, { key: entry.item.id, ...rowProps, agent: entry.item, marked: markedUnread[entry.item.id] === true })), -+ wide ? (0, import_react17.createElement)(FlyBotSections, { roster, actions, renderBot: (bot) => (0, import_react17.createElement)(BotRow, { key: bot.id, ...rowProps, bot, asRow: true, marked: markedUnread[bot.id] === true }) }) : null -+ ) ++ return (0, import_react14.createElement)( ++ "span", ++ { className: "bt-inbox-result" }, ++ slot.open ? (0, import_react14.createElement)("input", { ++ type: "text", ++ className: "bt-inbox-answer-text", ++ value: slot.text, ++ disabled: busy, ++ "aria-label": t("What should change"), ++ placeholder: t("What should change"), ++ onChange: (event) => { ++ slot.text = String(event.target?.value ?? ""); ++ redraw((count) => count + 1); ++ }, ++ onKeyDown: (event) => { ++ if (event.key !== "Enter" || event.shiftKey || event.nativeEvent.isComposing) return; ++ event.preventDefault(); ++ send(); ++ } ++ }) : null, ++ slot.open ? (0, import_react14.createElement)("button", { type: "button", className: "bt-send bt-soft-sm", disabled: busy, onClick: send }, t("Send it back")) : (0, import_react14.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", disabled: busy, onClick: () => { ++ slot.open = true; ++ redraw((count) => count + 1); ++ } }, t("Send back")), ++ (0, import_react14.createElement)("button", { type: "button", className: "bt-send bt-soft-sm", disabled: busy, onClick: () => onAnswer(card, "accept") }, t("Accept")) + ); - } --function GroupReplies({ sessionId, callId, done, content, roster, actions, useUi, useWork }) { -- const [live, setLive] = (0, import_react6.useState)({ replies: [], speaking: null }); -- (0, import_react6.useEffect)(() => { -- if (done) return void 0; -- let stop = false; -- let timer; -- let seen = ""; -- const tick = async () => { -- const result = await actions.roomProgress(sessionId); -- if (stop) return; -- const entry = (result ?? []).find((item) => item.callId === callId) ?? (result ?? [])[0]; -- const key = entry ? JSON.stringify([entry.replies, entry.speaking]) : ""; -- if (entry && key !== seen) { -- seen = key; -- setLive({ replies: entry.replies ?? [], speaking: entry.speaking ?? null }); -+function UpdateButton({ useUpdate, actions }) { -+ const update = useUpdate((value) => value); -+ const [open, setOpen] = (0, import_react17.useState)(false); -+ const [box, setBox] = (0, import_react17.useState)(null); -+ const [error, setError] = (0, import_react17.useState)(""); -+ const [dshError, setDshError] = (0, import_react17.useState)(""); -+ const button = (0, import_react17.useRef)(null); -+ (0, import_react17.useEffect)(() => { -+ if (!open) return void 0; -+ const onDown = (event) => { -+ if (event.target instanceof Element && (event.target.closest(".bt-update-pop") || button.current?.contains(event.target))) return; -+ setOpen(false); -+ }; -+ const onKey = (event) => { -+ if (event.key === "Escape") { -+ event.stopPropagation(); -+ setOpen(false); -+ button.current?.focus(); - } -- timer = setTimeout(tick, document.hidden ? 5e3 : 1e3); - }; -- void tick(); -+ window.addEventListener("mousedown", onDown); -+ window.addEventListener("keydown", onKey, true); - return () => { -- stop = true; -- clearTimeout(timer); -+ window.removeEventListener("mousedown", onDown); -+ window.removeEventListener("keydown", onKey, true); - }; -- }, [done, sessionId, callId]); -- let replies = live.replies; -- if (done) { -- try { -- replies = JSON.parse(contentText(content)).replies ?? []; -- } catch { -- replies = []; -- } -- } -- const pending = done || !live.speaking ? [] : [live.speaking]; -- const speakerTool = useWork?.((work) => work.bots?.[live.speaking]?.tool) ?? void 0; -- if (replies.length === 0 && pending.length === 0) return null; -- const room2 = roster.roomsById[sessionId]; -- return (0, import_react6.createElement)( -- "div", -- { className: "bt-group-replies" }, -- replies.map((reply, index) => { -- const bot = roster.byId[reply.botId] ?? { id: reply.botId, name: reply.name, color: reply.color ?? "gray" }; -- if (reply.kind === "user") { -- return (0, import_react6.createElement)("div", { key: `user-${index}`, className: "bt-urow" }, (0, import_react6.createElement)( -- "div", -- { className: "bt-ububble" }, -- (0, import_react6.createElement)(import_dsh_client_ui_primitives.MarkdownText, { text: reply.text, labels: mdLabels(), variant: "compact" }) -- )); -- } -- if (reply.kind === "failed") { -- return (0, import_react6.createElement)( -- "div", -- { key: `${reply.botId}-${index}`, className: "bt-event bt-event-error", role: "status", title: [reply.code, reply.status, reply.text].filter(Boolean).join(" · ") }, -- t("{name} could not answer: {reason}", { name: bot.name, reason: failureLabel(reply.code, reply.text) }) -- ); -- } -- if (reply.kind === "lookup") { -- return (0, import_react6.createElement)( -- "div", -- { key: `${reply.botId}-${index}`, className: "bt-event" }, -- reply.text ? t("{name} looked up its own chat for “{text}”", { name: bot.name, text: reply.text }) : t("{name} looked up its own chat", { name: bot.name }) -- ); -- } -- if (reply.kind === "missed") { -- return (0, import_react6.createElement)("div", { key: `${reply.botId}-${index}`, className: "bt-event" }, t("{name} didn't answer in time", { name: bot.name })); -- } -- if (reply.kind === "resumed") { -- return (0, import_react6.createElement)("div", { key: `${reply.botId}-${index}`, className: "bt-event" }, t("{name} was cut off by a restart and picked its work back up", { name: bot.name })); -- } -- if (reply.kind === "cut") { -- return (0, import_react6.createElement)("div", { key: `${reply.botId}-${index}`, className: "bt-event" }, t("{name} was cut off by a restart again and stopped", { name: bot.name })); -- } -- if (reply.kind === "approval" || reply.kind === "working") { -- return (0, import_react6.createElement)( -+ }, [open]); -+ const phase = update?.phase ?? "idle"; -+ const dsh = update?.dsh; -+ const dshPhase = dsh?.phase ?? "idle"; -+ const dshRow = showsDshRow(dsh, update?.app); -+ if (!update?.available && !dshRow && phase === "idle" && dshPhase === "idle") return null; -+ const cloudPhase = phase === "installing" || dshPhase === "installing" ? "installing" : phase !== "idle" ? phase : dshPhase; -+ const toggle = () => { -+ const rect = button.current?.getBoundingClientRect(); -+ if (rect) setBox({ left: Math.max(8, Math.min(rect.left, window.innerWidth - 288)), top: rect.bottom + 6 }); -+ setOpen((value) => !value); -+ }; -+ const start = () => { -+ setError(""); -+ void actions.installUpdate().catch((failure) => setError(hostText(failure?.message ?? String(failure)))); ++} ++function AnswerFields({ card, busy, onAnswer }) { ++ const [, redraw] = (0, import_react14.useState)(0); ++ const question = card.kind === "question"; ++ const taskInput = card.kind === "task" && card.status === "input" && typeof card.task?.inputRequest?.id === "string"; ++ if (!question && !taskInput) return null; ++ const slot = answerSlot(card.id); ++ const multi = question && card.question?.multiSelect === true; ++ const options = question && Array.isArray(card.question?.options) ? card.question.options : []; ++ const send = () => { ++ if (busy) return; ++ const custom = slot.text.trim(); ++ if (question) { ++ const selected = multi ? slot.picked.filter((label) => label !== "") : []; ++ if (selected.length === 0 && custom === "") return; ++ onAnswer(card, { selected, custom }); ++ return; ++ } ++ if (custom === "") return; ++ onAnswer(card, custom); + }; -+ const startDsh = () => { -+ setDshError(""); -+ void actions.installDshUpdate().catch((failure) => setDshError(hostText(failure?.message ?? String(failure)))); ++ const choose = (label) => { ++ if (busy) return; ++ if (!multi) { ++ onAnswer(card, { selected: [label], custom: "" }); ++ return; ++ } ++ slot.picked = slot.picked.includes(label) ? slot.picked.filter((item) => item !== label) : [...slot.picked, label]; ++ redraw((count) => count + 1); + }; -+ const label = phase === "installing" ? t("Updating FlyBot core…") : dshPhase === "installing" ? t("Updating DSH…") : phase === "installed" ? t("FlyBot core {version} is installed. Restart DSH to use it.", { version: update.installed ?? update.latest }) : dshPhase === "installed" ? t("Restart DSH to start using DSH {version}.", { version: dsh.installed ?? dsh.verified }) : update?.available ? t("FlyBot core {version} is available", { version: update.latest }) : t("DSH {version} is available", { version: dsh?.verified }); -+ const message = error || (phase === "failed" ? update.error : ""); -+ const dshMessage = dshError || (dshPhase === "failed" ? dsh?.error : ""); -+ const dshText = dshPhase === "installed" ? t("Restart DSH to start using DSH {version}.", { version: dsh.installed ?? dsh.verified }) : dsh?.available === false && dsh?.blockedBy ? t("Update FlyBot core {version} first.", { version: dsh.blockedBy }) : t("You have {version}.", { version: dsh?.current }); -+ return (0, import_react17.createElement)( -+ import_react17.Fragment, -+ null, -+ (0, import_react17.createElement)("button", { -+ ref: button, ++ return (0, import_react14.createElement)( ++ "span", ++ { className: "bt-inbox-answer" }, ++ ...options.map((option, index) => (0, import_react14.createElement)("button", { ++ key: `${option.label}-${index}`, + type: "button", -+ className: "bt-round bt-update", -+ "data-phase": cloudPhase, ++ className: "bt-soft bt-soft-sm", ++ disabled: busy, ++ "aria-pressed": multi ? slot.picked.includes(option.label) : void 0, ++ onClick: () => choose(option.label) ++ }, option.label)), ++ (0, import_react14.createElement)("input", { ++ type: "text", ++ className: "bt-inbox-answer-text", ++ value: slot.text, ++ disabled: busy, ++ "aria-label": t("Your own answer"), ++ placeholder: t("Type your own answer"), ++ onChange: (event) => { ++ slot.text = String(event.target?.value ?? ""); ++ redraw((count) => count + 1); ++ }, ++ onKeyDown: (event) => { ++ if (event.key !== "Enter" || event.shiftKey || event.nativeEvent.isComposing) return; ++ event.preventDefault(); ++ send(); ++ } ++ }), ++ (0, import_react14.createElement)("button", { type: "button", className: "bt-send bt-soft-sm", disabled: busy, onClick: send }, t("Submit")) ++ ); + } +-function SecretDock({ sessionId, useRoster, useCards, useSession, actions }) { ++function InboxRow({ wide, useRoster, useWork, useUi, actions }) { + const roster = useRoster((value) => value); +- const shown = useCards?.((state) => state[sessionId]) ?? NONE; +- const loading = useSession?.(historyLoading) === true; +- const bot = roster.byId[sessionId]; +- if (!bot || currentPart(bot) !== sessionId) return null; +- const drawn = (entry) => entry.callId && entry.sessionId === sessionId && (loading || shown[entry.callId] > 0); +- const waiting = (roster.secretRequests?.[bot.id] ?? []).filter((entry) => entry.status === "pending" && !drawn(entry)); +- if (waiting.length === 0) return null; +- return (0, import_react5.createElement)("div", { className: "bt-q-dock bt-secret-dock" }, waiting.map((request) => (0, import_react5.createElement)(SecretCard, { key: `${request.id}:${request.kind}`, bot, request, roster, actions }))); +-} +-function ScopeEditor({ secret, roster, onSave }) { +- const [mode, setMode] = (0, import_react5.useState)(secret.scope === "all" ? "all" : "some"); +- const [picked, setPicked] = (0, import_react5.useState)(secret.scope === "all" ? [] : secret.scope); +- const toggle = (id) => setPicked((list) => list.includes(id) ? list.filter((item) => item !== id) : [...list, id]); +- const same = mode === "all" ? secret.scope === "all" : secret.scope !== "all" && [...picked].sort().join() === [...secret.scope].sort().join(); +- return (0, import_react5.createElement)( +- "div", +- { className: "bt-secret-edit-block" }, +- (0, import_react5.createElement)("div", { className: "bt-secret-edit-label" }, t("Who may use it")), +- (0, import_react5.createElement)( +- "div", +- { className: "bt-secret-radios", role: "radiogroup", "aria-label": t("Who may use {name}", { name: secret.name }) }, +- [["all", t("All Bots")], ["some", t("Chosen Bots")]].map(([id, text]) => (0, import_react5.createElement)( +- "label", +- { key: id, className: "bt-secret-radio" }, +- (0, import_react5.createElement)("input", { type: "radio", name: `scope-${secret.name}`, checked: mode === id, onChange: () => setMode(id) }), +- text +- )) +- ), +- mode === "some" ? (0, import_react5.createElement)("div", { className: "bt-secret-bots" }, roster.bots.map((bot) => (0, import_react5.createElement)("button", { +- key: bot.id, ++ const work = useWork ? useWork((value) => value) : {}; ++ const open = useUi ? useUi((value) => Boolean(value.inbox)) : false; ++ const tasks = useTasks(actions); ++ const workflows = useWorkflows(actions); ++ const count = needsCount(roster, work, tasks, void 0, workflows); ++ const label = count > 0 ? t("Inbox, {count} need you", { count }) : t("Inbox"); ++ return (0, import_react14.createElement)( ++ "button", ++ { + type: "button", +- className: "bt-secret-bot", +- "aria-pressed": picked.includes(bot.id), +- onClick: () => toggle(bot.id) +- }, (0, import_react5.createElement)(BotAvatar, { bot, size: 20, badge: false, live: false }), bot.name, picked.includes(bot.id) ? (0, import_react5.createElement)(CheckIcon) : null))) : null, +- (0, import_react5.createElement)( +- "div", +- { className: "bt-secret-row-actions" }, +- (0, import_react5.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", disabled: same, onClick: () => onSave(mode === "all" ? "all" : picked) }, t("Save access")) +- ) ++ className: "bt-row bt-inbox-row", + "aria-label": label, -+ title: label, ++ title: wide ? void 0 : label, + "aria-haspopup": "dialog", + "aria-expanded": open, -+ onClick: toggle -+ }, (0, import_react17.createElement)(CloudDownloadIcon, { done: phase === "installed" }), phase === "failed" || dshPhase === "failed" ? (0, import_react17.createElement)("span", { className: "bt-update-dot", "aria-hidden": true }) : null), -+ open && box ? (0, import_react_dom4.createPortal)((0, import_react17.createElement)( -+ "div", -+ { className: "bt-update-pop", role: "dialog", "aria-label": update?.available || phase !== "idle" ? t("FlyBot core update") : t("DSH update"), style: box }, -+ update?.available || phase !== "idle" ? (0, import_react17.createElement)( -+ import_react17.Fragment, -+ null, -+ (0, import_react17.createElement)("div", { className: "bt-update-title" }, phase === "installed" ? t("Update installed") : t("FlyBot core {version}", { version: update.latest })), -+ (0, import_react17.createElement)("div", { className: "bt-update-text" }, phase === "installed" ? t("Restart DSH to start using FlyBot core {version}.", { version: update.installed ?? update.latest }) : t("You have {version}.", { version: update.current })), -+ message ? (0, import_react17.createElement)("div", { className: "bt-update-error", role: "alert" }, t("The update did not finish: {reason}", { reason: message })) : null, -+ (0, import_react17.createElement)( - "div", -- { key: `${reply.botId}-${index}`, className: "bt-event" }, -- (0, import_react6.createElement)( -- "button", -- { type: "button", className: "bt-event-whole", onClick: () => actions.showDetails(sessionId) }, -- (0, import_react6.createElement)(BotMark, { bot, size: 16 }), -- (0, import_react6.createElement)("span", { className: "bt-event-name" }, t(reply.kind === "working" ? "{name} started on it" : "{name} paused for your approval", { name: bot.name })) -- ) -- ); ++ onClick: () => actions.openInbox() ++ }, ++ (0, import_react14.createElement)("span", { className: "bt-inbox-mark" }, (0, import_react14.createElement)(InboxIcon)), ++ wide ? (0, import_react14.createElement)("span", { className: "bt-inbox-label" }, t("Inbox")) : null, ++ count > 0 ? (0, import_react14.createElement)("span", { className: "bt-inbox-count", "aria-hidden": true }, count > 99 ? "99+" : String(count)) : null + ); + } +-function ValueEditor({ secret, onSave }) { +- const [value, setValue] = (0, import_react5.useState)(""); +- const [replaced, setReplaced] = (0, import_react5.useState)(false); +- const long = value.trim().length >= MIN_VALUE; +- const save = () => { +- if (!long) return; +- void Promise.resolve(onSave(value)).then((ok) => { +- if (ok) { +- setValue(""); +- setReplaced(true); - } -- if (reply.kind === "stopped") { -- return (0, import_react6.createElement)("div", { key: `${reply.botId}-${index}`, className: "bt-event" }, t("You stopped {name}'s work", { name: bot.name })); +- }); +- }; +- const edit = (next) => { +- setValue(next); +- setReplaced(false); +- }; +- return (0, import_react5.createElement)( +- "div", +- { className: "bt-secret-edit-block" }, +- (0, import_react5.createElement)("div", { className: "bt-secret-edit-label" }, t("Replace the value")), +- (0, import_react5.createElement)( +- "div", +- { className: "bt-secret-inline" }, +- (0, import_react5.createElement)("label", { className: "bt-q-field bt-secret-field" }, (0, import_react5.createElement)(MaskedInput, { value, onChange: edit, onEnter: save, label: t("New value of {name}", { name: secret.name }) })), +- (0, import_react5.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", disabled: !long, onClick: save }, t("Replace")) +- ), +- value !== "" && !long ? (0, import_react5.createElement)("div", { className: "bt-set-hint" }, t("At least {count} characters.", { count: MIN_VALUE })) : replaced ? (0, import_react5.createElement)("div", { className: "bt-set-hint bt-secret-ok", role: "status" }, (0, import_react5.createElement)(CheckIcon), t("Replaced. Bots get the new value from their next command.")) : null +- ); ++function Status({ status }) { ++ const { label, tone, Icon } = STATUSES[status]; ++ return (0, import_react14.createElement)("span", { className: "bt-inbox-status", "data-tone": tone }, (0, import_react14.createElement)(Icon), (0, import_react14.createElement)("span", null, t(label))); + } +-function SecretRow({ secret, roster, actions, run, open, onToggle }) { +- const [confirm, setConfirm] = (0, import_react5.useState)(false); +- (0, import_react5.useEffect)(() => { +- if (!open) setConfirm(false); +- }, [open]); +- const asker = secret.requestedBy ? roster.byId[secret.requestedBy]?.name ?? t("a deleted Bot") : t("you"); +- const allowed = secret.scope === "all" ? [] : secret.scope.map((id) => roster.byId[id]).filter(Boolean); +- const meta = [t("Asked by {name}", { name: asker }), t("added {time}", { time: when(secret.createdAt) }), secret.lastUsedAt ? t("last used {time}", { time: when(secret.lastUsedAt) }) : t("not used yet")].join(" · "); +- return (0, import_react5.createElement)( ++function whenText(card, now) { ++ if (card.status === "running" || card.status === "task") { ++ return typeof card.since === "number" && now - card.since >= 6e4 ? t("for {time}", { time: elapsedLabel(now - card.since) }) : t("just started"); ++ } ++ return typeof card.at === "number" ? relativeShort(card.at) : ""; ++} ++function InboxCard({ card, roster, now, busy, error, onAnswer, onOpen }) { ++ const who = card.bot ? card.bot.name : card.room?.name ?? ""; ++ const produced = card.kind === "task" && card.status === "result" ? [card.bot?.model] : []; ++ const meta = [who, ...produced, card.place, whenText(card, now)].filter(Boolean).join(" · "); ++ const answerable = card.kind === "approval" && card.approval?.answerable === true; ++ const evidence = card.kind === "task" && card.status === "result" ? card.task?.result?.links ?? [] : []; ++ const sentBack = card.kind === "task" && card.status === "task" ? card.task?.followUp?.note : void 0; ++ const target = card.kind === "task" ? card.task?.target?.href : void 0; ++ const writeBack = card.kind === "task" && card.task?.writeBack?.state === "failed" ? card.task.writeBack : void 0; ++ const unconfirmed = card.kind === "task" && card.task?.writeBack?.state === "unconfirmed" ? card.task.writeBack : void 0; ++ return (0, import_react14.createElement)( + "div", +- { className: "bt-secret-item", "data-open": open ? "" : void 0 }, +- (0, import_react5.createElement)( +- "button", +- { type: "button", className: "bt-set-row bt-secret-summary", "aria-expanded": open, onClick: onToggle }, +- (0, import_react5.createElement)( ++ { className: "bt-inbox-card", role: "group", "aria-label": `${t(STATUSES[card.status].label)}: ${card.title}`, "data-status": card.status, "data-run": card.kind === "workflow" ? card.run?.id : void 0 }, ++ (0, import_react14.createElement)("span", { className: "bt-inbox-ava" }, card.bot ? (0, import_react14.createElement)(BotAvatar, { bot: card.bot, size: 28, badge: false, live: false }) : card.room ? (0, import_react14.createElement)(RoomAvatar, { room: card.room, roster, size: 28 }) : null), ++ (0, import_react14.createElement)( ++ "span", ++ { className: "bt-inbox-copy" }, ++ (0, import_react14.createElement)( + "span", +- { className: "bt-set-copy" }, +- (0, import_react5.createElement)("span", { className: "bt-secret-title" }, (0, import_react5.createElement)(KeyIcon), (0, import_react5.createElement)("code", { className: "bt-secret-name" }, secret.name), secret.purpose ? (0, import_react5.createElement)("span", { className: "bt-secret-purpose" }, secret.purpose) : null), +- (0, import_react5.createElement)("span", { className: "bt-set-hint" }, meta) ++ { className: "bt-inbox-top" }, ++ (0, import_react14.createElement)(Status, { status: card.status }), ++ (0, import_react14.createElement)("span", { className: "bt-inbox-meta", title: meta }, meta) + ), +- (0, import_react5.createElement)( +- "span", +- { className: "bt-set-control" }, +- secret.scope === "all" ? (0, import_react5.createElement)("span", { className: "bt-tag" }, t("All Bots")) : allowed.length === 0 ? (0, import_react5.createElement)("span", { className: "bt-tag" }, t("No Bot")) : (0, import_react5.createElement)("span", { className: "bt-secret-faces", title: allowed.map((bot) => bot.name).join(", ") }, allowed.slice(0, 5).map((bot) => (0, import_react5.createElement)(BotAvatar, { key: bot.id, bot, size: 20, badge: false, live: false }))) +- ) ++ (0, import_react14.createElement)("span", { className: "bt-inbox-title", title: card.title }, card.title), ++ card.calls > 0 ? (0, import_react14.createElement)("span", { className: "bt-inbox-calls" }, t("{count} tool calls", { count: card.calls })) : null, ++ card.reason ? (0, import_react14.createElement)("span", { className: "bt-inbox-reason", title: card.reason }, failureSentence(card.reason, t)) : null, ++ card.activity ? (0, import_react14.createElement)("span", { className: "bt-inbox-activity", title: card.activity }, t("Latest: {text}", { text: card.activity })) : null, ++ card.hint ? (0, import_react14.createElement)("span", { className: "bt-inbox-hint" }, card.hint) : null, ++ evidence.length > 0 ? (0, import_react14.createElement)("span", { className: "bt-inbox-evidence" }, ...evidence.map((href, index) => (0, import_react14.createElement)("a", { ++ key: `${href}-${index}`, ++ href, ++ target: "_blank", ++ rel: "noreferrer noopener", ++ className: "bt-inbox-evidence-link", ++ title: href ++ }, href))) : null, ++ sentBack ? (0, import_react14.createElement)("span", { className: "bt-inbox-reason", title: sentBack }, t("Sent back: {note}", { note: sentBack })) : null, ++ target ? (0, import_react14.createElement)("span", { className: "bt-inbox-evidence" }, (0, import_react14.createElement)("a", { ++ href: target, ++ target: "_blank", ++ rel: "noreferrer noopener", ++ className: "bt-inbox-evidence-link bt-inbox-target", ++ title: target ++ }, t("Comment goes to {target}", { target: target.replace(/^https:\/\/github\.com\//, "") }))) : null, ++ writeBack ? (0, import_react14.createElement)("span", { className: "bt-inbox-writeback", role: "alert", title: writeBack.error }, t("Could not comment on {target}: {error}", { target: writeBack.target, error: writeBack.error })) : null, ++ unconfirmed ? (0, import_react14.createElement)("span", { className: "bt-inbox-writeback", title: unconfirmed.target }, t("Could not confirm the comment on {target} went out", { target: unconfirmed.target })) : null, ++ (0, import_react14.createElement)(AnswerFields, { card, busy, onAnswer }), ++ (0, import_react14.createElement)(ResultActions, { card, busy, onAnswer }), ++ error ? (0, import_react14.createElement)("span", { className: "bt-inbox-error", role: "alert" }, error) : null + ), +- open ? (0, import_react5.createElement)( +- "div", +- { className: "bt-secret-edit" }, +- (0, import_react5.createElement)(ScopeEditor, { key: JSON.stringify(secret.scope), secret, roster, onSave: (scope) => run(actions.secretUpdate(secret.name, scope)) }), +- (0, import_react5.createElement)(ValueEditor, { secret, onSave: (value) => run(actions.secretSet({ name: secret.name, value })) }), +- (0, import_react5.createElement)( +- "div", +- { className: "bt-secret-row-actions" }, +- confirm ? [ +- (0, import_react5.createElement)("span", { key: "q", className: "bt-set-hint" }, t("Delete {name}? Bots lose it at once.", { name: secret.name })), +- (0, import_react5.createElement)("button", { key: "no", type: "button", className: "bt-soft bt-soft-sm", onClick: () => setConfirm(false) }, t("Keep")), +- (0, import_react5.createElement)("button", { key: "yes", type: "button", className: "bt-soft bt-soft-sm bt-secret-danger", onClick: () => run(actions.secretDelete(secret.name)) }, t("Delete")) +- ] : (0, import_react5.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm bt-secret-danger", onClick: () => setConfirm(true) }, (0, import_react5.createElement)(TrashIcon), t("Delete")) +- ) +- ) : null ++ (0, import_react14.createElement)( ++ "span", ++ { className: "bt-inbox-actions" }, ++ answerable ? (0, import_react14.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", disabled: busy, onClick: () => onAnswer(card, "reject") }, t("Reject")) : null, ++ answerable ? (0, import_react14.createElement)("button", { type: "button", className: "bt-send bt-soft-sm", disabled: busy, onClick: () => onAnswer(card, "allow") }, t("Allow once")) : null, ++ ...(answerable ? alwaysChoices(card.approval, card.bot) : []).map(([choice, label]) => (0, import_react14.createElement)("button", { key: choice, type: "button", className: "bt-soft bt-soft-sm", "data-choice": choice, disabled: busy, onClick: () => onAnswer(card, choice) }, t(label))), ++ (0, import_react14.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", "aria-label": who ? t("Open {name}", { name: card.room?.name ?? who }) : t("Open"), onClick: () => onOpen(card) }, t("Open")) ++ ) + ); + } +-function AddSecret({ actions, run, onDone }) { +- const [name, setName] = (0, import_react5.useState)(""); +- const [purpose, setPurpose] = (0, import_react5.useState)(""); +- const [value, setValue] = (0, import_react5.useState)(""); +- const ready = /^[A-Za-z][\w-]{0,63}$/.test(name.trim()) && value.trim().length >= MIN_VALUE; +- const save = () => { +- if (!ready) return; +- void run(actions.secretSet({ name, purpose, value, scope: "all" })).then((ok) => { +- if (ok) { +- setValue(""); +- onDone(); - } -- return (0, import_react6.createElement)(GroupMessage, { key: `${reply.botId}-${index}`, bot, text: reply.text, room: room2, roster, actions, msgKey: callId ? `${sessionId}:${callId}:${index}` : void 0, useUi }); -- }), -- pending.map((id) => { -- const bot = roster.byId[id]; -- if (!bot) return null; +- }); ++function useNow2(ms) { ++ const [now, setNow] = (0, import_react14.useState)(Date.now); ++ (0, import_react14.useEffect)(() => { ++ const timer = setInterval(() => setNow(Date.now()), ms); ++ return () => clearInterval(timer); ++ }, [ms]); ++ return now; ++} ++function openCard(actions, card) { ++ actions.closeInbox(); ++ if (card?.kind === "workflow") { ++ actions.openSettings("workflow"); ++ return; ++ } ++ actions.openSession(card.chat); ++} ++function InboxDialog({ roster, useWork, useSessions, actions, leaving = false }) { ++ const work = useWork((value) => value); ++ const sessions = useSessions((value) => value); ++ const tasks = useTasks(actions); ++ const workflows = useWorkflows(actions); ++ const now = useNow2(3e4); ++ const [answered, setAnswered] = (0, import_react14.useState)({}); ++ const [busy, setBusy] = (0, import_react14.useState)(""); ++ const [errors, setErrors] = (0, import_react14.useState)({}); ++ const [feedback, setFeedback] = (0, import_react14.useState)(""); ++ const [allDone, setAllDone] = (0, import_react14.useState)(false); ++ const close = (0, import_react14.useCallback)(() => actions.closeInbox(), []); ++ useEscape(close); ++ (0, import_react14.useEffect)(() => { ++ if (!feedback) return void 0; ++ const timer = setTimeout(() => setFeedback(""), FEEDBACK_MS); ++ return () => clearTimeout(timer); ++ }, [feedback]); ++ const model = inboxModel({ roster, work, tasks, sessions, workflows, now }); ++ const groups = visibleNeeds(model, answered); ++ const answer = (card, choice) => { ++ setBusy(card.id); ++ setErrors((value) => ({ ...value, [card.id]: "" })); ++ const pending = inboxAnswer(actions, card, choice); ++ if (pending === void 0) { ++ setBusy(""); ++ return; ++ } ++ void Promise.resolve(pending).then(() => { ++ setAnswered((value) => ({ ...value, [card.id]: true })); ++ const name = card.bot?.name ?? ""; ++ setFeedback(card.kind === "approval" ? choice === "allow" ? t("Allowed once for {name}", { name }) : choice === "always-bot" ? t("Always allowed for {name}", { name }) : choice === "always-room" ? t("Always allowed in this group") : t("Rejected for {name}", { name }) : t("Answered")); ++ }, (failure) => setErrors((value) => ({ ...value, [card.id]: hostText(failure?.message ?? String(failure)) }))).finally(() => setBusy("")); + }; +- return (0, import_react5.createElement)( ++ const open = (card) => openCard(actions, card); ++ return (0, import_react14.createElement)( + "div", +- { className: "bt-set-row bt-set-paste bt-secret-add" }, +- (0, import_react5.createElement)("input", { className: "bt-input", "aria-label": t("Key name"), placeholder: t("NAME, e.g. GITHUB_TOKEN"), value: name, spellCheck: false, autoComplete: "off", autoFocus: true, onChange: (event) => setName(event.target.value.toUpperCase()) }), +- (0, import_react5.createElement)("input", { className: "bt-input", "aria-label": t("What it is for"), placeholder: t("What it is for"), value: purpose, autoComplete: "off", onChange: (event) => setPurpose(event.target.value) }), +- (0, import_react5.createElement)("label", { className: "bt-q-field bt-secret-field" }, (0, import_react5.createElement)(MaskedInput, { value, onChange: setValue, onEnter: save, label: t("Value") })), +- (0, import_react5.createElement)( ++ { className: "bt-mem-layer bt-inbox-layer", "data-leaving": leaving || void 0 }, ++ (0, import_react14.createElement)("div", { className: "bt-scrim bt-mem-scrim", onMouseDown: close }), ++ (0, import_react14.createElement)( + "div", +- { className: "bt-secret-row-actions" }, +- (0, import_react5.createElement)("span", { className: "bt-set-hint" }, t("New keys are for all Bots; narrow it down afterwards.")), +- (0, import_react5.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: onDone }, t("Cancel")), +- (0, import_react5.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", disabled: !ready, onClick: save }, t("Save key")) ++ { className: "bt-mem bt-inbox", role: "dialog", "aria-modal": true, "aria-labelledby": "bt-inbox-heading" }, ++ (0, import_react14.createElement)( ++ "div", ++ { className: "bt-mem-head" }, ++ (0, import_react14.createElement)("h2", { id: "bt-inbox-heading" }, t("Inbox")), ++ (0, import_react14.createElement)("span", { className: "bt-mem-status" }, t("Your Bots")), ++ (0, import_react14.createElement)("span", { className: "bt-mem-head-gap" }), ++ (0, import_react14.createElement)("button", { type: "button", className: "bt-icon-btn", "aria-label": t("Close inbox"), autoFocus: true, onClick: close }, (0, import_react14.createElement)(CloseIcon)) ++ ), ++ (0, import_react14.createElement)( ++ "div", ++ { className: "bt-mem-body bt-inbox-body" }, ++ (0, import_react14.createElement)("div", { className: "bt-inbox-feedback", role: "status", "aria-live": "polite" }, feedback), ++ GROUPS.map((group) => { ++ const settled = group.id === "done" ? model.doneAll ?? groups.done : null; ++ const canOpen = group.id === "done" && settled.length > DONE_MAX; ++ const rows = canOpen && allDone ? settled : groups[group.id]; ++ const hidden = group.id === "done" ? Math.max(0, (model.doneTotal ?? rows.length) - rows.length) : 0; ++ return (0, import_react14.createElement)( ++ "section", ++ { key: group.id, className: "bt-inbox-group", "aria-labelledby": `bt-inbox-${group.id}` }, ++ (0, import_react14.createElement)( ++ "h3", ++ { id: `bt-inbox-${group.id}`, className: "bt-inbox-head" }, ++ (0, import_react14.createElement)("span", null, t(group.title)), ++ rows.length > 0 ? (0, import_react14.createElement)("span", { className: "bt-inbox-head-count" }, String(rows.length)) : null ++ ), ++ rows.length === 0 ? (0, import_react14.createElement)("p", { className: "bt-inbox-empty" }, t(group.empty)) : (0, import_react14.createElement)("div", { className: "bt-inbox-list" }, rows.map((card) => (0, import_react14.createElement)(InboxCard, { ++ key: card.id, ++ card, ++ roster, ++ now, ++ busy: busy === card.id, ++ error: errors[card.id], ++ onAnswer: answer, ++ onOpen: open ++ }))), ++ hidden > 0 ? (0, import_react14.createElement)("div", { key: "more", className: "bt-inbox-more" }, t("{count} more not shown", { count: hidden })) : null, ++ canOpen ? (0, import_react14.createElement)("button", { ++ key: "all", ++ type: "button", ++ className: "bt-inbox-show-all", ++ "aria-expanded": allDone, ++ onClick: () => setAllDone((value) => !value) ++ }, t(allDone ? "Show fewer" : "Show all")) : null ++ ); ++ }) ++ ) + ) + ); + } +-function SecretsPage({ roster, actions, run, Section }) { +- const [open, setOpen] = (0, import_react5.useState)(null); +- const [adding, setAdding] = (0, import_react5.useState)(false); +- const secrets = roster.secrets ?? []; +- return [ +- (0, import_react5.createElement)( +- "p", +- { key: "about", className: "bt-secret-about" }, +- t("Keys your Bots asked for. Bots never see the values: they use them as "), +- (0, import_react5.createElement)("code", null, "$DSH_SECRET_NAME"), +- t(" in their shell, and every value is replaced by "), +- (0, import_react5.createElement)("code", null, "[secret:NAME]"), +- t(" before anything goes to a model.") +- ), +- (0, import_react5.createElement)( +- Section, +- { key: "list", title: t("Secrets · {count}", { count: secrets.length }) }, +- secrets.length === 0 && !adding ? (0, import_react5.createElement)("div", { className: "bt-set-row" }, (0, import_react5.createElement)("span", { className: "bt-set-hint" }, t("No keys yet. When a Bot needs one, it shows a card in its chat."))) : null, +- secrets.map((secret) => (0, import_react5.createElement)(SecretRow, { key: secret.name, secret, roster, actions, run, open: open === secret.name, onToggle: () => setOpen(open === secret.name ? null : secret.name) })), +- adding ? (0, import_react5.createElement)(AddSecret, { actions, run, onDone: () => setAdding(false) }) : null +- ), +- adding ? null : (0, import_react5.createElement)("div", { key: "add", className: "bt-actions" }, (0, import_react5.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: () => setAdding(true) }, t("Add a key"))) +- ]; +-} +-var SECRET_CSS = ` +-.bt-secret-head{align-items:center;gap:10px} +-.bt-secret-who{position:relative;flex:none;display:inline-flex} +-.bt-secret-lock{position:absolute;right:-5px;bottom:-4px;width:16px;height:16px;border-radius:50%;background:var(--bt-main);color:var(--bt-ink-2);display:inline-flex;align-items:center;justify-content:center;box-shadow:0 0 0 1.5px var(--bt-bubble)} +-.bt-secret-lock svg{width:11px;height:11px} +-.bt-secret-name{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:12px;line-height:16px;padding:1px 6px;border-radius:5px;background:var(--bt-hover);color:var(--bt-ink)} +-.bt-secret-body{display:flex;flex-direction:column;gap:8px} +-.bt-secret-field{align-items:center;min-height:36px} +-.bt-secret-input{flex:1;width:100%;min-width:0;margin:0;padding:0;border:0;outline:none;background:transparent;color:var(--bt-ink);font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:14px;line-height:20px;letter-spacing:.04em} +-.bt-secret-input[data-masked]{-webkit-text-security:disc} +-.bt-secret-input::placeholder{color:var(--bt-ink-3);font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",system-ui,sans-serif;letter-spacing:0} +-.bt-secret-scope{position:relative;display:inline-grid;grid-template-columns:1fr 1fr;align-self:flex-start;padding:2px;border-radius:999px;background:var(--bt-hover)} +-.bt-secret-glide{position:absolute;top:2px;bottom:2px;left:2px;width:calc(50% - 2px);border-radius:999px;background:var(--bt-main);box-shadow:0 1px 3px rgba(20,20,20,.08),inset 0 0 0 .5px color-mix(in srgb,var(--bt-ink) 6%,transparent);transition:${spring(SPRING_SHAPE, "transform")}} +-.bt-secret-glide[data-at="1"]{transform:translateX(100%)} +-.bt-secret-seg{position:relative;z-index:1;height:28px;padding:0 14px;border:0;border-radius:999px;background:none;color:var(--bt-ink-2);font:inherit;font-size:13px;white-space:nowrap;cursor:pointer;transition:color .16s ease} +-.bt-secret-seg[aria-checked=true]{color:var(--bt-ink);font-weight:500} +-.bt-secret-seg:focus-visible{outline:2px solid var(--bt-accent);outline-offset:-2px} +-/* A key card asks the user to act, so the process group carrying it stays open in every +- work-details mode (same override as .bt-group-replies in styles.js). */ +-[data-step-process]:has(.bt-secret)>div:first-child{display:none!important} +-[data-step-process]:has(.bt-secret)>[data-step-process-body]{display:block!important;content-visibility:visible!important;max-height:none!important;overflow:visible!important;mask-image:none!important;scrollbar-gutter:auto!important} +-.bt-secret-ok{display:flex;align-items:center;gap:4px;margin-top:6px;animation:bt-secret-in .22s cubic-bezier(.22,1,.36,1)} +-.bt-secret-ok svg{width:13px;height:13px;color:#16a34a} +-@keyframes bt-secret-in{from{opacity:0;transform:translateY(-2px)}} +-.bt-secret-saved{display:flex;align-items:center;gap:6px;font-size:13px;line-height:18px;color:var(--bt-ink-2)} +-.bt-secret-note{font-size:12px;line-height:17px;color:var(--bt-ink-3)} +-.bt-secret-note code,.bt-secret-about code{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:11.5px} +-.bt-secret-warn{display:flex;align-items:center;gap:6px;padding:6px 10px;border-radius:8px;font-size:12px;line-height:16px;color:#b25e09;background:color-mix(in srgb,#f59e0b 12%,transparent)} +-.bt-secret-error{font-size:12px;line-height:16px;color:#e02135} +-.bt-secret .bt-q-btn:disabled{opacity:.5;cursor:default} +-.bt-secret-done .bt-q-head{align-items:center} +-.bt-secret-done .bt-q-title{font-size:13px;line-height:18px;color:var(--bt-ink-2);font-weight:500} +-.bt-secret-scope-tag{font-weight:400;color:var(--bt-ink-3)} +-.bt-secret-check{color:var(--bt-green,#22a06b);animation:bt-secret-pop ${SPRING_POP.duration}ms ${SPRING_POP.easing} backwards} +-.bt-secret-done{animation:bt-secret-settle .32s cubic-bezier(.22,1,.36,1) backwards} +-@keyframes bt-secret-pop{from{opacity:0;transform:scale(.4)}} +-@keyframes bt-secret-settle{from{opacity:.4;transform:scale(.985)}} +-.bt-secret-event{display:inline-flex;align-items:center;gap:5px} +-.bt-secret-dock{flex-direction:column;align-items:center} +-.bt-secret-dock .bt-q{max-width:min(550px,calc(100% - 32px))} +-.bt-secret-about{margin:0 0 12px;padding:0 4px;font-size:12px;line-height:17px;color:var(--bt-ink-2)} +-.bt-secret-title{display:flex;align-items:center;gap:6px;min-width:0;color:var(--bt-ink)} +-.bt-secret-title svg{flex:none;color:var(--bt-ink-2)} +-.bt-secret-purpose{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--bt-ink-2)} +-.bt-secret-faces{display:inline-flex} +-.bt-secret-faces>*+*{margin-left:-6px} +-.bt-secret-edit{display:flex;flex-direction:column;gap:14px;padding:4px 12px 14px;animation:bt-q-in ${SPRING_SHAPE.duration}ms ${SPRING_SHAPE.easing} backwards,bt-veil-in .2s ease-out backwards} +-.bt-secret-edit-block{display:flex;flex-direction:column;gap:8px} +-.bt-secret-edit-label{font-size:12px;line-height:16px;font-weight:600;color:var(--bt-ink-2)} +-.bt-secret-radios{display:flex;gap:16px;font-size:13px} +-.bt-secret-radio{display:inline-flex;align-items:center;gap:6px;cursor:pointer} +-.bt-secret-bots{display:flex;flex-wrap:wrap;gap:6px} +-.bt-secret-bot{display:inline-flex;align-items:center;gap:6px;height:30px;padding:0 10px 0 5px;border-radius:999px;border:.5px solid var(--bt-line);background:var(--bt-card);color:var(--bt-ink-2);font:inherit;font-size:12px;cursor:pointer;transition:background-color .12s ease,color .12s ease,border-color .12s ease} +-.bt-secret-bot[aria-pressed=true]{background:var(--bt-active);color:var(--bt-ink);border-color:var(--bt-line-2)} +-.bt-secret-bot svg{width:13px;height:13px} +-.bt-secret-inline{display:flex;gap:8px;align-items:center} +-.bt-secret-row-actions{display:flex;align-items:center;justify-content:flex-end;gap:8px;flex-wrap:wrap} +-.bt-secret-row-actions .bt-set-hint{flex:1} +-.bt-secret-danger{color:#e02135;display:inline-flex;align-items:center;gap:4px} +-.bt-secret-danger svg{width:13px;height:13px} +-.bt-secret-add .bt-input{height:34px} +-@media (prefers-reduced-motion:reduce){.bt-secret-glide{transition:none}.bt-secret-check,.bt-secret-done,.bt-secret-ok,.bt-secret-edit{animation:none}} ++var INBOX_CSS = ` ++.bt-inbox-row{min-height:40px;padding:6px 8px;gap:10px} ++.bt-inbox-mark{flex:none;width:36px;height:28px;display:inline-flex;align-items:center;justify-content:center;color:var(--bt-ink-2)} ++.bt-inbox-mark svg{width:18px;height:18px} ++.bt-inbox-label{flex:1 1 auto;min-width:0;font-size:14px;line-height:20px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis} ++.bt-inbox-count{flex:none;min-width:20px;height:20px;padding:0 6px;border-radius:999px;background:var(--bt-accent);color:var(--bt-accent-ink,#fff);font-size:11.5px;line-height:20px;font-weight:600;text-align:center;font-variant-numeric:tabular-nums;box-sizing:border-box} ++.bt-rail .bt-inbox-row{position:relative} ++.bt-rail .bt-inbox-count{position:absolute;top:2px;right:calc(50% - 22px);min-width:16px;height:16px;padding:0 4px;font-size:10px;line-height:16px;box-shadow:0 0 0 1.5px var(--bt-sidebar)} ++.bt-inbox-body{gap:18px} ++.bt-inbox-feedback{min-height:0;font-size:13px;line-height:18px;color:var(--bt-ink-2)} ++.bt-inbox-feedback:empty{display:none} ++.bt-inbox-group{display:flex;flex-direction:column;gap:8px} ++.bt-inbox-head{display:flex;align-items:center;gap:6px;margin:0;font-size:13px;line-height:18px;font-weight:600;color:var(--bt-ink-2)} ++.bt-inbox-head-count{font-weight:500;color:var(--bt-ink-3);font-variant-numeric:tabular-nums} ++.bt-inbox-empty{margin:0;font-size:13px;line-height:18px;color:var(--bt-ink-3)} ++.bt-inbox-more{margin:0;font-size:12.5px;line-height:18px;color:var(--bt-ink-3);font-variant-numeric:tabular-nums} ++.bt-inbox-show-all{align-self:flex-start;padding:0;border:0;background:none;font:inherit;font-size:12.5px;line-height:18px;color:var(--bt-accent);cursor:pointer} ++.bt-inbox-show-all:hover{text-decoration:underline} ++.bt-inbox-show-all:focus-visible{outline:2px solid color-mix(in srgb,var(--bt-accent) 45%,transparent);outline-offset:2px;border-radius:4px} ++.bt-inbox-list{display:flex;flex-direction:column;gap:6px} ++.bt-inbox-card{display:flex;align-items:flex-start;gap:10px;padding:10px 12px;border:.5px solid var(--bt-line-2);border-radius:12px;background:var(--bt-card,var(--bt-main))} ++.bt-inbox-card:focus-within{box-shadow:0 0 0 2px color-mix(in srgb,var(--bt-accent) 35%,transparent)} ++.bt-inbox-ava{flex:none;line-height:0;padding-top:2px} ++.bt-inbox-copy{flex:1 1 auto;min-width:0;display:flex;flex-direction:column;gap:2px} ++.bt-inbox-top{display:flex;align-items:center;gap:8px;min-width:0} ++.bt-inbox-status{flex:none;display:inline-flex;align-items:center;gap:4px;height:20px;padding:0 7px 0 5px;border-radius:999px;font-size:11.5px;line-height:20px;font-weight:600;white-space:nowrap} ++.bt-inbox-status svg{flex:none} ++.bt-inbox-status[data-tone=ask]{background:color-mix(in srgb,var(--bt-star) 18%,transparent);color:color-mix(in srgb,var(--bt-star) 70%,var(--bt-ink))} ++.bt-inbox-status[data-tone=danger]{background:rgba(224,33,53,.12);color:#c21d2e} ++.bt-inbox-status[data-tone=run]{background:color-mix(in srgb,var(--bt-accent) 14%,transparent);color:color-mix(in srgb,var(--bt-accent) 80%,var(--bt-ink))} ++.bt-inbox-status[data-tone=done]{background:color-mix(in srgb,var(--bt-green) 16%,transparent);color:color-mix(in srgb,var(--bt-green) 70%,var(--bt-ink))} ++.bt-inbox-status[data-tone=muted]{background:var(--bt-hover);color:var(--bt-ink-2)} ++.bt-inbox-meta{min-width:0;font-size:12px;line-height:16px;color:var(--bt-ink-3);white-space:nowrap;overflow:hidden;text-overflow:ellipsis} ++.bt-inbox-title{font-size:13.5px;line-height:19px;color:var(--bt-ink);white-space:nowrap;overflow:hidden;text-overflow:ellipsis} ++.bt-inbox-reason,.bt-inbox-hint,.bt-inbox-activity{font-size:12px;line-height:16px;color:var(--bt-ink-2);white-space:nowrap;overflow:hidden;text-overflow:ellipsis} ++/* Needs you is answered on the card, so its ask is the whole text (#1177). The phone sheet clamps these two classes to two lines at a lower specificity; this unsets that clamp. In progress and Done stay one line. */ ++.bt-inbox-card:is([data-status=approval],[data-status=held],[data-status=input],[data-status=result],[data-status=failed]) :is(.bt-inbox-title,.bt-inbox-reason){white-space:pre-wrap;overflow-wrap:anywhere;overflow:visible;text-overflow:clip;display:block;min-width:0;-webkit-line-clamp:unset;-webkit-box-orient:unset} ++.bt-inbox-calls{font-size:12px;line-height:16px;color:var(--bt-ink-3);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;font-variant-numeric:tabular-nums} ++.bt-inbox-activity{color:var(--bt-ink-3)} ++.bt-inbox-error{font-size:12px;line-height:16px;color:#c21d2e;overflow-wrap:anywhere} ++.bt-inbox-writeback{font-size:12px;line-height:16px;color:#c21d2e;overflow-wrap:anywhere} ++.bt-inbox-evidence{display:flex;flex-wrap:wrap;gap:6px;font-size:12px;line-height:16px} ++.bt-inbox-evidence-link{color:var(--bt-blue,#3a7afe);text-decoration:none;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:100%} ++.bt-inbox-evidence-link:hover{text-decoration:underline} ++.bt-inbox-result{display:flex;flex-wrap:wrap;align-items:center;gap:6px;margin-top:6px} ++.bt-inbox-result .bt-send,.bt-inbox-result .bt-soft{height:28px;font-size:12px} ++.bt-inbox-answer{display:flex;flex-wrap:wrap;align-items:center;gap:6px;margin-top:6px} ++.bt-inbox-answer-text{flex:1 1 140px;min-width:0;height:28px;padding:0 8px;border:.5px solid var(--bt-line-2);border-radius:8px;background:transparent;color:var(--bt-ink);font:inherit;font-size:12px} ++.bt-inbox-answer .bt-send,.bt-inbox-answer .bt-soft{height:28px;font-size:12px} ++.bt-inbox-answer button[aria-pressed=true]{background:var(--bt-active);color:var(--bt-ink)} ++.bt-inbox-actions{flex:none;display:flex;align-items:center;gap:6px;align-self:center} ++.bt-inbox-actions .bt-send{height:28px;font-size:12px} + `; + +-// src/client/turn-reasoning.js +-function turnReasoning(steps, current) { +- const members = steps.length ? steps : [current]; +- if (members.find((step) => stepReasoning(step) !== "")?.step !== current.step) return ""; +- return members.filter((step) => !thinkingLive(step)).map(stepReasoning).filter(Boolean).join("\n\n"); +-} +-var texts = (step) => step.blocks.filter((block) => block.kind === "text" && block.text.trim() !== ""); +-var thinkingLive = (step) => step.status === "running" && texts(step).length === 0 && step.blocks.some((block) => block.kind === "reasoning"); +-var stepReasoning = (step) => step.blocks.filter((block) => block.kind === "reasoning").map((block) => block.text).join("\n").trim(); +- +-// src/client/cells.js +-var import_react6 = require("react"); +-var import_react_dom = require("react-dom"); +-var import_dsh_client_ui_primitives = require("@deepseek-ai/dsh-client-ui-primitives"); +-var mdLabels = () => ({ code: { copyLabel: t("Copy"), copiedLabel: t("Copied") }, footnotes: t("Footnotes") }); +-function Bubbles({ texts: texts2, roster, selfId, actions, streaming, sessionId, msgKey, useUi, lead, mentions }) { +- const onClick = (event) => { +- const target = mentionTarget(event); +- if (target === null) return; +- event.preventDefault(); +- event.stopPropagation(); +- actions.openSession(target); ++// src/shared/board-card.js ++function taskCardKey(id) { ++ const text2 = String(id ?? "").trim(); ++ return text2 === "" ? null : `task:${text2}`; ++} ++function taskCard(task) { ++ const key = taskCardKey(task?.id); ++ if (key === null) return null; ++ return { ++ source: "task", ++ key, ++ id: String(task.id), ++ title: String(task.title ?? "") + }; +- const reactions = useUi ? useUi((state) => msgKey ? state.reactions?.[msgKey] ?? null : null) : null; +- return (0, import_react6.createElement)( +- "div", +- { className: "bt-astack", "data-lead": lead ? "" : void 0, onClickCapture: onClick }, +- texts2.map((text, index) => { +- const key = msgKey ? `${msgKey}:${index}` : void 0; +- const picked = key ? reactions?.[index] ?? [] : []; +- const last = index === texts2.length - 1; +- const live = streaming && last; - return (0, import_react6.createElement)( -+ { className: "bt-update-actions" }, -+ update.notesUrl ? (0, import_react17.createElement)("a", { className: "bt-update-notes", href: update.notesUrl, target: "_blank", rel: "noopener noreferrer" }, t("What's new")) : null, -+ phase === "installed" ? null : (0, import_react17.createElement)("button", { -+ type: "button", -+ className: "bt-update-go", -+ disabled: phase === "installing", -+ onClick: start -+ }, phase === "installing" ? t("Updating…") : phase === "failed" ? t("Try again") : t("Update")) -+ ) -+ ) : null, -+ dshRow ? (0, import_react17.createElement)( - "div", -- { key: `pending-${id}`, className: "bt-activity" }, -- (0, import_react6.createElement)(BotMark, { bot, size: 22, state: speakerTool ? toolState(speakerTool) : "thinking" }), -- (0, import_react6.createElement)(Shimmer, null, speakerTool ? `${bot.name} · ${workPhrase(speakerTool)}…` : t("{name} is typing…", { name: bot.name })) +- "div", +- { key: index, className: "bt-msg" }, +- (0, import_react6.createElement)( +- "div", +- { className: "bt-msg-line" }, +- lead && last ? lead : null, +- (0, import_react6.createElement)( +- "div", +- { className: "bt-bubble" }, +- (0, import_react6.createElement)(import_dsh_client_ui_primitives.MarkdownText, { text: linkMentions(text, roster.bots, selfId), streaming: live, labels: mdLabels(), variant: "compact", fileMentions: mentions }) +- ), +- live ? null : (0, import_react6.createElement)(MessageTools, { text, name: roster.byId[selfId]?.name ?? t("Bot"), sessionId, react: key ? (emoji) => actions.toggleReaction(msgKey, index, emoji) : void 0, actions }) +- ), +- picked.length ? (0, import_react6.createElement)("div", { className: "bt-reacts" }, picked.map((emoji) => (0, import_react6.createElement)("button", { +- key: emoji, +- type: "button", +- className: "bt-react", +- "aria-label": t("Remove reaction {emoji}", { emoji }), +- onClick: () => actions.toggleReaction(msgKey, index, emoji) +- }, emoji))) : null - ); - }) -+ { className: "bt-update-dsh" }, -+ (0, import_react17.createElement)("div", { className: "bt-update-title" }, t("DSH {version}", { version: dsh.verified })), -+ (0, import_react17.createElement)("div", { className: "bt-update-text" }, dshText), -+ dshMessage ? (0, import_react17.createElement)("div", { className: "bt-update-error", role: "alert" }, t("The update did not finish: {reason}", { reason: dshMessage })) : null, -+ (0, import_react17.createElement)( -+ "div", -+ { className: "bt-update-actions" }, -+ dsh.kind === "app" ? dsh.available ? (0, import_react17.createElement)("button", { type: "button", className: "bt-update-go", onClick: () => void actions.openAppUpdates(dsh.verified).catch(() => { -+ }) }, t("Open updates")) : null : !dsh.available && dshPhase === "idle" ? null : dshPhase === "installed" ? null : (0, import_react17.createElement)("button", { -+ type: "button", -+ className: "bt-update-go", -+ disabled: dshPhase === "installing", -+ onClick: startDsh -+ }, dshPhase === "installing" ? t("Updating…") : dshPhase === "failed" ? t("Try again") : t("Update")) -+ ) -+ ) : null -+ ), document.body) : null -+ ); +} -+function ConnectApps({ wide, actions }) { -+ if (!wide) return null; -+ return (0, import_react17.createElement)( -+ "span", -+ { className: "bt-connect-wrap" }, -+ (0, import_react17.createElement)( -+ "button", -+ { type: "button", className: "bt-connect bt-connect-foot", onClick: () => actions.openConnectors() }, -+ (0, import_react17.createElement)(PlugIcon), -+ t("Connect apps") -+ ) - ); - } --var DRAWN = /* @__PURE__ */ new Set(["message_bot", "stop_bot", "create_bot", "update_bot", "group_relay", "create_group", "update_group", "delete_group", "post_to_group", "ask_user", "request_secret", "ask_user_question", "read_group_chat", "remember", "forget"]); --var CARDS = /* @__PURE__ */ new Set(["ask_user", "request_secret"]); --function callTree(root) { -- const calls = []; -- const seen = /* @__PURE__ */ new Set(); -- const visit = (call2) => { -- const id = call2.callId ?? call2; -- if (seen.has(id)) return; -- seen.add(id); -- calls.push(call2); -- for (const child of call2.subCalls ?? []) visit(child); -- }; -- visit(root); -- return calls; -+function YouAvatar() { -+ return (0, import_react17.createElement)( -+ "span", -+ { className: "bt-you-wrap" }, -+ (0, import_react17.createElement)("span", { className: "bt-you", "aria-hidden": true }, (0, import_react17.createElement)(BotMark, { look: MAIN_LOOK, size: 24 })), -+ (0, import_react17.createElement)("span", { style: { position: "absolute", width: 1, height: 1, overflow: "hidden", clip: "rect(0 0 0 0)" } }, t("Settings")) -+ ); - } --function useShownCards(actions, sessionId, callIds) { -- const key = JSON.stringify(callIds); -- (0, import_react6.useLayoutEffect)(() => { -- const ids = JSON.parse(key); -- if (ids.length === 0 || !actions.noteCards) return void 0; -- actions.noteCards(sessionId, ids, 1); -- return () => actions.noteCards(sessionId, ids, -1); -- }, [sessionId, key]); -+var openShellSettings = null; -+var openShellOnboarding = null; -+var shellShortcuts = null; -+function holdShellShortcuts(shortcuts) { -+ shellShortcuts = shortcuts; -+ return () => { -+ if (shellShortcuts === shortcuts) shellShortcuts = null; -+ }; - } --function renderCall(call2, { sessionId, roster, actions, useUi, useWork, findBot }) { -- const tool = toolName(call2); -- const done = call2.kind === "tool-result"; -- const { text: arg, value: argValue } = toolArgs(call2); -- const self = roster.byId[sessionId]; -- switch (tool) { -- case "message_bot": { -- const target = findBot(arg("to")) ?? { name: arg("to") ?? "…", color: "gray" }; -- if (done && call2.isError) return (0, import_react6.createElement)("div", { className: "bt-event" }, t("Couldn't message {name}", { name: target.name })); -- return (0, import_react6.createElement)(EventLine, { prefix: t("Messaged"), bots: [target], onOpen: (bot) => bot.id && actions.openExchange(self?.id ?? sessionId, bot.id), whole: true }); -- } -- case "stop_bot": { -- const target = findBot(arg("bot")) ?? { name: arg("bot") ?? "…", color: "gray" }; -- if (done && call2.isError) return (0, import_react6.createElement)("div", { className: "bt-event" }, t("Couldn't stop {name}", { name: target.name })); -- return (0, import_react6.createElement)(EventLine, { prefix: t("Stopped"), bots: [target], onOpen: (bot) => bot.id && actions.openExchange(self?.id ?? sessionId, bot.id), whole: true }); -- } -- case "create_bot": { -- const created = findBot(arg("name")); -- if (!done || created === void 0) return null; -- return (0, import_react6.createElement)(EventLine, { prefix: t("Created"), bots: [created], onOpen: (bot) => actions.openSession(bot.id) }); -- } -- case "update_bot": { -- const target = findBot(arg("name")) ?? findBot(arg("bot")) ?? (arg("bot") ? void 0 : self); -- return done && target ? (0, import_react6.createElement)(EventLine, { prefix: t("Updated"), bots: [target], onOpen: (bot) => actions.openSession(bot.id) }) : null; -- } -- case "group_relay": -- return (0, import_react6.createElement)(GroupReplies, { sessionId, callId: call2.callId, done, content: call2.content, roster, actions, useUi, useWork }); -- case "create_group": -- case "update_group": -- case "delete_group": -- case "post_to_group": { -- const verbs = { -- create_group: ["Created group", "Couldn't create the group"], -- update_group: ["Updated group", "Couldn't update the group"], -- delete_group: ["Deleted group", "Couldn't delete the group"], -- post_to_group: ["Posted in", "Couldn't post in the group"] -- }[tool]; -- if (!done) return null; -- const result = contentText(call2.content); -- const named = /^(Created group|Updated|Nothing changed in|Deleted|Posted in) "([^"]+)"/.exec(result); -- if (call2.isError || !named) return (0, import_react6.createElement)("div", { className: "bt-event", title: result }, t(verbs[1])); -- const name = named[2]; -- const room2 = roster.rooms.find((entry) => entry.name === name); -- return (0, import_react6.createElement)(GroupEvent, { prefix: t(verbs[0]), room: tool === "delete_group" ? void 0 : room2, name, roster, actions }); -- } -- case "ask_user": { -- const question = roster.questions?.[sessionId]; -- if (!question || question.callId !== call2.callId) return (0, import_react6.createElement)("span", { className: "bt-q-gone", hidden: true }); -- return (0, import_react6.createElement)(PendingQuestion, { sessionId, question, actions, inline: true }); -- } -- case "request_secret": -- return (0, import_react6.createElement)(SecretToolCell, { sessionId, callId: call2.callId, name: arg("name"), done, content: call2.content, roster, actions }); -- case "ask_user_question": { -- if (!done) return null; -- let parsed = null; -- try { -- parsed = JSON.parse(contentText(call2.content)); -- } catch { -- parsed = null; -- } -- const answer = parsed?.answers?.[0]; -- const question = argValue("questions")?.[0]; -- if (!answer) return null; -- return (0, import_react6.createElement)("div", { className: "bt-q-inline" }, (0, import_react6.createElement)(SettledQuestion, { question, answer })); -- } -- case "read_group_chat": { -- if (!done) return null; -- const room2 = roster.rooms.find((entry) => entry.name === arg("group")); -- return room2 ? (0, import_react6.createElement)(GroupEvent, { prefix: t("Read"), room: room2, roster, actions }) : null; -+function openHarnessSettings() { -+ requestAnimationFrame(() => { -+ if (openShellSettings) { -+ openShellSettings(); -+ return; - } -- case "remember": -- case "forget": -- return done && !call2.isError ? (0, import_react6.createElement)(MemoryEvent, { sessionId, scope: arg("scope"), result: contentText(call2.content), roster, actions, findBot }) : null; -- default: -- return null; -- } -+ const desktop = document.querySelector('button[aria-haspopup="menu"][data-signed-out]'); -+ if (desktop) openThroughMenu(desktop); -+ else pressSettingsShortcut(); -+ }); - } --function ToolCell({ node, sessionId, useRoster, useUi, useWork, actions }) { -- const roster = useRoster((value) => value); -- const root = node.data.root; -- const turn = node.location?.turn?.turn ?? root.turn; -- const hidden = isHiddenTurn(roster, sessionId, turn); -- const findBot = (nameOrId) => roster.byId[nameOrId] ?? roster.bots.find((bot) => bot.name.toLowerCase() === String(nameOrId ?? "").toLowerCase()); -- const calls = hidden ? [] : callTree(root); -- const drawn = calls.filter((call2) => DRAWN.has(toolName(call2))); -- const running = root.kind === "tool-result" ? void 0 : calls.findLast((call2) => call2.kind !== "tool-result"); -- const note = running ? toolActivity(toolName(running), toolArgs(running).text, findBot, roster.byId[sessionId]) : null; -- useLiveNote(actions, sessionId, `tool:${root.callId ?? node.key}`, note); -- useShownCards(actions, sessionId, drawn.filter((call2) => CARDS.has(toolName(call2)) && call2.callId).map((call2) => call2.callId)); -- if (drawn.length === 0) return null; -- const env = { sessionId, roster, actions, useUi, useWork, findBot }; -- return (0, import_react6.createElement)(import_react6.Fragment, null, drawn.map((call2, index) => (0, import_react6.createElement)(import_react6.Fragment, { key: call2.callId ?? index }, renderCall(call2, env)))); -+function openHarnessOnboarding(id) { -+ requestAnimationFrame(() => { -+ if (openShellOnboarding) openShellOnboarding(id); -+ else openHarnessSettings(); -+ }); ++ ++// src/client/board-card.js ++var import_react15 = require("react"); ++var TASK_SKIN = { ++ card: "bt-board-card", ++ top: "bt-board-card-top", ++ id: "bt-board-id", ++ badge: "bt-board-badge", ++ title: "bt-board-title", ++ labels: "bt-board-labels", ++ label: "bt-board-label", ++ owner: "bt-board-who", ++ note: "bt-board-note" ++}; ++function BoardCard({ ++ card, ++ skin = TASK_SKIN, ++ attrs = {}, ++ idText = "", ++ topExtra = null, ++ badge = "", ++ meta = null, ++ note = "", ++ footer = null, ++ actions = null, ++ emptyTitle = "", ++ draggable = false, ++ dragValue = "", ++ onDragStart = null, ++ onDragEnd = null ++}) { ++ const labels = Array.isArray(card?.labels) ? card.labels : []; ++ return (0, import_react15.createElement)( ++ "article", ++ { ++ ...attrs, ++ className: skin.card, ++ "data-source": card?.source, ++ "data-key": card?.key ?? void 0, ++ draggable: draggable || void 0, ++ onDragStart: draggable ? (event) => { ++ event.dataTransfer?.setData("text/plain", dragValue); ++ if (event.dataTransfer) event.dataTransfer.effectAllowed = "move"; ++ onDragStart?.(); ++ } : void 0, ++ onDragEnd: draggable ? () => onDragEnd?.() : void 0 ++ }, ++ (0, import_react15.createElement)( ++ "span", ++ { className: skin.top }, ++ (0, import_react15.createElement)("span", { className: skin.id }, idText), ++ topExtra, ++ badge ? (0, import_react15.createElement)("span", { className: skin.badge, "data-badge": card?.badge ?? void 0 }, badge) : null ++ ), ++ (0, import_react15.createElement)("span", { className: skin.title }, card?.title || emptyTitle), ++ labels.length ? (0, import_react15.createElement)("span", { className: skin.labels }, labels.map((name) => (0, import_react15.createElement)("span", { key: name, className: skin.label, "data-label": name }, name))) : null, ++ card?.owner ? (0, import_react15.createElement)("span", { className: skin.owner, "data-kind": card.ownerKind ?? "human" }, card.owner) : null, ++ note ? (0, import_react15.createElement)("span", { className: skin.note }, note) : null, ++ meta, ++ footer, ++ actions + ); } -- --// src/client/activity-row.js --var import_react7 = require("react"); --var import_react_dom2 = require("react-dom"); --var PHRASE_HOLD_MS = 800; --var drawPhrase = (pool) => t(pool[Math.min(pool.length - 1, Math.floor(Math.random() * pool.length))]); --function elapsedLabel(ms) { -- const minutes = Math.floor(ms / 6e4); -- if (minutes < 60) return `${Math.max(1, minutes)}m`; -- const hours = Math.floor(minutes / 60); -- return minutes % 60 === 0 ? `${hours}h` : `${hours}h ${minutes % 60}m`; -+var MODELS_STEP_ID = "flybot-models"; -+var MODELS_STEP_ORDER = 1e3; -+function ModelsSettingsStep({ explicit, complete, openSection }) { -+ (0, import_react17.useLayoutEffect)(() => { -+ if (explicit) openSection("models"); -+ complete(); -+ }, []); -+ return null; +-var REACTIONS = ["👍", "❤️", "😂", "😮", "😢", "🙏"]; +-function useDismiss(open, close, refs) { +- (0, import_react6.useEffect)(() => { +- if (!open) return void 0; +- const onDown = (event) => { +- if (refs.some((ref) => ref.current?.contains(event.target))) return; +- close(); +- }; +- const onKey = (event) => { +- if (event.key === "Escape") { +- event.stopPropagation(); +- close(); +- } +- }; +- window.addEventListener("mousedown", onDown); +- window.addEventListener("keydown", onKey, true); +- return () => { +- window.removeEventListener("mousedown", onDown); +- window.removeEventListener("keydown", onKey, true); +- }; +- }, [open]); ++ ++// src/client/kanban.js ++var import_react16 = require("react"); ++var POLL_MS4 = 15e3; ++function BoardIcon() { ++ return (0, import_react16.createElement)( ++ "svg", ++ { width: 18, height: 18, viewBox: "0 0 20 20", fill: "none", stroke: "currentColor", strokeWidth: 1.6, strokeLinejoin: "round", "aria-hidden": true }, ++ (0, import_react16.createElement)("rect", { x: 2.5, y: 3, width: 4, height: 14, rx: 1 }), ++ (0, import_react16.createElement)("rect", { x: 8, y: 3, width: 4, height: 9, rx: 1 }), ++ (0, import_react16.createElement)("rect", { x: 13.5, y: 3, width: 4, height: 12, rx: 1 }) ++ ); } --function useRunningHost(active) { -- const [host, setHost] = (0, import_react7.useState)(null); -- (0, import_react7.useEffect)(() => { -- if (!active) { -- setHost(null); -- return void 0; -- } -- let frame = 0; -- const find = () => { -- frame = 0; -- const node = document.querySelector("[data-chat-flow] > [data-chat-running]"); -- setHost((prev) => prev === node ? prev : node); -+function openHarnessModels() { -+ openHarnessOnboarding(MODELS_STEP_ID); +-function MessageTools({ text, name, sessionId, react, actions }) { +- const [panel, setPanel] = (0, import_react6.useState)(null); +- const [place, setPlace] = (0, import_react6.useState)(null); +- const [copied, setCopied] = (0, import_react6.useState)(false); +- const tools = (0, import_react6.useRef)(null); +- const pop = (0, import_react6.useRef)(null); +- const close = () => setPanel(null); +- useDismiss(panel !== null, close, [tools, pop]); +- const open = (kind, event) => { +- if (panel === kind) { +- close(); +- return; ++function shortId(id) { ++ const hex = String(id ?? "").replace(/^task-/, ""); ++ return `#${hex.slice(-6)}`; +} -+function openThroughMenu(trigger) { -+ const before = new Set(document.querySelectorAll('[role="menu"]')); -+ trigger.click(); -+ let frames = 0; -+ const look = () => { -+ const menu = [...document.querySelectorAll('[role="menu"]')].find((node) => !before.has(node)); -+ const item = menu?.querySelector('button[role="menuitem"]'); -+ if (item) item.click(); -+ else if (++frames < 30) requestAnimationFrame(look); -+ else pressSettingsShortcut(); -+ }; -+ requestAnimationFrame(look); ++var ARCHIVE_SHOWN = 20; ++function boardTasksFor(tasks, archive, limit = ARCHIVE_SHOWN) { ++ const newest = [...archive ?? []].sort((a, b) => (b.updatedAt ?? 0) - (a.updatedAt ?? 0) || (b.createdAt ?? 0) - (a.createdAt ?? 0)); ++ return [...tasks ?? [], ...newest.slice(0, limit)]; +} -+function pressSettingsShortcut() { -+ const binding = shellShortcuts?.catalog.getSnapshot().find((row) => row.id === "settings.open")?.binding; -+ if (!binding) return; -+ const held = new Set(binding.modifiers); -+ window.dispatchEvent(new KeyboardEvent("keydown", { -+ code: binding.code, -+ key: binding.code === "Comma" ? "," : "", -+ bubbles: true, -+ cancelable: true, -+ ctrlKey: held.has("control"), -+ altKey: held.has("alt"), -+ shiftKey: held.has("shift"), -+ metaKey: held.has("meta") -+ })); ++function boardColumns(tasks) { ++ const columns = { "not-started": [], "in-progress": [], done: [] }; ++ for (const task of tasks ?? []) { ++ const column = columnOf(task); ++ (columns[column] ?? columns["not-started"]).push(task); ++ } ++ for (const list of Object.values(columns)) list.sort((a, b) => (b.updatedAt ?? 0) - (a.updatedAt ?? 0)); ++ return columns; ++} ++function columnTree(tasks) { ++ const ids = new Set((tasks ?? []).map((task) => task.id)); ++ const childrenOf = /* @__PURE__ */ new Map(); ++ const nested = /* @__PURE__ */ new Set(); ++ for (const task of tasks ?? []) { ++ if (!task.parentId || !ids.has(task.parentId)) continue; ++ const parent = tasks.find((item) => item.id === task.parentId); ++ if (parent?.kind !== "group") continue; ++ const list = childrenOf.get(parent.id) ?? []; ++ list.push(task); ++ childrenOf.set(parent.id, list); ++ nested.add(task.id); ++ } ++ const nests = []; ++ const loose = []; ++ for (const task of tasks ?? []) { ++ if (nested.has(task.id)) continue; ++ if (task.kind === "group") nests.push({ task, children: childrenOf.get(task.id) ?? [] }); ++ else loose.push(task); ++ } ++ return { nests, loose }; ++} ++function adminOf(roster, room2) { ++ const me = roster?.self?.memberId; ++ if (!me) return room2?.origin !== "face-to-face"; ++ const human = (room2?.participants ?? []).find((person) => person.kind === "human" && person.id === me && person.leftSeq === void 0); ++ if (human?.role === "admin") return true; ++ const admin = roster?.byId?.[room2?.admin]; ++ return Boolean(admin && !admin.remote && admin.ownerId === me); ++} ++function boardRail(roster) { ++ const me = roster?.self?.memberId; ++ const bots = (roster?.bots ?? []).filter((bot) => bot && !bot.remote && (!me || !bot.ownerId || bot.ownerId === me)); ++ const ids = new Set(bots.map((bot) => bot.id)); ++ const children = /* @__PURE__ */ new Map(); ++ for (const bot of bots) { ++ const parent = bot.createdBy && ids.has(bot.createdBy) ? bot.createdBy : ""; ++ const list = children.get(parent) ?? []; ++ list.push(bot); ++ children.set(parent, list); ++ } ++ for (const list of children.values()) list.sort((a, b) => (a.createdAt ?? 0) - (b.createdAt ?? 0) || String(a.name).localeCompare(String(b.name))); ++ const rows = []; ++ const walk = (parent, depth) => { ++ for (const bot of children.get(parent) ?? []) { ++ rows.push({ kind: "bot", id: bot.id, name: bot.name, depth, bot }); ++ walk(bot.id, depth + 1); + } +- const rect = event.currentTarget.getBoundingClientRect(); +- const below = rect.bottom + 220 < window.innerHeight; +- setPlace(kind === "react" ? { left: rect.left + rect.width / 2, top: rect.top - 6, "--bt-origin": "bottom center" } : { left: Math.min(rect.left, window.innerWidth - 208), ...below ? { top: rect.bottom + 4 } : { bottom: window.innerHeight - rect.top + 4, "--bt-origin": "bottom left" } }); +- setPanel(kind); +- }; +- const copy2 = () => { +- void navigator.clipboard?.writeText(text).then(() => { +- setCopied(true); +- setTimeout(() => setCopied(false), 1e3); +- }); + }; +- const download2 = () => { +- const url = URL.createObjectURL(new Blob([text], { type: "text/markdown" })); +- const link = Object.assign(document.createElement("a"), { href: url, download: `${name}-message.md` }); +- link.click(); +- setTimeout(() => URL.revokeObjectURL(url), 1e3); ++ walk("", 0); ++ const groups = (roster?.rooms ?? []).filter((room2) => room2 && room2.hidden !== true && room2.origin !== "face-to-face" && adminOf(roster, room2)).map((room2) => ({ kind: "group", id: room2.id, name: room2.displayName || room2.name || t("Group"), room: room2 })); ++ return { bots: rows, groups }; ++} ++function canMoveTask(roster, task) { ++ const me = roster?.self?.memberId; ++ if (!me) return true; ++ const bot = roster?.byId?.[task?.ownerBotId]; ++ return Boolean(bot && !bot.remote && (!bot.ownerId || bot.ownerId === me)); ++} ++var TERMINAL_STATUSES = /* @__PURE__ */ new Set(["accepted", "cancelled", "failed"]); ++function finishedAt(task) { ++ const terminal = (task?.actors ?? []).find((entry) => TERMINAL_STATUSES.has(entry?.status)); ++ if (Number.isFinite(terminal?.at)) return terminal.at; ++ return Number.isFinite(task?.updatedAt) ? task.updatedAt : void 0; ++} ++function priorityLabel(priority) { ++ if (priority === "urgent") return t("Highest"); ++ if (priority === "high") return t("High"); ++ if (priority === "low") return t("Low"); ++ return t("Normal"); ++} ++function statusLabel(status) { ++ switch (status) { ++ case "claimed": ++ return t("Claimed"); ++ case "working": ++ return t("In progress"); ++ case "blocked": ++ return t("Blocked"); ++ case "result-ready": ++ return t("Result ready"); ++ case "accepted": ++ return t("Done"); ++ case "cancelled": ++ return t("Cancelled"); ++ case "failed": ++ return t("Failed"); ++ default: ++ return t("Not started"); ++ } ++} ++function dropLabel(column) { ++ if (column === "in-progress") return t("Start task"); ++ if (column === "done") return t("Complete task"); ++ return t("Not started"); +} -+function AccountLauncher({ seat = true, settingsOpen, openSettings, openOnboarding, actions }) { -+ const [open, setOpen] = (0, import_react17.useState)(false); -+ (0, import_react17.useLayoutEffect)(() => { -+ if (!seat) return void 0; -+ actions.seatAccount(true); -+ return () => actions.seatAccount(false); -+ }, [seat]); -+ const [box, setBox] = (0, import_react17.useState)(null); -+ const button = (0, import_react17.useRef)(null); -+ (0, import_react17.useEffect)(() => { -+ if (settingsOpen) setOpen(false); -+ }, [settingsOpen]); -+ (0, import_react17.useEffect)(() => { -+ if (!openSettings) return void 0; -+ openShellSettings = openSettings; ++function columnTitle(column) { ++ if (column === "in-progress") return t("In progress"); ++ if (column === "done") return t("Done"); ++ return t("Not started"); ++} ++function botName(roster, task) { ++ return roster?.byId?.[task.ownerBotId]?.name || task.ownerBotName || ""; ++} ++function lanesOf(tasks, roster) { ++ const order = []; ++ const map = /* @__PURE__ */ new Map(); ++ for (const task of tasks) { ++ const id = task.ownerBotId ?? ""; ++ if (!map.has(id)) { ++ map.set(id, []); ++ order.push(id); ++ } ++ map.get(id).push(task); ++ } ++ return order.map((id) => ({ id, name: botName(roster, map.get(id)[0]) || id, tasks: map.get(id) })); ++} ++var upsert = (list, task) => list.some((item) => item.id === task.id) ? list.map((item) => item.id === task.id ? task : item) : [...list, task]; ++var without = (list, id) => list.filter((item) => item.id !== id); ++function useBoardTasks(actions, scope) { ++ const [own, setOwn] = (0, import_react16.useState)([]); ++ const [groupTasks, setGroupTasks] = (0, import_react16.useState)([]); ++ const [archive, setArchive] = (0, import_react16.useState)([]); ++ const key = scope?.kind === "group" ? `group:${scope.roomId}` : `bot:${scope.botId ?? ""}`; ++ (0, import_react16.useEffect)(() => { ++ let stopped = false; ++ const load = () => { ++ const query = scope?.kind === "group" ? { roomId: scope.roomId } : {}; ++ const archiveQuery = scope?.kind === "bot" ? { botId: scope.botId } : query; ++ const job = scope?.kind === "group" ? Promise.resolve(actions?.tasks?.(query)) : Promise.resolve(actions?.tasks?.()); ++ void job.then((list) => { ++ if (stopped || !Array.isArray(list)) return; ++ if (scope?.kind === "group") setGroupTasks(list); ++ else setOwn(list); ++ }).catch(() => void 0); ++ void Promise.resolve(actions?.tasks?.({ ...archiveQuery, archive: true, limit: ARCHIVE_SHOWN })).then((list) => { ++ if (stopped || !Array.isArray(list)) return; ++ setArchive(list.filter((task) => columnOf(task) === "done")); ++ }).catch(() => void 0); ++ }; ++ load(); ++ const timer = setInterval(load, POLL_MS4); + return () => { -+ if (openShellSettings === openSettings) openShellSettings = null; - }; -- const observer = new MutationObserver(() => { -- if (!frame) frame = requestAnimationFrame(find); -- }); -- observer.observe(document.querySelector("[data-conversation-scroll]") ?? document.body, { childList: true, subtree: true }); -- find(); -+ }, [openSettings]); -+ (0, import_react17.useEffect)(() => { -+ if (!openOnboarding) return void 0; -+ openShellOnboarding = openOnboarding; - return () => { -- cancelAnimationFrame(frame); -- observer.disconnect(); -+ if (openShellOnboarding === openOnboarding) openShellOnboarding = null; - }; -- }, [active]); -- return host; --} --function useRowEntrances(replacing) { -- const check = (0, import_react7.useRef)(replacing); -- check.current = replacing; -- (0, import_react7.useEffect)(() => { -- const seen = /* @__PURE__ */ new WeakSet(); -- const started = Date.now(); -- let column = null; -- let frame = 0; -- const settle = (event) => { -- if (event.target !== event.currentTarget || !/^bt-row/.test(event.animationName)) return; -- delete event.currentTarget.dataset.btEnter; -- event.currentTarget.removeEventListener("animationend", settle); -+ }, [openOnboarding]); -+ (0, import_react17.useEffect)(() => { -+ if (!open) return void 0; -+ const onDown = (event) => { -+ if (event.target instanceof Element && (event.target.closest(".bt-account-menu") || button.current?.contains(event.target))) return; -+ setOpen(false); - }; -- const scan = () => { -- frame = 0; -- const next = document.querySelector("[data-chat-flow]"); -- if (next !== column) { -- observer.disconnect(); -- column = next; -- if (column) observer.observe(column, { childList: true, subtree: true }); -- } -- if (!column) return; -- const items = [...column.querySelectorAll(":scope > [data-chat-flow-key]")]; -- let last = -1; -- items.forEach((item, index) => { -- if (seen.has(item)) last = index; -- }); -- const fresh = items.filter((item) => !seen.has(item) && item.offsetHeight > 0); -- for (const item of fresh) seen.add(item); -- if (last < 0 || Date.now() - started < 800 || fresh.length > 3) return; -- for (const item of fresh) { -- if (items.indexOf(item) < last) continue; -- const reply = [void 0, "assistant-step", "tool-call"].includes(item.dataset.chatFlowKind); -- item.dataset.btEnter = reply && check.current() ? "after-collapse" : "new"; -- item.addEventListener("animationend", settle); -+ const onKey = (event) => { -+ if (event.key === "Escape") { -+ event.stopPropagation(); -+ setOpen(false); -+ button.current?.focus(); - } - }; -- const observer = new MutationObserver(() => { -- if (!frame) frame = requestAnimationFrame(scan); -- }); -- scan(); -+ window.addEventListener("mousedown", onDown); -+ window.addEventListener("keydown", onKey, true); - return () => { -- cancelAnimationFrame(frame); -- observer.disconnect(); -+ window.removeEventListener("mousedown", onDown); -+ window.removeEventListener("keydown", onKey, true); - }; -- }, []); -+ }, [open]); -+ const toggle = () => { -+ const rect = button.current?.getBoundingClientRect(); -+ if (rect) setBox({ left: rect.left, bottom: window.innerHeight - rect.top + 5 }); -+ setOpen((value) => !value); -+ }; -+ const groups = [ -+ [ -+ [t("Usage"), GaugeIcon, () => actions.openSettings("usage")], -+ [t("Connect apps"), ConnectorIcon, () => actions.openConnectors()], -+ [t("Plugins"), PlugIcon, () => actions.selectPanel("plugins")], -+ [t("Scheduled tasks"), ClockIcon, () => actions.openSettings("schedules")], -+ [t("Settings"), GearIcon, () => actions.openSettings("general")] ++ stopped = true; ++ clearInterval(timer); ++ }; ++ }, [key]); ++ const tasks = scope?.kind === "group" ? groupTasks : own.filter((task) => !scope?.botId || task.ownerBotId === scope.botId); ++ const replace = (task) => { ++ const done2 = columnOf(task) === "done"; ++ if (scope?.kind === "group") setGroupTasks((list) => done2 ? without(list, task.id) : upsert(list, task)); ++ else setOwn((list) => done2 ? without(list, task.id) : upsert(list, task)); ++ setArchive((list) => done2 ? [task, ...without(list, task.id)] : without(list, task.id)); + }; +- const item = (label, Icon, run) => (0, import_react6.createElement)("button", { key: label, type: "button", role: "menuitem", onClick: () => { +- close(); +- run(); +- } }, (0, import_react6.createElement)(Icon), label); +- return (0, import_react6.createElement)( +- "div", +- { ref: tools, className: "bt-tools", "data-open": panel !== null || void 0 }, +- react ? (0, import_react6.createElement)("button", { type: "button", className: "bt-tool", "aria-label": t("Add reaction"), title: t("Add reaction"), "aria-expanded": panel === "react", onClick: (event) => open("react", event) }, (0, import_react6.createElement)(SmileIcon)) : null, +- sessionId ? (0, import_react6.createElement)("button", { type: "button", className: "bt-tool", "aria-label": t("Reply"), title: t("Reply"), onClick: () => actions.quote(sessionId, text) }, (0, import_react6.createElement)(ReplyIcon)) : null, +- (0, import_react6.createElement)("button", { type: "button", className: "bt-tool", "aria-label": t("More message actions"), title: t("More"), "aria-haspopup": "menu", "aria-expanded": panel === "more", onClick: (event) => open("more", event) }, copied ? (0, import_react6.createElement)(CheckIcon) : (0, import_react6.createElement)(DotsIcon)), +- panel === "react" ? (0, import_react_dom.createPortal)((0, import_react6.createElement)( ++ const done = scope?.kind === "group" ? archive : archive.filter((task) => !scope?.botId || task.ownerBotId === scope.botId); ++ return { own, groupTasks, tasks, archive: done, replace }; ++} ++function RailButton({ row, selected, meta, onSelect }) { ++ return (0, import_react16.createElement)( ++ "button", ++ { ++ type: "button", ++ className: "bt-board-rail-item", ++ "data-kind": row.kind, ++ "data-id": row.id, ++ "data-selected": selected || void 0, ++ "aria-current": selected ? "true" : void 0, ++ style: row.depth ? { paddingLeft: `${10 + row.depth * 14}px` } : void 0, ++ onClick: () => onSelect(row.kind === "group" ? { kind: "group", roomId: row.id } : { kind: "bot", botId: row.id }) ++ }, ++ (0, import_react16.createElement)("span", { className: "bt-board-rail-name" }, row.name), ++ meta ? (0, import_react16.createElement)("span", { className: "bt-board-rail-meta" }, meta) : null ++ ); ++} ++function BoardCard2({ task, roster, movable, onMove, onDrag, partOf: partOf2 }) { ++ const column = columnOf(task); ++ const priority = priorityOf(task); ++ const progress = progressOf(task); ++ const who = botName(roster, task); ++ const question = task.waitingFor === "input" ? task.inputRequest?.question : ""; ++ const overall = task.kind === "group"; ++ const finished = column === "done" ? finishedAt(task) : void 0; ++ return (0, import_react16.createElement)(BoardCard, { ++ card: taskCard(task), ++ skin: TASK_SKIN, ++ attrs: { ++ "data-task": task.id, ++ "data-column": column, ++ "data-priority": priority, ++ "data-kind": overall ? "group" : void 0 ++ }, ++ idText: shortId(task.id), ++ emptyTitle: t("Untitled task"), ++ topExtra: [ ++ (0, import_react16.createElement)("span", { key: "priority", className: "bt-board-priority", "data-priority": priority }, priorityLabel(priority)), ++ (0, import_react16.createElement)("span", { key: "status", className: "bt-board-status" }, statusLabel(task.status)) + ], -+ [ -+ [t("Switch to classic Agent mode"), SwitchIcon, () => actions.setSurface("agent")] -+ ] -+ ]; -+ return (0, import_react17.createElement)( -+ import_react17.Fragment, -+ null, -+ (0, import_react17.createElement)( -+ "button", -+ { ref: button, type: "button", className: "bt-account", "aria-label": t("Account"), "aria-haspopup": "menu", "aria-expanded": open, onClick: toggle }, -+ (0, import_react17.createElement)("span", { className: "bt-you", "aria-hidden": true }, (0, import_react17.createElement)(BotMark, { look: MAIN_LOOK, size: 24 })) ++ note: task.note ?? "", ++ meta: [ ++ overall ? (0, import_react16.createElement)("span", { key: "kind", className: "bt-board-kind" }, t("Group task")) : null, ++ partOf2 ? (0, import_react16.createElement)("span", { key: "part", className: "bt-board-part" }, t("Part of {title}", { title: partOf2 })) : null, ++ question ? (0, import_react16.createElement)("span", { key: "wait", className: "bt-board-wait" }, question) : null ++ ], ++ footer: (0, import_react16.createElement)( ++ "span", ++ { className: "bt-board-foot" }, ++ who ? (0, import_react16.createElement)("span", { className: "bt-board-who" }, who) : null, ++ Number.isFinite(finished) && finished > 0 ? (0, import_react16.createElement)("span", { key: "finished", className: "bt-board-when" }, relativeShort(finished)) : null, ++ (0, import_react16.createElement)( ++ "span", ++ { className: "bt-board-progress" }, ++ (0, import_react16.createElement)( ++ "span", ++ { className: "bt-board-progress-bar", role: "progressbar", "aria-valuemin": 0, "aria-valuemax": 100, "aria-valuenow": progress, "aria-label": t("Progress") }, ++ (0, import_react16.createElement)("span", { style: { width: `${progress}%` } }) ++ ), ++ (0, import_react16.createElement)("span", { className: "bt-board-percent" }, t("{percent}%", { percent: progress })) ++ ) ++ ), ++ actions: [ ++ movable && column === "not-started" ? (0, import_react16.createElement)("button", { key: "start", type: "button", className: "bt-soft bt-soft-sm", onClick: () => onMove?.(task.id, "in-progress") }, t("Start")) : null, ++ movable && column === "in-progress" && task.status !== "blocked" ? (0, import_react16.createElement)("button", { key: "complete", type: "button", className: "bt-soft bt-soft-sm", onClick: () => onMove?.(task.id, "done") }, t("Complete")) : null ++ ], ++ draggable: movable, ++ dragValue: task.id, ++ onDragStart: () => onDrag?.(task.id), ++ onDragEnd: () => onDrag?.(null) ++ }); ++} ++function cardProps(task, roster, onMove, onDrag, partOf2, locked) { ++ return { task, roster, movable: !locked && canMoveTask(roster, task), onMove, onDrag, partOf: partOf2 }; ++} ++function BoardColumn({ column, tasks, roster, lanes, dragging, onMove, onDrag, archiveFull, locked }) { ++ const tree = lanes ? columnTree(tasks) : null; ++ const linked = tree ? tree.loose.filter((task) => task.parentId || task.parentTitle) : []; ++ const plain = tree ? tree.loose.filter((task) => !task.parentId && !task.parentTitle) : tasks; ++ return (0, import_react16.createElement)( ++ "section", ++ { ++ className: "bt-board-column", ++ "data-column": column, ++ "aria-label": columnTitle(column), ++ onDragOver: (event) => { ++ if (locked || !dragging) return; ++ event.preventDefault(); ++ }, ++ onDrop: (event) => { ++ if (locked) return; ++ event.preventDefault(); ++ const id = event.dataTransfer?.getData("text/plain"); ++ if (id) onMove?.(id, column); ++ onDrag?.(null); ++ } ++ }, ++ (0, import_react16.createElement)( ++ "h3", ++ { className: "bt-board-column-head" }, ++ (0, import_react16.createElement)("span", null, columnTitle(column)), ++ // #2045: the Done column is one archive page. `tasks.length` is what was drawn, not what the ++ // owner has, so a full page says `20 newest` instead of claiming the page is the total. ++ (0, import_react16.createElement)("span", { className: "bt-board-count" }, column === "done" && archiveFull ? t("{count} newest", { count: tasks.length }) : String(tasks.length)) + ), -+ open && box ? (0, import_react_dom4.createPortal)((0, import_react17.createElement)( -+ "div", -+ { className: "bt-menu bt-menu-up bt-account-menu", role: "menu", "aria-label": t("Account"), style: box }, -+ (0, import_react17.createElement)(FlyBotAccountHeader, { actions }), -+ groups.map((group, index) => (0, import_react17.createElement)( -+ import_react17.Fragment, -+ { key: index }, -+ index > 0 ? (0, import_react17.createElement)("div", { className: "bt-menu-sep", role: "separator" }) : null, -+ group.map(([label, Icon, run, hint]) => (0, import_react17.createElement)("button", { -+ key: label, -+ type: "button", -+ role: "menuitem", -+ onClick: () => { -+ setOpen(false); -+ void Promise.resolve(run()).catch((error) => actions.reportError(error, "menu")); -+ } -+ }, (0, import_react17.createElement)(Icon), (0, import_react17.createElement)("span", { className: "bt-menu-label" }, label), hint ? (0, import_react17.createElement)("span", { className: "bt-menu-hint" }, hint) : null)) ++ dragging ? (0, import_react16.createElement)("div", { className: "bt-board-drop" }, dropLabel(column)) : null, ++ tasks.length === 0 && !dragging ? (0, import_react16.createElement)("p", { className: "bt-board-empty" }, t("Nothing in this column.")) : null, ++ tree ? tree.nests.map((nest) => (0, import_react16.createElement)( + "div", +- { ref: pop, className: "bt-react-strip", role: "menu", "aria-label": t("Reactions"), style: place }, +- REACTIONS.map((emoji) => (0, import_react6.createElement)("button", { key: emoji, type: "button", role: "menuitem", "aria-label": emoji, onClick: () => { +- close(); +- react(emoji); +- } }, emoji)) +- ), document.body) : null, +- panel === "more" ? (0, import_react_dom.createPortal)((0, import_react6.createElement)( ++ { key: nest.task.id, className: "bt-board-nest" }, ++ (0, import_react16.createElement)(BoardCard2, cardProps(nest.task, roster, onMove, onDrag, void 0, locked)), ++ nest.children.map((child) => (0, import_react16.createElement)( ++ "div", ++ { key: child.id, className: "bt-board-subtask", "data-parent": nest.task.id }, ++ (0, import_react16.createElement)(BoardCard2, cardProps(child, roster, onMove, onDrag, void 0, locked)) + )) -+ ), document.body) : null -+ ); ++ )) : null, ++ linked.map((task) => (0, import_react16.createElement)(BoardCard2, { key: task.id, ...cardProps(task, roster, onMove, onDrag, task.parentTitle || "", locked) })), ++ lanes ? lanesOf(plain, roster).map((lane) => (0, import_react16.createElement)( + "div", +- { ref: pop, className: "bt-menu", role: "menu", "aria-label": t("Message actions"), style: place }, +- item(t("Copy"), CopyIcon, copy2), +- item(t("Download"), DownloadIcon, download2) +- ), document.body) : null ++ { key: lane.id, className: "bt-board-lane" }, ++ (0, import_react16.createElement)("span", { className: "bt-board-lane-name" }, lane.name), ++ lane.tasks.map((task) => (0, import_react16.createElement)(BoardCard2, { key: task.id, ...cardProps(task, roster, onMove, onDrag, void 0, locked) })) ++ )) : plain.map((task) => (0, import_react16.createElement)(BoardCard2, { key: task.id, ...cardProps(task, roster, onMove, onDrag, void 0, locked) })) + ); } --function ActivityIndicator({ sessionId, useRoster, useSessionStatus, useLive, useSession, actions }) { -- const roster = useRoster((value) => value); -- const running = useSessionStatus((map) => map.get(sessionId)?.running === true); -- const liveKey = useLive((state) => JSON.stringify(state[sessionId] ?? {})); -- const pendingInput = useSession((snapshot) => Math.max(0, ...(snapshot?.pendingSubmissions ?? []).filter((sub) => sub.placement !== "queued").map((sub) => sub.time))); -- const bot = roster.byId[sessionId]; -- const room2 = roster.roomsById[sessionId]; -- const entries2 = (0, import_react7.useMemo)(() => Object.values(JSON.parse(liveKey)), [liveKey]); -- const streaming = entries2.some((entry) => entry.text); -- const tools = entries2.filter((entry) => !entry.text).sort((a, b) => a.at - b.at); -- const relaying = tools.some((entry) => entry.relay); -- const visible = running && !streaming && !relaying && (bot !== void 0 || room2 !== void 0); -- const tool = tools.at(-1); -- const inputRef = (0, import_react7.useRef)(0); -- const inputAt = Math.max(inputRef.current, pendingInput); -- inputRef.current = inputAt; -- const want = !visible ? null : inputAt > (tool?.at ?? 0) ? { key: `input:${inputAt}`, pool: PHRASES.thinking, state: "thinking" } : tool ? { key: tool.label ? `tool:${tool.label}` : "working", label: tool.label, pool: PHRASES.working, state: tool.state ?? "working" } : { key: "thinking", pool: PHRASES.thinking, state: "thinking" }; -- const [shown, setShown] = (0, import_react7.useState)(null); -- const wantKey = want?.key ?? null; -- const markState = (0, import_react7.useRef)("thinking"); -- if (want) markState.current = want.state; -- (0, import_react7.useEffect)(() => { -- if (wantKey === null || shown?.key === wantKey) return void 0; -- const make = () => setShown({ key: wantKey, text: want.label ?? drawPhrase(want.pool), at: Date.now() }); -- const wait = shown ? shown.at + PHRASE_HOLD_MS - Date.now() : 0; -- if (wait <= 0) { -- make(); -+function useLandOnMainBot(roster, useSessions, actions) { -+ const mainView = useSessions((list) => { -+ const row = Object.values(list.byId).find((session) => (session?.retainedBy?.mainView ?? 0) > 0); -+ return row ? `${row.id}|${row.blank ? 1 : 0}` : ""; -+ }); -+ const lastBot = (0, import_react17.useRef)(null); -+ (0, import_react17.useEffect)(() => { -+ if (!roster.ready || !roster.mainBotId) return void 0; -+ const [id, blank3] = mainView.split("|"); -+ const bot = id === "" ? void 0 : roster.byId[id]; -+ if (bot !== void 0) { -+ lastBot.current = { botId: bot.id, sessionId: id }; -+ if (currentPart(bot) !== id) actions.landOn(bot.id); - return void 0; - } -- const timer = setTimeout(make, wait); -- return () => clearTimeout(timer); -- }, [wantKey, shown]); -- const [phase, setPhase] = (0, import_react7.useState)(null); -- const endedAt = (0, import_react7.useRef)(0); -- const shift = (0, import_react7.useRef)(null); -- const leaving = (0, import_react7.useRef)(false); -- leaving.current = phase !== null && !visible; -- (0, import_react7.useEffect)(() => { -- if (visible) { -- setPhase("in"); -+ if (id !== "" && (roster.roomsById[id] || (actions.isAgentSession?.(id) ?? roster.agentOf[id] !== void 0) || blank3 !== "1")) { -+ lastBot.current = null; - return void 0; - } -- setPhase((current) => current === "in" ? "out" : current); -- const timer = setTimeout(() => { -- shift.current = document.querySelector("[data-conversation-scroll]")?.scrollTop ?? null; -- setPhase((current) => current === "out" ? null : current); -- }, 140); -+ const last = lastBot.current; -+ const timer = setTimeout(() => actions.landOn(roster.mainBotId, last, { stay: id === "", view: id }), last ? 0 : 400); - return () => clearTimeout(timer); -- }, [visible]); -- (0, import_react7.useLayoutEffect)(() => { -- if (phase !== null) return; -- endedAt.current = Date.now(); -- setShown(null); -- const before = shift.current; -- shift.current = null; -- const scroller = document.querySelector("[data-conversation-scroll]"); -- if (before === null || !scroller || reducedMotion()) return; -- const delta = before - scroller.scrollTop; -- if (delta > 0) scroller.querySelector("[data-chat-flow]")?.animate([{ transform: `translateY(${-delta}px)` }, { transform: "none" }], { duration: 140, easing: "cubic-bezier(.23,1,.32,1)" }); -- }, [phase]); -- (0, import_react7.useEffect)(() => { -- if (!running) actions.clearLive(sessionId); -- }, [running, sessionId]); -- useRowEntrances(() => leaving.current || Date.now() - endedAt.current < 300); -- const [now, setNow] = (0, import_react7.useState)(Date.now); -- (0, import_react7.useEffect)(() => { -- if (phase !== "in") return void 0; -- const timer = setInterval(() => setNow(Date.now()), 1e4); -- return () => clearInterval(timer); -- }, [phase]); -- const host = useRunningHost(running); -- if (!host || phase === null || !shown) return null; -- const elapsed = now - shown.at >= 6e4 ? elapsedLabel(now - shown.at) : null; -- return (0, import_react_dom2.createPortal)((0, import_react7.createElement)( -- "div", -- { className: "bt-typing", "data-exiting": phase === "out" ? "" : void 0, "aria-hidden": true }, -- bot ? (0, import_react7.createElement)(BotMark, { bot, size: 28, state: markState.current, live: true, pokeable: true }) : (0, import_react7.createElement)(RoomAvatar, { room: room2, roster, size: 28 }), -- (0, import_react7.createElement)( -- "span", -- { className: "bt-typing-text" }, -- (0, import_react7.createElement)( -- "span", -- { key: `${shown.key}:${shown.at}`, className: "bt-typing-label" }, -- (0, import_react7.createElement)(Shimmer, null, `${shown.text}…`), -- elapsed ? (0, import_react7.createElement)("span", { className: "bt-typing-time" }, `· ${elapsed}`) : null -- ) -- ) -- ), host); --} -- --// src/client/dsh-offer.js --var APP_OFFER = /* @__PURE__ */ new Set(["available", "downloading", "verifying", "installing", "ready"]); --var bare = (value) => String(value ?? "").trim().replace(/^v/i, "").split("+")[0]; --var sameVersion = (a, b) => bare(a) !== "" && bare(a) === bare(b); --function showsDshRow(dsh, app) { -- if (!dsh?.verified || !dsh.current || sameVersion(dsh.verified, dsh.current)) return false; -- if (dsh.kind === "npm") return true; -- return dsh.kind === "app" && APP_OFFER.has(app?.phase) && sameVersion(app.version, dsh.verified); -+ }, [mainView, roster]); +-function ThinkingRow({ text }) { +- return (0, import_react6.createElement)( +- "details", +- { className: "bt-think" }, +- (0, import_react6.createElement)("summary", null, t("Thought it through")), +- (0, import_react6.createElement)("div", { className: "bt-think-text" }, text) ++function BoardView({ roster, tasks, rail, scope, counts: counts2, dragging, error, draft, onDraft, onSelect, onMove, onDrag, onCreate, archiveFull, locked }) { ++ const columns = boardColumns(tasks); ++ const lanes = scope?.kind === "group"; ++ const selectedBot = scope?.kind === "bot" ? scope.botId : ""; ++ const selectedRoom = scope?.kind === "group" ? scope.roomId : ""; ++ return (0, import_react16.createElement)( ++ "div", ++ { className: "bt-board-body" }, ++ (0, import_react16.createElement)( ++ "aside", ++ { className: "bt-board-rail", "aria-label": t("Task board") }, ++ (0, import_react16.createElement)("h3", { className: "bt-board-rail-head" }, t("Your Bots")), ++ rail.bots.length === 0 ? (0, import_react16.createElement)("p", { className: "bt-board-empty" }, t("No tasks yet.")) : rail.bots.map((row) => { ++ const count = counts2?.[row.id] ?? { done: 0, total: 0 }; ++ const capped = archiveFull && (scope?.kind === "group" ? count.done > 0 : count.done >= ARCHIVE_SHOWN); ++ const meta = count.total === 0 ? "" : capped ? t("{count}+ done", { count: count.done }) : t("{done}/{total}", { done: count.done, total: count.total }); ++ return (0, import_react16.createElement)(RailButton, { ++ key: row.id, ++ row, ++ selected: row.id === selectedBot, ++ onSelect, ++ meta ++ }); ++ }), ++ rail.groups.length > 0 ? (0, import_react16.createElement)("h3", { className: "bt-board-rail-head" }, t("Groups you admin")) : null, ++ rail.groups.map((row) => (0, import_react16.createElement)(RailButton, { key: row.id, row, selected: row.id === selectedRoom, onSelect })) ++ ), ++ (0, import_react16.createElement)( ++ "div", ++ { className: "bt-board-main" }, ++ error ? (0, import_react16.createElement)("p", { className: "bt-board-error", role: "alert" }, error) : null, ++ lanes ? (0, import_react16.createElement)("p", { className: "bt-board-hint" }, t("Assign, claim, and progress live on this board: one overall task, a subtask per part, an assignee, a column, and progress. @ only wakes someone.")) : null, ++ scope?.kind === "bot" && onCreate && !locked ? (0, import_react16.createElement)( ++ "form", ++ { className: "bt-board-add", onSubmit: (event) => { ++ event.preventDefault(); ++ onCreate(draft); ++ } }, ++ (0, import_react16.createElement)("input", { "aria-label": t("Task title"), placeholder: t("New task"), value: draft ?? "", onChange: (event) => onDraft?.(event.target.value) }), ++ (0, import_react16.createElement)("button", { type: "submit", className: "bt-soft bt-soft-sm" }, t("Add task")) ++ ) : null, ++ (0, import_react16.createElement)( ++ "div", ++ { className: "bt-board-columns" }, ++ ["not-started", "in-progress", "done"].map((column) => (0, import_react16.createElement)(BoardColumn, { ++ key: column, ++ column, ++ tasks: columns[column], ++ roster, ++ lanes, ++ dragging, ++ onMove, ++ onDrag, ++ archiveFull, ++ locked ++ })) ++ ) ++ ) + ); } -- --// src/client/sidebar.js --var import_react8 = require("react"); --var import_react_dom3 = require("react-dom"); --var import_dsh_client_ui_primitives2 = require("@deepseek-ai/dsh-client-ui-primitives"); --function useMarkedUnread(id, active, marked, actions) { -- (0, import_react8.useEffect)(() => { -- if (active && marked) actions.clearUnread(id); -- }, [active, marked]); -+function FooterAccount(props) { -+ const seated = props.useUi((value) => value.accountSeated === true); -+ if (seated) return null; -+ return (0, import_react17.createElement)("span", { className: "bt-foot-account" }, (0, import_react17.createElement)(AccountLauncher, { ...props, seat: false })); +-function useFileMentions({ node, openFile, fileMentions, useTurnData }) { +- const tail = useTurnData?.("turn-tail"); +- const location2 = node.location; +- const turn = location2?.kind === "turn" || location2?.kind === "step" ? location2.turn : void 0; +- const seq = node.data.finalNode?.seq; +- return (0, import_react6.useMemo)(() => { +- if (typeof fileMentions !== "function" || turn?.status !== "closed" || seq === void 0) return void 0; +- return tail?.closing?.finalNode.seq === seq ? fileMentions({ turn, seq, openFile }) : void 0; +- }, [fileMentions, openFile, seq, tail, turn]); +-} +-function AssistantCell({ node, groupPart, sessionId, openFile, fileMentions, useTurnData, useRoster, useUi, useTurnSteps, useTurnContext, actions }) { +- const roster = useRoster((value) => value); +- const data = node.data; +- const steps = useTurnSteps(String(data.turn)); +- const contexts = useTurnContext(String(data.turn)); +- const mentions = useFileMentions({ node, openFile, fileMentions, useTurnData }); +- const hidden = Boolean(roster.roomsById[sessionId]) || isHiddenTurn(roster, sessionId, data.turn); +- const texts2 = groupPart === "reasoning" ? [] : data.blocks.filter((block) => block.kind === "text" && block.text.trim() !== "").map((block) => block.text); +- const running = data.status === "running"; +- useLiveNote(actions, sessionId, `text:${node.key ?? data.seq}`, !hidden && running && texts2.length > 0 ? { text: true } : null); +- if (hidden) return null; +- const reasoning = groupPart === "response" ? "" : turnReasoning(steps, data); +- const relays = relayPart(data, groupPart) ? relaysBefore(contexts, steps, data) : []; +- if (texts2.length === 0 && !reasoning && relays.length === 0) return null; +- const selfId = roster.byId[sessionId]?.id ?? sessionId; +- return (0, import_react6.createElement)( +- import_react6.Fragment, +- null, +- relays.map((relay) => { +- const source = relay.source; +- const sender = roster.byId[source.senderSessionId] ?? { id: source.senderSessionId, name: source.senderName ?? t("a Bot"), color: "gray" }; +- return (0, import_react6.createElement)(EventLine, { +- key: relay.seq, +- prefix: source.role === "stopped" ? t("Stopped by") : source.role === "reply" ? t("Reply from") : t("Message from"), +- bots: [sender], +- onOpen: () => { +- if (sender.id) actions.openExchange(selfId, sender.id); +- }, +- whole: true +- }); +- }), +- reasoning ? (0, import_react6.createElement)(ThinkingRow, { text: reasoning }) : null, +- texts2.length ? (0, import_react6.createElement)(Bubbles, { texts: texts2, roster, selfId, actions, streaming: running, sessionId, msgKey: `${sessionId}:${data.turn}:${node.key ?? data.seq ?? ""}`, useUi, mentions }) : null ++function KanbanRow({ wide, useUi, actions }) { ++ const open = useUi ? useUi((value) => Boolean(value.board)) : false; ++ const label = t("Tasks"); ++ return (0, import_react16.createElement)( ++ "button", ++ { ++ type: "button", ++ className: "bt-row bt-board-row", ++ "aria-label": label, ++ title: wide ? void 0 : label, ++ "aria-haspopup": "dialog", ++ "aria-expanded": open, ++ onClick: () => actions.openBoard() ++ }, ++ (0, import_react16.createElement)("span", { className: "bt-board-mark" }, (0, import_react16.createElement)(BoardIcon)), ++ wide ? (0, import_react16.createElement)("span", { className: "bt-board-label" }, label) : null + ); } --var PinMark = () => (0, import_react8.createElement)("span", { className: "bt-pin", "aria-hidden": true }, (0, import_react8.createElement)(PinMarkIcon)); --var useApproval = (useWork, match) => useWork ? useWork((work) => (work.approvals ?? []).some(match)) : false; --var useGroupWork = (useWork, pick2) => useWork ? useWork((work) => pick2(work.rooms ?? {}).join(",")) : ""; --var workKeys = (text) => text === "" ? [] : text.split(",").map((key) => { -- const [id, tool] = key.split("|"); -- return { id, tool: tool || void 0 }; --}); --var workLine = (lead, tool, more) => (0, import_react8.createElement)(Shimmer, null, `${lead} · ${workPhrase(tool)}…${more > 0 ? ` +${more}` : ""}`); --function BotRow({ bot, roster, actions, useSessions, useSessionStatus, useSessionRetainInfo, useActivity, useWork, wide, marked }) { -- const id = bot.id; -- const part = currentPart(bot); -- const preview = useSessions((list) => previewOf(list, part, roster.exchangeTurns[part])); -- const running = useSessionStatus((map) => map.get(part)?.running === true); -- const active = useSessionRetainInfo(part, (info) => (info?.retainedBy?.mainView ?? 0) > 0); -- const unread = useSessionStatus((map) => map.get(part)?.completionUnread === true || map.get(part)?.pendingInteraction !== void 0) || marked && !active; -- useMarkedUnread(id, active, marked, actions); -- const main = isMainOf(roster, id); -- const tile = roster.mainBotId === id; -- const approval = useApproval(useWork, (entry) => entry.botId === id); -- const alert = roster.questions?.[part] !== void 0 || approval; -- const doing = useActivity((map) => map[id]); -- const groups = workKeys(useGroupWork(useWork, (rooms) => Object.entries(rooms).flatMap(([roomId, entries2]) => entries2.filter((entry) => entry.botId === id).map((entry) => `${roomId}|${entry.tool ?? ""}`)))); -- const away = !running && groups.length > 0; -- const state = botState(running || away, doing, alert); -- const awayLine = away ? workLine(roster.roomsById[groups[0].id]?.name ?? t("a group chat"), groups[0].tool, groups.length - 1) : null; -- if (bot.hidden && !active && !main) return null; -- const onContextMenu = (event) => { -- event.preventDefault(); -- actions.openMenu({ kind: "bot", id, x: event.clientX, y: event.clientY }); -- }; -- const pinned = bot.pinned === true && !tile; -- const label = [bot.name, main ? t("Main Bot") : "", pinned ? t("Pinned") : "", running || away ? t("Working") : "", approval ? t("Waiting for your approval") : alert ? t("Waiting for your answer") : "", unread ? t("Unread activity") : ""].filter(Boolean).join(", "); -- if (tile && wide) { -- return (0, import_react8.createElement)("div", { className: "bt-tile-wrap" }, (0, import_react8.createElement)( +-function EventLine({ prefix, bots, joiner = t("and"), onOpen, whole = false }) { +- if (whole && bots.length === 1) { +- return (0, import_react6.createElement)( ++function scopeName(roster, scope) { ++ if (scope?.kind === "group") return roster.roomsById?.[scope.roomId]?.name || t("Group"); ++ if (scope?.kind === "bot") return roster.byId?.[scope.botId]?.name || ""; ++ return ""; ++} ++function KanbanDialog({ roster, actions, leaving = false }) { ++ const locked = typeof roster?.readOnly === "string" && roster.readOnly.trim() !== ""; ++ const rail = boardRail(roster); ++ const [scope, setScope] = (0, import_react16.useState)(() => rail.bots[0] ? { kind: "bot", botId: rail.bots[0].id } : rail.groups[0] ? { kind: "group", roomId: rail.groups[0].id } : null); ++ const [dragging, setDragging] = (0, import_react16.useState)(null); ++ const [error, setError] = (0, import_react16.useState)(""); ++ const [draft, setDraft] = (0, import_react16.useState)(""); ++ const { own, groupTasks, tasks, archive, replace } = useBoardTasks(actions, scope); ++ const boardTasks = boardTasksFor(tasks, archive); ++ const close = (0, import_react16.useCallback)(() => actions.closeBoard(), []); ++ useEscape(close); ++ (0, import_react16.useEffect)(() => { ++ if (typeof window === "undefined") return; ++ if (!window.location.hash.startsWith("#task=")) return; ++ const [kind, id] = window.location.hash.slice("#task=".length).split(":"); ++ window.location.hash = ""; ++ if (id === void 0 || id === "") return; ++ if (kind === "bot" && roster.byId?.[id]) setScope({ kind: "bot", botId: id }); ++ if (kind === "group" && roster.roomsById?.[id]) setScope({ kind: "group", roomId: id }); ++ }, []); ++ const archiveFull = Array.isArray(archive) && archive.length >= ARCHIVE_SHOWN; ++ const counts2 = {}; ++ for (const task of [...scope?.kind === "group" ? groupTasks : own, ...(archive ?? []).slice(0, ARCHIVE_SHOWN)]) { ++ const id = task.ownerBotId; ++ const bucket = counts2[id] ?? (counts2[id] = { done: 0, total: 0 }); ++ if (task.status === "cancelled" || task.status === "failed") continue; ++ bucket.total += 1; ++ if (columnOf(task) === "done") bucket.done += 1; ++ } ++ const move = async (id, column) => { ++ if (locked) return; ++ const task = tasks.find((item) => item.id === id) ?? own.find((item) => item.id === id); ++ if (!task || !canMoveTask(roster, task) || columnOf(task) === column) return; ++ setError(""); ++ try { ++ replace(await actions.taskUpdate({ id, column })); ++ } catch (failure) { ++ setError(failure?.message ?? String(failure)); ++ } ++ }; ++ const create = async (title) => { ++ if (locked) return; ++ const text2 = String(title ?? "").trim(); ++ if (!text2 || scope?.kind !== "bot") return; ++ setError(""); ++ try { ++ replace(await actions.taskCreate({ botId: scope.botId, title: text2, column: "not-started" })); ++ setDraft(""); ++ } catch (failure) { ++ setError(failure?.message ?? String(failure)); ++ } ++ }; ++ const name = scopeName(roster, scope); ++ return (0, import_react16.createElement)( ++ "div", ++ { className: "bt-mem-layer bt-board-layer", "data-leaving": leaving || void 0 }, ++ (0, import_react16.createElement)("div", { className: "bt-scrim", onClick: close }), ++ (0, import_react16.createElement)( + "div", +- { className: "bt-event" }, +- (0, import_react6.createElement)( +- "button", +- { type: "button", className: "bt-event-whole", title: bots[0].name, onClick: () => onOpen?.(bots[0]) }, +- (0, import_react6.createElement)("span", null, prefix), +- (0, import_react6.createElement)(BotMark, { bot: bots[0], size: 16 }), +- (0, import_react6.createElement)("span", { className: "bt-event-name" }, bots[0].name) +- ) +- ); ++ { className: "bt-mem bt-board", role: "dialog", "aria-modal": true, "aria-labelledby": "bt-board-heading" }, ++ (0, import_react16.createElement)( ++ "div", ++ { className: "bt-mem-head" }, ++ (0, import_react16.createElement)("h2", { id: "bt-board-heading" }, t("Board")), ++ (0, import_react16.createElement)("span", { className: "bt-mem-status" }, name), ++ // #1854: the left sidebar's Projects row is the one entry to a Project board; this header ++ // used to draw a second door (#1701), so the sidebar row was the only way in for the rest. ++ (0, import_react16.createElement)("span", { className: "bt-mem-head-gap" }), ++ (0, import_react16.createElement)("button", { type: "button", className: "bt-icon-btn", "aria-label": t("Close board"), autoFocus: true, onClick: close }, (0, import_react16.createElement)(CloseIcon)) ++ ), ++ (0, import_react16.createElement)(BoardView, { ++ roster, ++ tasks: boardTasks, ++ rail, ++ scope, ++ counts: counts2, ++ dragging, ++ error, ++ draft, ++ archiveFull, ++ locked, ++ onDraft: setDraft, ++ onSelect: setScope, ++ onMove: move, ++ onDrag: setDragging, ++ onCreate: create ++ }) ++ ) ++ ); ++} ++var KANBAN_CSS = ` ++.bt-board-row{min-height:40px;padding:6px 8px;gap:10px} ++.bt-board-mark{flex:none;width:36px;height:28px;display:inline-flex;align-items:center;justify-content:center;color:var(--bt-ink-2)} ++.bt-board-mark svg{width:18px;height:18px} ++.bt-board-label{flex:1 1 auto;min-width:0;font-size:14px;line-height:20px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis} ++.bt-mem.bt-board{width:min(1080px,calc(100vw - 32px));height:min(760px,calc(100vh - 48px))} ++.bt-board-body{flex:1 1 auto;min-height:0;display:flex;gap:0} ++.bt-board-rail{flex:none;width:232px;overflow:auto;border-right:.5px solid var(--bt-line);padding:8px 8px 16px;display:flex;flex-direction:column;gap:2px} ++.bt-board-rail-head{margin:10px 8px 4px;font-size:12px;line-height:16px;font-weight:600;color:var(--bt-ink-3)} ++.bt-board-rail-item{display:flex;align-items:center;gap:8px;width:100%;min-height:36px;padding:6px 10px;border:0;border-radius:10px;background:transparent;color:var(--bt-ink);text-align:left;cursor:pointer} ++.bt-board-rail-item:hover{background:var(--bt-hover)} ++.bt-board-rail-item[data-selected]{background:var(--bt-active,var(--bt-hover))} ++.bt-board-rail-name{flex:1 1 auto;min-width:0;font-size:13.5px;line-height:18px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis} ++.bt-board-rail-meta{flex:none;font-size:12px;line-height:16px;color:var(--bt-ink-3);font-variant-numeric:tabular-nums} ++.bt-board-main{flex:1 1 auto;min-width:0;display:flex;flex-direction:column;gap:10px;padding:12px 14px 16px} ++.bt-board-add{display:flex;gap:8px} ++.bt-board-add input{flex:1 1 auto;min-width:0;height:32px;padding:0 10px;border:.5px solid var(--bt-line-2);border-radius:10px;background:var(--bt-card,var(--bt-main));color:var(--bt-ink);font:inherit} ++.bt-board-columns{flex:1 1 auto;min-height:0;display:flex;gap:10px} ++.bt-board-column{flex:1 1 0;min-width:0;display:flex;flex-direction:column;gap:8px;padding:8px;border-radius:14px;background:var(--bt-hover);overflow:auto} ++.bt-board-column-head{display:flex;align-items:center;gap:6px;margin:0;padding:2px 4px;font-size:13px;line-height:18px;font-weight:600;color:var(--bt-ink-2)} ++.bt-board-count{font-weight:500;color:var(--bt-ink-3);font-variant-numeric:tabular-nums} ++.bt-board-drop{margin:0;padding:18px 8px;border:1px dashed var(--bt-line-2);border-radius:12px;text-align:center;font-size:13px;line-height:18px;color:var(--bt-ink-3)} ++.bt-board-empty{margin:0;padding:8px 4px;font-size:13px;line-height:18px;color:var(--bt-ink-3)} ++.bt-board-hint{margin:0;font-size:13px;line-height:18px;color:var(--bt-ink-2)} ++.bt-board-error{margin:0;font-size:13px;line-height:18px;color:#c21d2e} ++.bt-board-lane{display:flex;flex-direction:column;gap:6px} ++.bt-board-lane-name{font-size:12px;line-height:16px;font-weight:600;color:var(--bt-ink-3);padding:0 2px} ++.bt-board-nest{display:flex;flex-direction:column;gap:6px} ++.bt-board-subtask{margin-left:12px;padding-left:8px;border-left:2px solid var(--bt-line)} ++.bt-board-kind{font-size:11px;line-height:16px;font-weight:600;color:var(--bt-ink-2)} ++.bt-board-part{font-size:12px;line-height:16px;color:var(--bt-ink-3)} ++.bt-board-wait{font-size:12px;line-height:16px;color:var(--bt-ink-2)} ++.bt-board-card{display:flex;flex-direction:column;gap:6px;padding:10px;border:.5px solid var(--bt-line-2);border-radius:12px;background:var(--bt-card,var(--bt-main));cursor:grab} ++.bt-board-card-top{display:flex;align-items:center;gap:6px;min-width:0} ++.bt-board-id{flex:none;font-size:12px;line-height:16px;color:var(--bt-ink-3);font-variant-numeric:tabular-nums} ++.bt-board-priority{flex:none;height:18px;padding:0 6px;border-radius:999px;font-size:11px;line-height:18px;font-weight:600} ++.bt-board-priority[data-priority=urgent]{background:rgba(224,33,53,.12);color:#c21d2e} ++.bt-board-priority[data-priority=high]{background:rgba(217,119,6,.14);color:#b45309} ++.bt-board-priority[data-priority=normal]{background:color-mix(in srgb,var(--bt-accent) 14%,transparent);color:color-mix(in srgb,var(--bt-accent) 80%,var(--bt-ink))} ++.bt-board-priority[data-priority=low]{background:color-mix(in srgb,var(--bt-green) 16%,transparent);color:color-mix(in srgb,var(--bt-green) 70%,var(--bt-ink))} ++.bt-board-status{margin-left:auto;font-size:11.5px;line-height:16px;color:var(--bt-ink-3);white-space:nowrap} ++.bt-board-title{font-size:13.5px;line-height:19px;color:var(--bt-ink)} ++.bt-board-note{font-size:12px;line-height:16px;color:var(--bt-ink-2)} ++.bt-board-foot{display:flex;align-items:center;gap:8px} ++.bt-board-who{flex:none;max-width:40%;font-size:12px;line-height:16px;color:var(--bt-ink-2);white-space:nowrap;overflow:hidden;text-overflow:ellipsis} ++.bt-board-when{flex:none;font-size:12px;line-height:16px;color:var(--bt-ink-3);white-space:nowrap} ++.bt-board-progress{flex:1 1 auto;min-width:0;display:flex;align-items:center;gap:6px} ++.bt-board-progress-bar{flex:1 1 auto;height:4px;border-radius:999px;background:var(--bt-line);overflow:hidden} ++.bt-board-progress-bar>span{display:block;height:100%;background:var(--bt-accent)} ++.bt-board-percent{flex:none;font-size:11.5px;line-height:16px;color:var(--bt-ink-3);font-variant-numeric:tabular-nums} ++.bt-board-card .bt-soft{align-self:flex-start} ++@media (max-width:800px){ ++ .bt-mem.bt-board{width:min(100vw,calc(100vw - 16px));height:100%} ++ .bt-board-body{flex-direction:column} ++ .bt-board-rail{width:auto;max-height:180px;border-right:0;border-bottom:.5px solid var(--bt-line)} ++ .bt-board-columns{overflow:auto} ++ .bt-board-column{min-width:220px} ++} ++@media (max-width:640px){ ++ .bt-mem.bt-board{width:100vw;border-radius:0;border:0} ++ .bt-board-columns{flex-direction:column} ++ .bt-board-column{min-width:0} ++} ++`; ++ ++// src/client/role-labels.js ++var ROLE_LABELS = { owner: "Owner", maintainer: "Maintainer", member: "Member" }; ++function roleText(role) { ++ if (role === null || role === void 0 || role === "") return ""; ++ return t(ROLE_LABELS[role] ?? String(role)); ++} ++ ++// src/client/projects-nav.js ++var import_react17 = require("react"); ++var import_react_dom3 = require("react-dom"); ++var PROJECTS_NAV_CSS = ` ++.bt-projects-row { display: flex; align-items: center; gap: 8px; } ++.bt-projects-chevron { margin-left: auto; display: inline-flex; transition: transform 120ms ease; } ++.bt-projects-row[aria-expanded="true"] .bt-projects-chevron { transform: rotate(90deg); } ++.bt-projects-list { display: flex; flex-direction: column; margin: 0 0 4px; padding-left: 30px; } ++.bt-project-entry { display: flex; align-items: baseline; gap: 8px; width: 100%; text-align: left; border: 0; background: none; color: inherit; font: inherit; padding: 4px 8px; border-radius: 6px; cursor: pointer; } ++.bt-project-entry:hover, .bt-project-entry:focus-visible { background: var(--bt-hover, rgba(127,127,127,.14)); outline: none; } ++.bt-project-entry[aria-current="true"] { background: var(--bt-hover, rgba(127,127,127,.14)); } ++.bt-project-entry-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } ++.bt-project-entry-role { margin-left: auto; opacity: .62; font-size: .85em; white-space: nowrap; } ++.bt-projects-empty, .bt-projects-note { padding: 2px 8px 6px; opacity: .7; font-size: .85em; } ++.bt-project-menu { position: fixed; z-index: 60; min-width: 168px; padding: 4px; border-radius: 8px; background: var(--bt-menu-bg, #1f2126); border: 1px solid var(--bt-menu-border, rgba(255,255,255,.12)); box-shadow: 0 8px 24px rgba(0,0,0,.32); } ++.bt-project-menu-item { display: flex; align-items: center; gap: 8px; width: 100%; border: 0; background: none; color: inherit; font: inherit; text-align: left; padding: 6px 8px; border-radius: 6px; cursor: pointer; } ++.bt-project-menu-item:hover, .bt-project-menu-item:focus-visible { background: var(--bt-hover, rgba(127,127,127,.14)); outline: none; } ++.bt-project-menu-item[aria-checked="true"]::before { content: "✓"; margin-right: 2px; } ++.bt-project-menu-item:not([aria-checked="true"])::before { content: " "; margin-right: 2px; } ++.bt-project-menu-item[disabled] { opacity: .6; cursor: default; } ++`; ++function projectHash(id) { ++ return `#project=${encodeURIComponent(String(id ?? ""))}`; ++} ++function projectName(project) { ++ const name = typeof project?.name === "string" ? project.name.trim() : ""; ++ return name === "" ? `#${project?.id ?? ""}` : name; ++} ++function projectMenuItems(project) { ++ const current = project?.role ?? null; ++ const choices = Array.isArray(project?.choices) ? project.choices : []; ++ const items = current === null ? [] : [{ role: current, label: roleText(current), current: true, pick: false }]; ++ for (const role of choices) { ++ if (role === current) continue; ++ items.push({ role, label: roleText(role), current: false, pick: true }); + } +- const parts = []; +- parts.push((0, import_react6.createElement)("span", { key: "p" }, prefix)); +- bots.forEach((bot, index) => { +- if (index > 0) parts.push((0, import_react6.createElement)("span", { key: `j${index}` }, joiner)); +- parts.push((0, import_react6.createElement)( - "button", -- { -- type: "button", -- className: "bt-tile", -- "aria-label": label, -- "aria-current": active ? "page" : void 0, -- "data-bot-id": id, -- title: preview || bot.name, -- onClick: () => actions.openSession(id), -- onContextMenu -- }, -- (0, import_react8.createElement)(BotAvatar, { bot, size: 60, state, main: true }), -- (0, import_react8.createElement)("span", { className: "bt-tile-name" }, bot.name), -- bot.role ? (0, import_react8.createElement)("span", { className: "bt-chip" }, bot.role) : null +- { key: bot.id ?? index, type: "button", onClick: () => onOpen?.(bot) }, +- (0, import_react6.createElement)(BotMark, { bot, size: 16 }), +- bot.name - )); -- } -- return (0, import_react8.createElement)( -+function BotReturn({ wide, useSurface, actions }) { -+ const mode = useSurface((value) => value.mode); -+ if (mode !== "agent") return null; -+ return (0, import_react17.createElement)( +- }); +- return (0, import_react6.createElement)("div", { className: "bt-event" }, parts); ++ return items; + } +-function GroupMessage({ bot, text, room: room2, roster, actions, msgKey, useUi }) { +- const lead = (0, import_react6.createElement)( ++function roleNote(answer) { ++ if (answer?.changed === true) return t("Role changed."); ++ const reason = typeof answer?.reason === "string" && answer.reason !== "" ? answer.reason : t("refused"); ++ return t("Role unchanged: {reason}", { reason }); ++} ++function ProjectList({ projects, error, note, onOpen, onRole }) { ++ const rows = projects === null ? (0, import_react17.createElement)("div", { className: "bt-projects-note" }, t("Reading Projects…")) : error !== "" ? (0, import_react17.createElement)("div", { className: "bt-projects-note" }, error) : projects.length === 0 ? (0, import_react17.createElement)("div", { className: "bt-projects-empty" }, t("No Projects yet")) : projects.map((project) => (0, import_react17.createElement)( "button", -- { -- type: "button", -- className: "bt-row", -- "aria-label": label, -- "aria-current": active ? "page" : void 0, -- "data-bot-id": id, -- title: wide ? void 0 : bot.name, -- onClick: () => actions.openSession(id), -- onContextMenu -- }, -- (0, import_react8.createElement)(BotAvatar, { bot, size: 36, state, main }), -- pinned && !wide ? (0, import_react8.createElement)(PinMark) : null, -- wide ? (0, import_react8.createElement)( +- { type: "button", className: "bt-lead", "aria-label": t("Open {name}'s chat", { name: bot.name }), onClick: () => actions.openSession(bot.id) }, +- (0, import_react6.createElement)(BotMark, { bot, size: 22 }) +- ); +- return (0, import_react6.createElement)( ++ { ++ key: String(project.id), ++ type: "button", ++ className: "bt-project-entry", ++ "data-project-id": String(project.id), ++ "aria-current": project.current === true ? "true" : void 0, ++ title: roleText(project.role) === "" ? projectName(project) : `${projectName(project)} · ${roleText(project.role)}`, ++ onClick: () => onOpen?.(project.id), ++ onContextMenu: (event) => { ++ event.preventDefault(); ++ onRole?.(project, event); ++ } ++ }, ++ (0, import_react17.createElement)("span", { className: "bt-project-entry-name" }, projectName(project)), ++ roleText(project.role) === "" ? null : (0, import_react17.createElement)("span", { className: "bt-project-entry-role" }, roleText(project.role)) ++ )); ++ return (0, import_react17.createElement)( + "div", +- { className: "bt-group-msg" }, +- (0, import_react6.createElement)( - "span", -- { className: "bt-row-body" }, -- (0, import_react8.createElement)("span", { className: "bt-row-name" }, (0, import_react8.createElement)("span", { className: "bt-row-title" }, bot.name), pinned ? (0, import_react8.createElement)(PinMark) : null), -- awayLine ? (0, import_react8.createElement)("span", { className: "bt-row-preview" }, awayLine) : preview ? (0, import_react8.createElement)("span", { className: "bt-row-preview" }, preview) : null -- ) : null, -- wide && unread && !active ? (0, import_react8.createElement)("span", { className: "bt-unread", "aria-hidden": true }) : null -+ { type: "button", className: "bt-return", "aria-label": t("FlyBot"), title: t("FlyBot"), onClick: () => actions.setSurface("bot") }, -+ (0, import_react17.createElement)(FlyBotMark, { size: 20 }), -+ wide ? (0, import_react17.createElement)("span", { className: "bt-return-name" }, t("FlyBot")) : null +- { className: "bt-group-name", style: { color: inkText(colorOf(bot)) } }, +- bot.name, +- room2?.admin === bot.id ? (0, import_react6.createElement)("span", { className: "bt-group-role" }, t("Admin")) : null +- ), +- (0, import_react6.createElement)(Bubbles, { texts: [text], roster, selfId: bot.id, actions, sessionId: room2?.id, msgKey, useUi, lead }) ++ { className: "bt-projects-list", role: "group", "aria-label": t("Projects") }, ++ rows, ++ note === "" || note === void 0 ? null : (0, import_react17.createElement)("div", { className: "bt-projects-note", role: "status" }, note) ); } --function RoomRow({ room: room2, roster, actions, useSessions, useSessionStatus, useSessionRetainInfo, useWork, wide, marked }) { -- const id = room2.id; -- const fallback = useSessions((list) => previewOf(list, id)); -- const preview = room2.preview?.replace(/\s+/g, " ").trim() || fallback; -- const active = useSessionRetainInfo(id, (info) => (info?.retainedBy?.mainView ?? 0) > 0); -- const unread = useSessionStatus((map) => map.get(id)?.completionUnread === true) || marked && !active; -- useMarkedUnread(id, active, marked, actions); -- const approval = useApproval(useWork, (entry) => entry.roomId === id); -- const members = workKeys(useGroupWork(useWork, (rooms) => (rooms[id] ?? []).map((entry) => `${entry.botId}|${entry.tool ?? ""}`))); -- const first = roster.byId[members[0]?.id]; -- const workingLine = first ? workLine(first.name, members[0].tool, members.length - 1) : null; -- if (room2.hidden && !active) return null; -- const pinned = room2.pinned === true; -- const label = [room2.name, pinned ? t("Pinned") : "", members.length > 0 ? t("Working") : "", approval ? t("Waiting for your approval") : ""].filter(Boolean).join(", "); -- return (0, import_react8.createElement)( -- "button", -- { -- type: "button", -- className: "bt-row", -- "aria-label": label, -- "aria-current": active ? "page" : void 0, -- "data-room-id": id, -- onClick: () => actions.openSession(id), -- onContextMenu: (event) => { -- event.preventDefault(); -- actions.openMenu({ kind: "room", id, x: event.clientX, y: event.clientY }); -- } -- }, -- (0, import_react8.createElement)(RoomAvatar, { room: room2, roster, size: 36 }), -- pinned && !wide ? (0, import_react8.createElement)(PinMark) : null, -- wide ? (0, import_react8.createElement)( -- "span", -- { className: "bt-row-body" }, -- (0, import_react8.createElement)("span", { className: "bt-row-name" }, (0, import_react8.createElement)("span", { className: "bt-row-title" }, room2.name), pinned ? (0, import_react8.createElement)(PinMark) : null), -- workingLine ? (0, import_react8.createElement)("span", { className: "bt-row-preview" }, workingLine) : preview ? (0, import_react8.createElement)("span", { className: "bt-row-preview" }, preview) : null -- ) : null, -- wide && approval && !active ? (0, import_react8.createElement)("span", { className: "bt-unread bt-unread-wait", "aria-hidden": true }) : wide && unread && !active ? (0, import_react8.createElement)("span", { className: "bt-unread", "aria-hidden": true }) : null -- ); -+var RETURN_CSS = ` -+.bt-return{display:inline-flex;align-items:center;justify-content:center;gap:8px;min-height:36px;padding:0 14px;border:0;border-radius:999px;background:none;color:inherit;font:inherit;font-size:14px;line-height:20px;cursor:pointer;transition:background-color .12s ease} -+.bt-return:hover{background:rgba(127,127,127,.14)} -+.bt-return:active{transform:scale(.96)} -+.bt-return .bt-mark,.bt-return .bt-flybot-mark{display:inline-flex;flex:none;line-height:0} -+.bt-return .bt-mark svg,.bt-return .bt-flybot-mark{width:20px;height:20px;overflow:visible} -+/* Shell styles carry --bt-ink-deepseek and Agent mode withdraws them; pin it here. */ -+.bt-return .bt-mark{--bt-ink-deepseek:#4D6BFE} -+.bt-return-name{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} -+`; -+function BrandMark({ size = 24 }) { -+ return (0, import_react17.createElement)("span", { className: "bt-brand-mark" }, (0, import_react17.createElement)(FlyBotMark, { size })); -+} -+function BrandName() { -+ return (0, import_react17.createElement)("span", { style: { fontWeight: 600, fontSize: 14 } }, t("FlyBot")); +-function GroupEvent({ prefix, room: room2, name, roster, actions }) { +- return (0, import_react6.createElement)( +- "div", +- { className: "bt-event" }, +- (0, import_react6.createElement)("span", null, prefix), +- room2 ? (0, import_react6.createElement)("button", { type: "button", onClick: () => actions.openSession(room2.id) }, (0, import_react6.createElement)(RoomAvatar, { room: room2, roster, size: 16 }), room2.name) : name ? (0, import_react6.createElement)("span", null, name) : null ++function Chevron() { ++ return (0, import_react17.createElement)( ++ "svg", ++ { width: 12, height: 12, viewBox: "0 0 20 20", fill: "none", stroke: "currentColor", strokeWidth: 2, strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": true }, ++ (0, import_react17.createElement)("path", { d: "M7.5 4.5L13 10l-5.5 5.5" }) + ); + } +-function MemoryEvent({ sessionId, scope, result, roster, actions, findBot }) { +- if (!/^(Saved to|Updated in|Removed from) /.test(result)) return null; +- const self = roster.byId[sessionId]; +- const team = /^(team|团队|共享)$/i.test(String(scope ?? "").trim()); +- const other = team || !scope ? void 0 : findBot(scope); +- const target = other && other.id !== self?.id ? other : self; +- if (!target) return null; +- const label = team ? t("Team memory updated") : target === self ? t("Memory updated") : t("Updated {name}'s memory", { name: target.name }); +- return (0, import_react6.createElement)( +- "div", +- { className: "bt-event" }, +- (0, import_react6.createElement)("button", { type: "button", title: result, onClick: () => actions.openMemory(target.id) }, (0, import_react6.createElement)(MemoryIcon), label) ++function ProjectsMark() { ++ return (0, import_react17.createElement)( ++ "svg", ++ { width: 18, height: 18, viewBox: "0 0 20 20", fill: "none", stroke: "currentColor", strokeWidth: 1.6, strokeLinejoin: "round", "aria-hidden": true }, ++ (0, import_react17.createElement)("path", { d: "M2.5 5.5h6l1.5 2h7.5v8a1 1 0 0 1-1 1h-13a1 1 0 0 1-1-1z" }), ++ (0, import_react17.createElement)("path", { d: "M2.5 5.5v-1a1 1 0 0 1 1-1h4l1.5 2" }) + ); + } +-function PartLine({ time }) { +- return (0, import_react6.createElement)("div", { className: "bt-part-line", role: "separator" }, Number.isFinite(time) ? (0, import_react6.createElement)("span", null, dayLabel(time)) : null); +-} +-var scrollParent = (node) => { +- for (let at = node.parentElement; at; at = at.parentElement) { +- if (/(auto|scroll)/.test(getComputedStyle(at).overflowY)) return at; +- } +- return null; +-}; +-function EarlierParts({ sessionId, roster, actions }) { +- const [view, setView] = (0, import_react6.useState)({ items: [], cursor: void 0, loading: true, failed: false, startedAt: null }); +- const top = (0, import_react6.useRef)(null); +- const busy = (0, import_react6.useRef)(false); +- const alive = (0, import_react6.useRef)(true); +- const restore = (0, import_react6.useRef)(null); +- const load = (cursor) => { +- if (busy.current) return; +- busy.current = true; +- setView((current) => ({ ...current, loading: true, failed: false })); +- actions.earlier(sessionId, cursor).then((page) => { +- busy.current = false; +- if (!alive.current) return; +- const scroller = top.current && scrollParent(top.current); +- if (scroller) restore.current = { scroller, fromEnd: scroller.scrollHeight - scroller.scrollTop }; +- setView((current) => ({ items: [...page.items, ...current.items], cursor: page.cursor, loading: false, failed: false, startedAt: page.startedAt ?? current.startedAt })); +- }, () => { +- busy.current = false; +- if (alive.current) setView((current) => ({ ...current, loading: false, failed: true })); +- }); +- }; +- (0, import_react6.useEffect)(() => { +- alive.current = true; +- load(void 0); ++function ProjectRoleMenu({ project, x, y, onPick, onClose }) { ++ const ref = (0, import_react17.useRef)(null); ++ (0, import_react17.useEffect)(() => { ++ const onDown = (event) => { ++ if (!ref.current?.contains(event.target)) onClose(); ++ }; ++ const onKey = (event) => { ++ if (event.key === "Escape") onClose(); ++ }; ++ window.addEventListener("mousedown", onDown, true); ++ window.addEventListener("keydown", onKey, true); + return () => { +- alive.current = false; ++ window.removeEventListener("mousedown", onDown, true); ++ window.removeEventListener("keydown", onKey, true); + }; +- }, [sessionId]); +- (0, import_react6.useLayoutEffect)(() => { +- const pending = restore.current; +- restore.current = null; +- if (pending) pending.scroller.scrollTop = pending.scroller.scrollHeight - pending.fromEnd; +- }, [view.items]); +- (0, import_react6.useEffect)(() => { +- const node = top.current; +- if (!node || view.loading || view.failed || !view.cursor || typeof IntersectionObserver !== "function") return void 0; +- const observer = new IntersectionObserver((entries2) => { +- if (entries2.some((entry) => entry.isIntersecting)) load(view.cursor); +- }, { root: scrollParent(node), rootMargin: "600px 0px 0px 0px" }); +- observer.observe(node); +- return () => observer.disconnect(); +- }, [view.cursor, view.loading, view.failed]); +- const selfId = roster.byId[sessionId]?.id; +- const rows = []; +- for (const item of view.items) { +- const last = rows.at(-1); +- if (item.kind === "bot" && last?.kind === "bot") last.texts.push(item.text); +- else rows.push(item.kind === "bot" ? { kind: "bot", texts: [item.text] } : item); +- } +- return (0, import_react6.createElement)( ++ }, [onClose]); ++ const items = projectMenuItems(project); ++ return (0, import_react_dom3.createPortal)((0, import_react17.createElement)( + "div", +- { className: "bt-earlier", role: "feed", "aria-label": t("Earlier messages"), "aria-busy": view.loading }, +- (0, import_react6.createElement)( +- "div", +- { ref: top, className: "bt-earlier-top" }, +- view.loading ? (0, import_react6.createElement)(Shimmer, null, t("Loading earlier messages…")) : view.failed ? (0, import_react6.createElement)("button", { type: "button", className: "bt-soft", onClick: () => load(view.cursor) }, t("Couldn’t load earlier messages · Retry")) : view.cursor ? (0, import_react6.createElement)("button", { type: "button", className: "bt-soft", onClick: () => load(view.cursor) }, t("Load earlier")) : null ++ { ++ ref, ++ className: "bt-project-menu", ++ role: "menu", ++ "aria-label": t("My role"), ++ style: { left: `${x}px`, top: `${y}px` }, ++ "data-project-id": String(project?.id ?? "") ++ }, ++ (0, import_react17.createElement)("div", { className: "bt-projects-note" }, projectName(project)), ++ items.length === 0 ? (0, import_react17.createElement)("div", { className: "bt-projects-note" }, t("No role to switch here.")) : items.map((item) => (0, import_react17.createElement)("button", { ++ key: item.role, ++ type: "button", ++ role: "menuitemradio", ++ className: "bt-project-menu-item", ++ "aria-checked": item.current ? "true" : "false", ++ disabled: !item.pick, ++ onClick: () => { ++ if (item.pick) onPick(item.role); ++ else onClose(); ++ } ++ }, item.label)) ++ ), document.body); +} -+var SIDEBAR_CSS = ` -+.bt-side-top .bt-brand{cursor:default;padding-left:6px} -+.bt-side-top .bt-brand:hover{background:none} -+.bt-brand .bt-mark{flex:none} -+.bt-pin{flex:none;display:inline-flex;color:var(--bt-ink-3)} -+.bt-rail .bt-row{position:relative} -+.bt-rail .bt-pin{position:absolute;top:3px;left:calc(50% + 12px)} -+.bt-side .bt-read-only{margin:4px 8px 6px;padding:8px 10px;border-radius:10px;background:var(--bt-hover);font-size:12px;line-height:1.45;text-align:left} -+.bt-rail .bt-read-only{width:100%;margin:4px 0 6px;padding:4px 2px;font-size:10px;line-height:1.3;text-align:center;white-space:nowrap;overflow:hidden;text-overflow:ellipsis} -+[class*="_footArea"]:has(.bt-foot-account){display:flex!important;flex-direction:row!important;align-items:center;gap:6px} -+[class*="_footArea"]:has(.bt-foot-account)>[class*="_footerActions"],[class*="_footArea"]:has(.bt-foot-account)>[class*="_footerActions"]>div{display:contents!important} -+.bt-foot-account{order:-1;flex:none;display:inline-flex} -+[class*="_footArea"]:has(.bt-foot-account) .bt-connect-foot{order:0;flex:1 1 auto;min-width:0;width:auto} -+[class*="_footArea"]:has(.bt-foot-account)>[class*="_settingsArea"]{order:1;flex:none} -+[class*="_footArea"]:has(.bt-foot-account)>[class*="_settingsArea"] button[class*="_trigger"] [class*="_label"]{display:none!important} -+ -+/* The update cloud springs in when a newer DS Bot shows up; its arrow drops in a -+ loop while the install runs. */ -+.bt-round.bt-update{position:relative;color:var(--bt-accent,#4D6BFE);animation:bt-update-in ${SPRING_SHAPE.duration}ms ${SPRING_SHAPE.easing}} -+.bt-round.bt-update svg{width:17px;height:17px} -+.bt-round.bt-update[data-phase=installed]{color:var(--bt-ink-2)} -+@keyframes bt-update-in{from{opacity:0;transform:scale(.6);filter:blur(4px)}} -+.bt-update[data-phase=installing] .bt-cloud-arrow{animation:bt-cloud-drop 1.1s cubic-bezier(.45,0,.55,1) infinite} -+@keyframes bt-cloud-drop{0%{transform:translateY(-3px);opacity:0}35%{opacity:1}70%{transform:translateY(1px);opacity:1}100%{transform:translateY(2px);opacity:0}} -+.bt-update-dot{position:absolute;top:6px;right:6px;width:7px;height:7px;border-radius:50%;background:#E5484D;box-shadow:0 0 0 1.5px var(--bt-sidebar)} -+.bt-update-pop{position:fixed;z-index:130;width:280px;padding:14px 14px 12px;border-radius:14px;background:var(--bt-main);border:.5px solid var(--bt-line-2);box-shadow:0 10px 20px -3px rgba(0,0,0,.1),0 4px 6px -4px rgba(0,0,0,.1);color:var(--bt-ink);box-sizing:border-box;transform-origin:top left;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",system-ui,sans-serif;animation:bt-update-pop ${SPRING_SHAPE.duration}ms ${SPRING_SHAPE.easing}} -+@keyframes bt-update-pop{from{opacity:0;transform:scale(.96);filter:blur(4px)}} -+.bt-update-title{font-size:14px;line-height:20px;font-weight:600} -+.bt-update-text{margin-top:2px;font-size:12.5px;line-height:18px;color:var(--bt-ink-2)} -+.bt-update-error{margin-top:8px;font-size:12px;line-height:17px;color:#c21d2e;overflow-wrap:anywhere;max-height:90px;overflow:auto} -+.bt-update-actions{display:flex;align-items:center;justify-content:flex-end;gap:10px;margin-top:12px} -+.bt-update-notes{margin-right:auto;font-size:12.5px;color:var(--bt-ink-2);text-decoration:none} -+.bt-update-notes:hover{color:var(--bt-ink);text-decoration:underline} -+.bt-update-go{height:30px;padding:0 14px;border:0;border-radius:999px;background:var(--bt-accent,#4D6BFE);color:var(--bt-accent-ink,#fff);font:inherit;font-size:13px;font-weight:500;cursor:pointer;transition:opacity .12s ease,transform .12s ease} -+.bt-update-go:hover:not(:disabled){opacity:.9} -+.bt-update-go:active:not(:disabled){transform:scale(.96)} -+.bt-update-go:disabled{opacity:.6;cursor:default} -+.bt-update-dsh:not(:first-child){margin-top:12px;padding-top:12px;border-top:.5px solid var(--bt-line-2)} -+@media (prefers-reduced-motion:reduce){.bt-round.bt-update,.bt-update-pop,.bt-update .bt-cloud-arrow{animation:none}} -+ -+/* The DSH Agent row and its expanding Session list. The black whale is the -+ harness's own mark, so the row reads as "plain dsh" next to the Bot marks. */ -+.bt-agent-ava{flex:none;width:36px;height:36px;border-radius:50%;display:inline-flex;align-items:center;justify-content:center;background:var(--bt-hover);color:var(--bt-ink)} -+.bt-agent-ava.bt-run{animation:bt-agent-pulse 1.4s ease-in-out infinite} -+@keyframes bt-agent-pulse{0%,100%{opacity:1}50%{opacity:.55}} -+.bt-agent-sub{display:grid;grid-template-rows:0fr;transition:grid-template-rows ${SPRING_SHAPE.duration}ms ${SPRING_SHAPE.easing}} -+.bt-agent-sub[data-open]{grid-template-rows:1fr} -+.bt-agent-sub-in{overflow:hidden;min-height:0;margin-left:44px} -+.bt-agent-sub-row{display:flex;align-items:center;gap:8px;width:100%;padding:5px 10px 5px 8px;border:0;border-radius:8px;background:none;color:var(--bt-ink-2);font:inherit;font-size:13px;line-height:20px;text-align:left;cursor:pointer} -+.bt-agent-sub-row:hover{background:var(--bt-hover);color:var(--bt-ink)} -+.bt-agent-sub-row[aria-current=page]{background:var(--bt-hover);color:var(--bt-ink);font-weight:500} -+.bt-agent-sub-title{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} -+.bt-agent-sub-time{flex:none;font-size:11px;color:var(--bt-ink-3)} -+.bt-agent-new{color:var(--bt-ink-3)} -+.bt-agent-new svg{width:14px;height:14px;flex:none} -+.bt-agent-edit{padding:3px 10px 3px 4px} -+.bt-agent-edit input{width:100%;border:0;border-radius:6px;background:var(--bt-hover);color:var(--bt-ink);font:inherit;font-size:13px;line-height:20px;padding:2px 8px;outline:none;box-shadow:inset 0 0 0 .5px var(--bt-line-2)} -+.bt-agent-sub[data-open] .bt-agent-sub-row{animation:bt-agent-row-in .24s ease both;animation-delay:calc(var(--i, 0)*30ms)} -+@keyframes bt-agent-row-in{from{opacity:0;filter:blur(4px)}to{opacity:1;filter:blur(0)}} -+@media (prefers-reduced-motion:reduce){.bt-agent-sub{transition:none}.bt-agent-sub .bt-agent-sub-row{animation:none}} -+ -+.bt-row-wrap{position:relative;display:flex;align-items:center;width:100%;min-width:0} -+.bt-row-wrap>.bt-row{flex:1 1 auto;min-width:0;width:100%} -+.bt-tree-item>.bt-row-wrap{flex:1 1 auto;width:auto;min-width:0} -+/* Gear sits over the row so hover/selected fill the same full-width band as a plain row (#384). */ -+.bt-row-settings{position:absolute;right:6px;top:50%;transform:translateY(-50%);z-index:1;flex:none;display:inline-flex;align-items:center;justify-content:center;width:28px;height:28px;padding:0;border:0;border-radius:8px;background:transparent;color:var(--bt-ink-3);cursor:pointer;opacity:0;pointer-events:none;transition:background-color .12s ease,color .12s ease,opacity .12s ease} -+.bt-row-wrap:hover>.bt-row,.bt-row-wrap:focus-within>.bt-row{padding-right:36px} -+.bt-row-wrap:hover .bt-row-settings,.bt-row-wrap:focus-within .bt-row-settings{opacity:1;pointer-events:auto;color:var(--bt-ink-2);background:var(--bt-hover)} -+.bt-row-settings:hover{opacity:1;color:var(--bt-ink-2);background:var(--bt-hover)} -+.bt-row-settings svg{width:15px;height:15px} -+`; -+ -+// src/client/busy-ack-logic.js -+function busyAckText(hostAck) { -+ return typeof hostAck?.text === "string" ? hostAck.text.trim() : ""; ++function ProjectsNav({ wide = true, request, onOpen } = {}) { ++ const read = request ?? call; ++ const open = (0, import_react17.useCallback)((id) => { ++ if (onOpen) { ++ onOpen(id); ++ return; ++ } ++ if (typeof window !== "undefined") window.location.hash = projectHash(id); ++ }, [onOpen]); ++ const [expanded, setExpanded] = (0, import_react17.useState)(false); ++ const [projects, setProjects] = (0, import_react17.useState)(null); ++ const [error, setError] = (0, import_react17.useState)(""); ++ const [note, setNote] = (0, import_react17.useState)(""); ++ const [menu, setMenu] = (0, import_react17.useState)(null); ++ const load = (0, import_react17.useCallback)(async () => { ++ try { ++ const listing = await read("projects"); ++ const list = Array.isArray(listing?.projects) ? listing.projects : []; ++ setProjects(list); ++ setError(""); ++ } catch (failure) { ++ setProjects([]); ++ setError(failure?.message ?? String(failure)); ++ } ++ }, [read]); ++ (0, import_react17.useEffect)(() => { ++ if (!expanded) return; ++ void load(); ++ }, [expanded, load]); ++ const toggle = () => { ++ setNote(""); ++ if (!wide) { ++ void open(""); ++ return; ++ } ++ setExpanded((value) => !value); ++ }; ++ const listed = expanded && wide; ++ return (0, import_react17.createElement)( ++ "div", ++ { className: "bt-projects" }, ++ (0, import_react17.createElement)( ++ "button", ++ { ++ type: "button", ++ className: "bt-row bt-projects-row", ++ "aria-expanded": listed ? "true" : "false", ++ "aria-label": t("Projects"), ++ title: wide ? void 0 : t("Projects"), ++ onClick: toggle ++ }, ++ (0, import_react17.createElement)("span", { className: "bt-projects-mark" }, (0, import_react17.createElement)(ProjectsMark)), ++ wide ? (0, import_react17.createElement)("span", { className: "bt-projects-label" }, t("Projects")) : null, ++ wide ? (0, import_react17.createElement)("span", { className: "bt-projects-chevron", "aria-hidden": true }, (0, import_react17.createElement)(Chevron)) : null + ), +- rows.map((row, index) => { +- const key = rows.length - index; +- switch (row.kind) { +- case "divider": +- return (0, import_react6.createElement)(PartLine, { key, time: row.time }); +- case "user": +- return (0, import_react6.createElement)("div", { key, className: "bt-urow" }, (0, import_react6.createElement)( +- "div", +- { className: "bt-ububble" }, +- row.text ? (0, import_react6.createElement)(import_dsh_client_ui_primitives.MarkdownText, { text: row.text, labels: mdLabels(), variant: "compact" }) : null, +- row.images ? (0, import_react6.createElement)("span", { className: "bt-uimages" }, row.images === 1 ? t("1 image") : t("{count} images", { count: row.images })) : null +- )); +- case "answer": +- return (0, import_react6.createElement)(AnsweredCell, { key, node: { data: row } }); +- case "bot": +- return (0, import_react6.createElement)(Bubbles, { key, texts: row.texts, roster, selfId, actions }); +- case "event": { +- if (row.role === "report") return (0, import_react6.createElement)(GroupEvent, { key, prefix: t("Replies from"), room: roster.roomsById[row.roomId], name: t("a group chat"), roster, actions }); +- const other = roster.byId[row.botId] ?? { id: row.botId, name: row.name ?? t("a Bot"), color: "gray" }; +- const prefix = { stopped: t("Stopped by"), reply: t("Reply from") }[row.role] ?? t("Message from"); +- return (0, import_react6.createElement)(EventLine, { key, prefix, bots: [other], onOpen: () => { +- if (selfId && other.id) actions.openExchange(selfId, other.id); +- }, whole: true }); ++ listed ? (0, import_react17.createElement)(ProjectList, { projects, error, note, onOpen: open, onRole: (project, event) => setMenu({ project, x: event.clientX, y: event.clientY }) }) : null, ++ menu === null ? null : (0, import_react17.createElement)(ProjectRoleMenu, { ++ project: menu.project, ++ x: menu.x, ++ y: menu.y, ++ onClose: () => setMenu(null), ++ onPick: async (role) => { ++ const project = menu.project; ++ setMenu(null); ++ try { ++ const answer = await read("project-role", { id: project.id, role }); ++ setNote(roleNote(answer)); ++ if (answer?.changed === true) void load(); ++ } catch (failure) { ++ setNote(failure?.message ?? String(failure)); + } +- default: +- return null; + } +- }), +- (0, import_react6.createElement)(PartLine, { time: view.startedAt }) ++ }) + ); } --function AgentSessionRow({ session, agent, actions, renaming, index }) { -- const input = (0, import_react8.useRef)(null); -- (0, import_react8.useLayoutEffect)(() => { -- if (!renaming) return; -- input.current?.focus(); -- input.current?.select(); -- }, [renaming]); -- if (renaming) { -- const commit = (keep) => { -- const value = input.current?.value ?? ""; -- if (!keep || value.trim() === "" || value.trim() === session.title) { -- actions.endAgentRename(); -- return; -- } -- void (async () => { -- try { -- await actions.renameAgentSession(agent.id, session.id, value); -- actions.endAgentRename(); -- } catch (error) { -- actions.reportError(error, "agent-rename"); -- } -- })(); -- }; -- return (0, import_react8.createElement)( -- "div", -- { className: "bt-agent-sub-row bt-agent-edit", style: { "--i": index } }, -- (0, import_react8.createElement)("input", { -- ref: input, -- defaultValue: session.title, -- "aria-label": t("Rename"), -- onKeyDown: (event) => { -- if (event.nativeEvent.isComposing) return; -- if (event.key === "Enter") commit(true); -- else if (event.key === "Escape") commit(false); -- }, -- onBlur: () => commit(true) -- }) -- ); -- } -- return (0, import_react8.createElement)( -- "button", -- { -- type: "button", -- className: "bt-agent-sub-row", -- "aria-current": session.active ? "page" : void 0, -- style: { "--i": index }, -- onClick: () => actions.openAgentSession(session.id), -- onContextMenu: (event) => { -- event.preventDefault(); -- event.stopPropagation(); -- actions.openMenu({ kind: "agent-session", id: session.id, agentId: agent.id, x: event.clientX, y: event.clientY }); +-function TriggerCell({ node, sessionId, useRoster, useSessions, useTurnClock, useUi, actions }) { +- const roster = useRoster((value) => value); +- const data = node.data; +- const source = data.source !== null && typeof data.source === "object" ? data.source : {}; +- const selfId = roster.byId[sessionId]?.id ?? sessionId; +- const deferred = source.kind === "bot" && source.deferred === true; +- const relayed = isRelayContext(data); +- const turn = node.location?.turn?.turn ?? data.turn; +- (0, import_react6.useLayoutEffect)(() => { +- if (relayed) actions.noteTurnPeer(sessionId, turn, source.senderSessionId); +- }, [relayed, sessionId, turn, source.senderSessionId]); +- const order = useSessions((list) => { +- const outline = list.projectionsBySession?.[sessionId]?.values?.turnOutline ?? list.byId[sessionId]?.projectionValues?.turnOutline; +- return Array.isArray(outline) ? outline.map((entry) => entry.turn).join(",") : ""; +- }); +- const peers = useTurnClock((state) => { +- if (!relayed) return ""; +- const turns = state[sessionId] ?? {}; +- return Object.keys(turns).filter((key) => turns[key].peer !== void 0).map((key) => `${key}:${turns[key].peer}`).join(","); +- }); +- const visit = useUi((state) => state.visits?.[sessionId]); +- const hiddenTurns = roster.exchangeTurns[sessionId]; +- const unreadOpening = useTurnClock((state) => { +- const first = openingTurn(state[sessionId], order, hiddenTurns); +- return first?.turn === turn && unseenSince(visit, first.start) ? clockLabel(first.start) : ""; +- }); +- const body = triggerBody(); +- return unreadOpening ? (0, import_react6.createElement)(import_react6.Fragment, null, (0, import_react6.createElement)("div", { className: "bt-open-new" }, (0, import_react6.createElement)(NewDivider), (0, import_react6.createElement)("div", { className: "bt-time-sep", role: "separator" }, unreadOpening)), body) : body; +- function triggerBody() { +- if (source.kind === "bot") { +- if (source.handoff === true) return roster.roomsById[sessionId] ? null : (0, import_react6.createElement)(EarlierParts, { sessionId, roster, actions }); +- if (source.role === "kickoff" || source.role === "room") return null; +- if (source.role === "secret" || deferred) return null; +- if (source.role === "post") { +- if (source.failed !== void 0 || source.resumed === true || source.stopped === true) return null; +- const room2 = roster.roomsById[sessionId]; +- const poster = roster.byId[source.senderSessionId] ?? { id: source.senderSessionId, name: source.senderName ?? t("a Bot"), color: "gray" }; +- return (0, import_react6.createElement)(GroupMessage, { bot: poster, text: stripHeader(contentText(data.content)), room: room2, roster, actions, msgKey: `${sessionId}:${data.turn}:post`, useUi }); - } -- }, -- (0, import_react8.createElement)("span", { className: "bt-agent-sub-title" }, session.title), -- (0, import_react8.createElement)("span", { className: "bt-agent-sub-time" }, session.at > 0 ? relativeShort(session.at) : "") -- ); -+function busyAckPending(hostAck) { -+ return busyAckText(hostAck) === ""; - } --function AgentRow({ agent, actions, useSessions, useSessionStatus, useUi, wide, marked }) { -- const info = useSessions((list) => JSON.stringify(agent.sessions.map((id) => { -- const row = list.byId[id]; -- return { -- id, -- // A blank Session's title is the workspace folder name; show the label instead. -- title: row?.blank === true ? "" : row?.displayTitle ?? row?.title ?? "", -- at: row?.updatedAt ?? 0, -- running: row?.running === true, -- active: (row?.retainedBy?.mainView ?? 0) > 0 -+function pendingBusyPlacement(submissions) { -+ if (!Array.isArray(submissions)) return false; -+ return submissions.some((item) => item?.placement === "queued" || item?.placement === "steering"); -+} -+function busyAckVisible({ hostAck, pendingBusy, streamingText }) { -+ if (streamingText) return false; -+ return busyAckText(hostAck) !== "" || pendingBusy === true; -+} -+function roomBusyAck(room2) { -+ const entries2 = Object.entries(room2?.busyAcks ?? {}).filter(([, ack2]) => ack2 !== void 0 && ack2 !== null); -+ if (entries2.length === 0) return null; -+ const [botId, ack] = entries2.sort((a, b) => (b[1].at ?? 0) - (a[1].at ?? 0))[0]; -+ return { botId, text: busyAckText(ack), at: ack.at ?? 0 }; -+} -+function roomBusyAckVisible({ ack, pendingBusy }) { -+ if (ack === null || ack === void 0) return false; -+ return ack.text !== "" || pendingBusy === true; -+} +- if (source.role === "report") { +- return (0, import_react6.createElement)(GroupEvent, { prefix: t("Replies from"), room: roster.roomsById[source.roomId], name: t("a group chat"), roster, actions }); +- } +- if (source.role === "resume" || source.resumed === true) return (0, import_react6.createElement)("div", { className: "bt-event" }, t("Picked up its work after a restart")); +- if (source.role === "retry" || source.retried === true) return (0, import_react6.createElement)("div", { className: "bt-event" }, t("Asked to make the tool call it wrote as text")); +- const run = relayed ? commRunOf( +- order.split(",").filter(Boolean).map(Number), +- Object.fromEntries(peers.split(",").filter(Boolean).map((pair) => [Number(pair.slice(0, pair.indexOf(":"))), pair.slice(pair.indexOf(":") + 1)])), +- turn, +- hiddenTurns +- ) : null; +- if (run === "folded") return null; +- const sender = roster.byId[source.senderSessionId] ?? { id: source.senderSessionId, name: source.senderName ?? t("a Bot"), color: "gray" }; +- const count = run?.count ?? (isHiddenTurn(roster, sessionId, turn) ? 2 : 1); +- const bots = [sender]; +- for (const peer of (run?.peerIds ?? []).map((id) => roster.byId[id]).filter(Boolean)) { +- if (!bots.some((bot) => bot.id === peer.id)) bots.push(peer); +- } +- return (0, import_react6.createElement)(EventLine, { +- prefix: source.role === "stopped" ? t("Stopped by") : count > 1 ? t("{count} messages with", { count }) : t("Message from"), +- bots, +- onOpen: (bot) => actions.openExchange(selfId, bot.id), +- whole: true +- }); +- } +- if (source.kind === "schedule") { +- const prompts = reminderPrompts(contentText(data.content)); +- return (0, import_react6.createElement)("div", { className: "bt-event" }, prompts.length > 0 ? t("Scheduled task: {text}", { text: prompts.join(" · ").slice(0, 80) }) : t("Routine started")); + -+// src/client/busy-ack.js ++// src/client/sidebar.js +var import_react18 = require("react"); -+var import_react_dom5 = require("react-dom"); -+function useRunningHost2(active) { -+ const [host, setHost] = (0, import_react18.useState)(null); ++var import_react_dom4 = require("react-dom"); ++var import_dsh_client_ui_primitives2 = require("@deepseek-ai/dsh-client-ui-primitives"); ++function useMarkedUnread(id, active, marked, actions) { + (0, import_react18.useEffect)(() => { -+ if (!active) { -+ setHost(null); -+ return void 0; -+ } -+ let frame = 0; -+ const find = () => { -+ frame = 0; -+ const node = document.querySelector(RUNNING_HOST); -+ setHost((prev) => prev === node ? prev : node); - }; -- }).sort((a, b) => b.at - a.at))); -- const statusInfo = useSessionStatus((map) => JSON.stringify(agent.sessions.map((id) => ({ -- id, -- running: map.get(id)?.running === true, -- unread: map.get(id)?.completionUnread === true -- })))); -- const statuses = Object.fromEntries(JSON.parse(statusInfo).map((entry) => [entry.id, entry])); -- const sessions = JSON.parse(info).map((session) => ({ -- ...session, -- running: statuses[session.id]?.running ?? session.running, -- unread: statuses[session.id]?.unread ?? false, -- title: session.title === "" ? t("New session") : session.title -- })); -- const renaming = useUi((value) => value.renameAgent); -- const active = sessions.some((session) => session.active); -- const running = sessions.some((session) => session.running); -- const unread = sessions.some((session) => session.unread) || marked && !active; -- useMarkedUnread(agent.id, active, marked, actions); -- const preview = sessions[0]?.title ?? t("New session"); -- const label = ["DSH Agent", running ? t("Working") : "", unread ? t("Unread activity") : ""].filter(Boolean).join(", "); -- const open = active && wide; -- return (0, import_react8.createElement)( -+ const observer = new MutationObserver(() => { -+ if (!frame) frame = requestAnimationFrame(find); -+ }); -+ observer.observe(document.querySelector("[data-conversation-scroll]") ?? document.body, { childList: true, subtree: true }); -+ find(); -+ return () => { -+ cancelAnimationFrame(frame); -+ observer.disconnect(); ++ if (active && marked) actions.clearUnread(id); ++ }, [active, marked]); ++} ++var PinMark = () => (0, import_react18.createElement)("span", { className: "bt-pin", "aria-hidden": true }, (0, import_react18.createElement)(PinMarkIcon)); ++var useApproval = (useWork, match) => useWork ? useWork((work) => (work.approvals ?? []).some(match)) : false; ++var useGroupWork = (useWork, pick2) => useWork ? useWork((work) => pick2(work.rooms ?? {}).join(",")) : ""; ++var workKeys = (text2) => text2 === "" ? [] : text2.split(",").map((key) => { ++ const [id, tool] = key.split("|"); ++ return { id, tool: tool || void 0 }; ++}); ++var workLine = (lead, tool, more) => (0, import_react18.createElement)(Shimmer, null, `${lead} · ${workPhrase(tool)}…${more > 0 ? ` +${more}` : ""}`); ++function BotRow({ bot, roster, actions, useSessions, useSessionStatus, useSessionRetainInfo, useActivity, useWork, wide, marked, asRow = false }) { ++ const id = bot.id; ++ const part = currentPart(bot); ++ const preview = useSessions((list) => nameMentions(previewOf(list, part, roster.exchangeTurns[part], unsentReplyTurns(bot, part), botNames(roster)), roster)); ++ const running = useSessionStatus((map) => map.get(part)?.running === true); ++ const active = useSessionRetainInfo(part, (info) => (info?.retainedBy?.mainView ?? 0) > 0); ++ const unread = useSessionStatus((map) => map.get(part)?.completionUnread === true || map.get(part)?.pendingInteraction !== void 0) || marked && !active; ++ useMarkedUnread(id, active, marked, actions); ++ const main = isMainOf(roster, id); ++ const tile = roster.mainBotId === id && !asRow; ++ const approval = useApproval(useWork, (entry) => entry.botId === id); ++ const waiting = approval || (bot.pendingCount ?? 0) > 0; ++ const alert = roster.questions?.[part] !== void 0 || waiting; ++ const doing = useActivity((map) => map[id]); ++ const groups = workKeys(useGroupWork(useWork, (rooms) => Object.entries(rooms).flatMap(([roomId, entries2]) => entries2.filter((entry) => entry.botId === id).map((entry) => `${roomId}|${entry.tool ?? ""}`)))); ++ const away = !running && groups.length > 0; ++ const state = botState(running || away, doing, alert); ++ const shownAway = away ? awayGroups(groups, roster.roomsById) : []; ++ const awayLine = shownAway.length > 0 ? workLine(roster.roomsById[shownAway[0].id]?.name ?? t("a group chat"), shownAway[0].tool, shownAway.length - 1) : null; ++ if (!isShownRemoteChat(bot) && !active && !main) return null; ++ const onContextMenu = (event) => { ++ event.preventDefault(); ++ actions.openMenu({ kind: "bot", id, x: event.clientX, y: event.clientY }); ++ }; ++ const pinned = bot.pinned === true && !tile; ++ const remote = Boolean(bot.remote); ++ const shown = remote ? friendBotTitle(bot) : bot.name; ++ const secondLine = awayLine || preview || (remote ? bot.remote?.gone ? t("no longer shared") : t("Friend's Bot") : ""); ++ const label = [shown, main ? t("Main Bot") : "", pinned ? t("Pinned") : "", running || away ? t("Working") : "", waiting ? t("Waiting for your approval") : alert ? t("Waiting for your answer") : "", unread ? t("Unread activity") : ""].filter(Boolean).join(", "); ++ if (tile && wide) { ++ return (0, import_react18.createElement)("div", { className: "bt-tile-wrap" }, (0, import_react18.createElement)( ++ "button", ++ { ++ type: "button", ++ className: "bt-tile", ++ "aria-label": label, ++ "aria-current": active ? "page" : void 0, ++ "data-bot-id": id, ++ title: preview || bot.name, ++ onClick: () => actions.openSession(id), ++ onContextMenu ++ }, ++ (0, import_react18.createElement)(BotAvatar, { bot, size: 60, state, main: true }), ++ (0, import_react18.createElement)("span", { className: "bt-tile-name" }, shown), ++ // The Main Bot's tile gets the same unread dot as a row (#420); a wait shows on its avatar. ++ unread && !waiting && !active ? (0, import_react18.createElement)("span", { className: "bt-unread bt-tile-unread", "aria-hidden": true }) : null, ++ bot.role ? (0, import_react18.createElement)("span", { className: "bt-chip" }, bot.role) : null ++ )); ++ } ++ const unreadMark = wide && waiting && !active ? (0, import_react18.createElement)("span", { className: "bt-unread bt-unread-wait", "aria-hidden": true }) : wide && unread && !active ? (0, import_react18.createElement)("span", { className: "bt-unread", "aria-hidden": true }) : null; ++ const row = (0, import_react18.createElement)( ++ "button", ++ { ++ type: "button", ++ className: "bt-row", ++ "aria-label": label, ++ "aria-current": active ? "page" : void 0, ++ // An own Bot's hover text names its model (#366); a friend's Bot runs on its owner's model. ++ "data-bot-id": id, ++ title: remote ? shown : botRowTitle(shown, modelOf(bot, roster)?.name, wide), ++ onClick: () => actions.openSession(id), ++ onContextMenu ++ }, ++ (0, import_react18.createElement)(BotAvatar, { bot, size: 36, state, main }), ++ pinned && !wide ? (0, import_react18.createElement)(PinMark) : null, ++ wide ? (0, import_react18.createElement)( ++ "span", ++ { className: "bt-row-body" }, ++ (0, import_react18.createElement)( ++ "span", ++ { className: "bt-row-name" }, ++ (0, import_react18.createElement)("span", { className: "bt-row-title" }, shown), ++ bot.remote ? (0, import_react18.createElement)(RemoteMark, { remote: bot.remote }) : null, ++ pinned ? (0, import_react18.createElement)(PinMark) : null ++ ), ++ secondLine ? (0, import_react18.createElement)("span", { className: "bt-row-preview" }, secondLine) : null ++ ) : null, ++ unreadMark ++ ); ++ const settingsLabel = bot.remote ? t("Bot settings") : t("Edit Bot"); ++ const settings = wide ? (0, import_react18.createElement)("button", { ++ type: "button", ++ className: "bt-row-settings", ++ "aria-label": settingsLabel, ++ title: settingsLabel, ++ onFocus: (event) => keepSidebarStill(event.currentTarget), ++ onClick: (event) => { ++ event.stopPropagation(); ++ keepSidebarStill(event.currentTarget); ++ actions.openSettings("bots", id); + } +- if (source.kind === "user-question-reply") return null; +- return (0, import_react6.createElement)("div", { className: "bt-event" }, contentText(data.content).slice(0, 80) || t("Update")); +- } ++ }, (0, import_react18.createElement)(GearIcon)) : null; ++ if (settings) return (0, import_react18.createElement)("div", { className: "bt-row-wrap", "data-bot-id": id }, row, settings); ++ return row; ++} ++function keepSidebarStill(node) { ++ const reset = () => { ++ for (let at = node?.parentElement; at; at = at.parentElement) if (at.scrollLeft > 0) at.scrollLeft = 0; ++ const page = typeof document === "undefined" ? null : document.scrollingElement; ++ if (page && page.scrollLeft > 0) page.scrollLeft = 0; ++ }; ++ reset(); ++ if (typeof requestAnimationFrame === "function") requestAnimationFrame(() => requestAnimationFrame(reset)); + } +-function NewDivider() { +- return (0, import_react6.createElement)("div", { className: "bt-new-sep", role: "separator", "aria-label": t("New messages") }, (0, import_react6.createElement)("span", null, t("New"))); ++function RemoteMark({ remote }) { ++ return (0, import_react18.createElement)( ++ "span", ++ { className: "bt-row-remote", title: remote.gone ? `${remote.handle} · ${t("no longer shared")}` : remote.handle, "aria-label": t("Friend's Bot") }, ++ (0, import_react18.createElement)( ++ "svg", ++ { width: 11, height: 11, viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: 2, strokeLinecap: "round", "aria-hidden": true }, ++ (0, import_react18.createElement)("circle", { cx: 12, cy: 12, r: 9 }), ++ (0, import_react18.createElement)("path", { d: "M3 12h18M12 3a14 14 0 0 1 0 18M12 3a14 14 0 0 0 0 18" }) ++ ) ++ ); + } +-function TurnProcessCell({ node, sessionId, turnProcess, useRoster, useUi, useTurnClock, actions }) { +- const open = turnProcess === void 0 || !turnProcess.foldable || turnProcess.open; +- (0, import_react6.useEffect)(() => { +- if (!open) turnProcess.setOpen(true); +- }, [open, turnProcess]); +- const turn = node?.location?.turn?.turn ?? node?.data?.turn; +- const start = node?.location?.turn?.start?.time; +- const hidden = useRoster((roster) => isHiddenTurn(roster, sessionId, turn)); +- (0, import_react6.useEffect)(() => { +- actions.noteTurnTime(sessionId, turn, "first", start); +- }, [sessionId, turn, start]); +- const visit = useUi((state) => state.visits?.[sessionId]); +- const fresh = useTurnClock((state) => { +- const own = state[sessionId]?.[turn]; +- return !hidden && typeof visit?.seen === "number" && own?.first !== void 0 && own.first <= visit.seen && own.end !== void 0 && own.end > visit.seen && own.end <= visit.entered; +- }); +- return fresh ? (0, import_react6.createElement)(NewDivider) : null; ++function isPastFaceRoom(room2) { ++ if (room2?.origin !== "face-to-face") return false; ++ const status = room2.face?.status; ++ if (status === "closed" || status === "left") return true; ++ const expires = room2.face?.expiresAt; ++ return status === "candidate" && expires && Number.isFinite(Date.parse(expires)) && Date.parse(expires) <= Date.now(); + } +-function Nothing() { +- return null; ++function FaceMark() { ++ return (0, import_react18.createElement)( ++ "span", ++ { className: "bt-row-remote bt-row-face", title: t("Face-to-face"), "aria-label": t("Face-to-face") }, ++ (0, import_react18.createElement)( ++ "svg", ++ { width: 11, height: 11, viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: 2, strokeLinecap: "round", "aria-hidden": true }, ++ (0, import_react18.createElement)("circle", { cx: 9, cy: 8, r: 3 }), ++ (0, import_react18.createElement)("circle", { cx: 16, cy: 9, r: 2.5 }), ++ (0, import_react18.createElement)("path", { d: "M4 18a5 5 0 0 1 10 0M14 18a4 4 0 0 1 6 0" }) ++ ) ++ ); + } +-var SEPARATOR_GAP_MS = 30 * 60 * 1e3; +-function clockLabel(time, now = Date.now()) { +- const date = new Date(time); +- const midnight = (value) => new Date(value.getFullYear(), value.getMonth(), value.getDate()).getTime(); +- const days = Math.round((midnight(new Date(now)) - midnight(date)) / 864e5); +- const clock4 = date.toLocaleTimeString(dateLocale(), { hour: "numeric", minute: "2-digit" }); +- if (days === 0) return t("Today {time}", { time: clock4 }); +- if (days === 1) return t("Yesterday {time}", { time: clock4 }); +- const sameYear = date.getFullYear() === new Date(now).getFullYear(); +- return `${date.toLocaleDateString(dateLocale(), { weekday: "short", month: "short", day: "numeric", ...sameYear ? {} : { year: "numeric" } })} ${clock4}`; ++function RoomRow({ room: room2, roster, actions, useSessions, useSessionStatus, useSessionRetainInfo, useWork, wide, marked }) { ++ const id = room2.id; ++ const fallback = useSessions((list) => nameMentions(previewOf(list, id, [], [], botNames(roster)), roster)); ++ const preview = nameMentions(visibleText(room2.preview), roster) || fallback; ++ const active = useSessionRetainInfo(id, (info) => (info?.retainedBy?.mainView ?? 0) > 0); ++ const unread = useSessionStatus((map) => map.get(id)?.completionUnread === true) || marked && !active; ++ useMarkedUnread(id, active, marked, actions); ++ const approval = useApproval(useWork, (entry) => entry.roomId === id); ++ const question = roster.questions?.[id] !== void 0; ++ const members = workKeys(useGroupWork(useWork, (rooms) => (rooms[id] ?? []).map((entry) => `${entry.botId}|${entry.tool ?? ""}`))); ++ const first = roster.byId[members[0]?.id]; ++ const workingLine = first ? workLine(first.name, members[0].tool, members.length - 1) : null; ++ if (room2.hidden && !active) return null; ++ const pinned = room2.pinned === true; ++ const title = roomTitle(room2); ++ const from = room2.sharedFrom ? sharedRoomTitle(room2.sharedFrom.inviterName) : ""; ++ const hover = from && from !== title ? `${title} · ${from}` : title; ++ const waiting = approval || (room2.pendingCount ?? 0) > 0; ++ const alert = question || waiting; ++ const face = room2.origin === "face-to-face"; ++ const label = [hover, face ? t("Face-to-face") : "", pinned ? t("Pinned") : "", members.length > 0 ? t("Working") : "", waiting ? t("Waiting for your approval") : alert ? t("Waiting for your answer") : ""].filter(Boolean).join(", "); ++ return (0, import_react18.createElement)( ++ "button", ++ { ++ type: "button", ++ className: "bt-row", ++ "aria-label": label, ++ "aria-current": active ? "page" : void 0, ++ // The full title on hover. The row wraps the number-first form so the second ++ // name is not cut to "SelfB…" (#1102, #304, #458). ++ title: hover, ++ "data-room-id": id, ++ onClick: () => actions.openSession(id), ++ onContextMenu: (event) => { ++ event.preventDefault(); ++ actions.openMenu({ kind: "room", id, x: event.clientX, y: event.clientY }); ++ } ++ }, ++ (0, import_react18.createElement)(RoomAvatar, { room: room2, roster, size: 36 }), ++ pinned && !wide ? (0, import_react18.createElement)(PinMark) : null, ++ wide ? (0, import_react18.createElement)( ++ "span", ++ { className: "bt-row-body" }, ++ (0, import_react18.createElement)("span", { className: "bt-row-name" }, (0, import_react18.createElement)("span", { className: face ? "bt-row-title bt-row-title-face" : "bt-row-title" }, face ? faceRowTitle(title) : title), face ? (0, import_react18.createElement)(FaceMark) : room2.sharedFrom ? (0, import_react18.createElement)("span", { className: "bt-tag", title: from }, t("Shared")) : null, pinned ? (0, import_react18.createElement)(PinMark) : null), ++ workingLine ? (0, import_react18.createElement)("span", { className: "bt-row-preview" }, workingLine) : preview ? (0, import_react18.createElement)("span", { className: "bt-row-preview" }, preview) : null ++ ) : null, ++ wide && alert && !active ? (0, import_react18.createElement)("span", { className: "bt-unread bt-unread-wait", "aria-hidden": true }) : wide && unread && !active ? (0, import_react18.createElement)("span", { className: "bt-unread", "aria-hidden": true }) : null ++ ); + } +-function nextTurnStart(turns, turn) { +- let next; +- for (const key in turns) { +- const candidate = Number(key); +- if (candidate > turn && turns[key].first !== void 0 && (next === void 0 || candidate < next)) next = candidate; ++function AgentSessionRow({ session, agent, actions, renaming, index }) { ++ const input = (0, import_react18.useRef)(null); ++ (0, import_react18.useLayoutEffect)(() => { ++ if (!renaming) return; ++ input.current?.focus(); ++ input.current?.select(); ++ }, [renaming]); ++ if (renaming) { ++ const commit = (keep) => { ++ const value = input.current?.value ?? ""; ++ if (!keep || value.trim() === "" || value.trim() === session.title) { ++ actions.endAgentRename(); ++ return; ++ } ++ void (async () => { ++ try { ++ await actions.renameAgentSession(agent.id, session.id, value); ++ actions.endAgentRename(); ++ } catch (error) { ++ actions.reportError(error, "agent-rename"); ++ } ++ })(); + }; -+ }, [active]); -+ return host; -+} -+function BusyAckBubble({ sessionId, useRoster, useBusyAcks, useSessionStatus, useSession, useLive }) { -+ const roster = useRoster((value) => value); -+ const room2 = roster.roomsById?.[sessionId]; -+ const memberAck = room2 ? roomBusyAck(room2) : null; -+ const hostAck = useBusyAcks((map) => map?.[sessionId] ?? null); -+ const running = useSessionStatus((map) => map.get(sessionId)?.running === true); -+ const pendingBusy = useSession((snapshot) => pendingBusyPlacement(snapshot?.pendingSubmissions)); -+ const streamingText = useLive((state) => Object.values(state[sessionId] ?? {}).some((entry) => entry?.text)); -+ const visible = room2 ? roomBusyAckVisible({ ack: memberAck, pendingBusy }) : running && busyAckVisible({ hostAck, pendingBusy, streamingText }); -+ const bot = room2 ? roster.byId[memberAck?.botId] : roster.byId[sessionId]; -+ const host = useRunningHost2(visible); -+ const shown = (0, import_react18.useRef)(null); -+ if (visible) shown.current = room2 ? { text: memberAck?.text ?? "", at: memberAck?.at ?? Date.now() } : { text: busyAckText(hostAck), at: hostAck?.at ?? Date.now() }; -+ const text2 = shown.current?.text ?? ""; -+ if (!visible || !host || !shown.current || !bot) return null; -+ return (0, import_react_dom5.createPortal)((0, import_react18.createElement)( - "div", -- { className: "bt-agent" }, -- (0, import_react8.createElement)( -- "button", -- { -- type: "button", -- className: "bt-row", -- "aria-label": label, -- "aria-current": active ? "page" : void 0, -- "data-agent-id": agent.id, -- title: wide ? void 0 : "DSH Agent", -- onClick: () => actions.openAgent(agent.id), -- onContextMenu: (event) => { -- event.preventDefault(); -- actions.openMenu({ kind: "agent", id: agent.id, x: event.clientX, y: event.clientY }); -- } -- }, -- (0, import_react8.createElement)("span", { className: `bt-agent-ava${running ? " bt-run" : ""}` }, (0, import_react8.createElement)(import_dsh_client_ui_primitives2.FishLogo, { size: 22 })), -- wide ? (0, import_react8.createElement)( -- "span", -- { className: "bt-row-body" }, -- (0, import_react8.createElement)("span", { className: "bt-row-name" }, "DSH Agent"), -- preview ? (0, import_react8.createElement)("span", { className: "bt-row-preview" }, preview) : null -- ) : null, -- wide && unread && !active ? (0, import_react8.createElement)("span", { className: "bt-unread", "aria-hidden": true }) : null -- ), -- (0, import_react8.createElement)( ++ return (0, import_react18.createElement)( ++ "div", ++ { className: "bt-agent-sub-row bt-agent-edit", style: { "--i": index } }, ++ (0, import_react18.createElement)("input", { ++ ref: input, ++ defaultValue: session.title, ++ "aria-label": t("Rename"), ++ onKeyDown: (event) => { ++ if (event.nativeEvent.isComposing) return; ++ if (event.key === "Enter") commit(true); ++ else if (event.key === "Escape") commit(false); ++ }, ++ onBlur: () => commit(true) ++ }) ++ ); + } +- return next === void 0 ? void 0 : turns[next].first; +-} +-function openingTurn(turns, firstTurns, hiddenTurns) { +- if (!turns || !firstTurns) return null; +- const start = nextTurnStart(turns, -Infinity); +- if (start === void 0) return null; +- const turn = Number(Object.keys(turns).filter((key) => turns[key].first === start)[0]); +- const listed = firstTurns.split(",").map(Number); +- const earlier = listed.filter((entry) => entry < turn); +- return listed.includes(turn) && earlier.every((entry) => hiddenTurns?.includes(entry)) ? { turn, start } : null; +-} +-var unseenSince = (visit, time) => typeof visit?.seen === "number" && time > visit.seen && time <= visit.entered; +-function TurnTailCell({ turn: location2, sessionId, useTurnClock, useUi, actions }) { +- const turn = location2.turn; +- const time = location2.end.time; +- (0, import_react6.useEffect)(() => { +- actions.noteTurnTime(sessionId, turn, "end", time); +- }, [sessionId, turn, time]); +- const label = useTurnClock((state) => { +- const start = nextTurnStart(state[sessionId] ?? {}, turn); +- return start !== void 0 && start - time >= SEPARATOR_GAP_MS ? clockLabel(start) : ""; +- }); +- const visit = useUi((state) => state.visits?.[sessionId]); +- const fresh = useTurnClock((state) => { +- if (typeof visit?.seen !== "number" || time > visit.seen) return false; +- const start = nextTurnStart(state[sessionId] ?? {}, turn); +- return start !== void 0 && start > visit.seen && start <= visit.entered; +- }); +- if (!label && !fresh) return null; +- return (0, import_react6.createElement)( +- import_react6.Fragment, +- null, +- label ? (0, import_react6.createElement)("div", { className: "bt-time-sep", role: "separator" }, label) : null, +- fresh ? (0, import_react6.createElement)(NewDivider) : null ++ return (0, import_react18.createElement)( ++ "button", + { -+ className: "bt-busy-ack", -+ "data-flybot": "busy-ack", -+ role: "status", -+ "aria-live": "polite", -+ "aria-busy": text2 ? void 0 : "true", -+ "data-pending": text2 ? void 0 : "" ++ type: "button", ++ className: "bt-agent-sub-row", ++ "aria-current": session.active ? "page" : void 0, ++ style: { "--i": index }, ++ onClick: () => actions.openAgentSession(session.id), ++ onContextMenu: (event) => { ++ event.preventDefault(); ++ event.stopPropagation(); ++ actions.openMenu({ kind: "agent-session", id: session.id, agentId: agent.id, x: event.clientX, y: event.clientY }); ++ } + }, -+ (0, import_react18.createElement)(BotMark, { bot, size: 28, state: "thinking", live: true }), ++ (0, import_react18.createElement)("span", { className: "bt-agent-sub-title" }, session.title), ++ (0, import_react18.createElement)("span", { className: "bt-agent-sub-time" }, session.at > 0 ? relativeShort(session.at) : "") + ); + } +-function Shimmer({ children }) { +- return (0, import_react6.createElement)("span", { className: "bt-shimmer" }, children); +-} +-var PHRASES = { +- thinking: ["Thinking about it", "Weighing the options", "Working out a plan", "Sorting out the details"], +- working: ["On it", "Busy with it", "Moving ahead", "Taking care of it", "Making headway"] +-}; +-var TOOL_PHRASES = [ +- [/web_?search|search_?web/i, "Searching online"], +- [/fetch|browse|browser|web/i, "Reading a page"], +- [/shell|bash|pwsh|powershell|exec|command|terminal/i, "Running shell commands"], +- [/^(read|view|cat)\b|read_/i, "Opening a file"], +- [/grep|glob|list_dir|^ls$|find|search/i, "Looking through files"], +- [/write|edit|patch|apply|replace/i, "Writing changes"], +- [/image|photo|draw/i, "Working on an image"] +-]; +-var GROUP_PHRASES = { create_group: "Creating a group", update_group: "Updating the group", delete_group: "Deleting the group", post_to_group: "Posting in the group" }; +-function toolActivity(name, arg, findBot, self) { +- switch (name) { +- case "ask_user": +- return null; +- case "request_secret": +- return null; +- case "list_secrets": +- return { label: t("Checking the keys"), state: "searching" }; +- case "ask_user_question": +- return { label: t("Waiting for your answer"), state: "alert" }; +- case "group_relay": +- return { relay: true, state: "orbit" }; +- case "message_bot": { +- const target = findBot(arg("to")); +- return { label: target ? t("Asking {name}", { name: target.name }) : t("Asking another Bot"), state: "sending" }; +- } +- case "stop_bot": { +- const target = findBot(arg("bot")); +- return { label: target ? t("Stopping {name}", { name: target.name }) : t("Stopping another Bot"), state: "sending" }; +- } +- case "create_bot": +- return { label: t("Creating {name}", { name: arg("name") ?? t("a Bot") }), state: "sending" }; +- case "update_bot": { +- const target = findBot(arg("name")) ?? findBot(arg("bot")) ?? (arg("bot") ? void 0 : self); +- return { label: t("Updating {name}", { name: target?.name ?? t("a Bot") }), state: "sending" }; ++function AgentRow({ agent, actions, useSessions, useSessionStatus, useUi, wide, marked }) { ++ const info = useSessions((list) => JSON.stringify(agent.sessions.map((id) => { ++ const row = list.byId[id]; ++ return { ++ id, ++ // A blank Session's title is the workspace folder name; show the label instead. ++ title: row?.blank === true ? "" : row?.displayTitle ?? row?.title ?? "", ++ at: row?.updatedAt ?? 0, ++ running: row?.running === true, ++ active: (row?.retainedBy?.mainView ?? 0) > 0 ++ }; ++ }).sort((a, b) => b.at - a.at))); ++ const statusInfo = useSessionStatus((map) => JSON.stringify(agent.sessions.map((id) => ({ ++ id, ++ running: map.get(id)?.running === true, ++ unread: map.get(id)?.completionUnread === true ++ })))); ++ const statuses = Object.fromEntries(JSON.parse(statusInfo).map((entry) => [entry.id, entry])); ++ const sessions = JSON.parse(info).map((session) => ({ ++ ...session, ++ running: statuses[session.id]?.running ?? session.running, ++ unread: statuses[session.id]?.unread ?? false, ++ title: session.title === "" ? t("New session") : session.title ++ })); ++ const renaming = useUi((value) => value.renameAgent); ++ const active = sessions.some((session) => session.active); ++ const running = sessions.some((session) => session.running); ++ const unread = sessions.some((session) => session.unread) || marked && !active; ++ useMarkedUnread(agent.id, active, marked, actions); ++ const preview = sessions[0]?.title ?? t("New session"); ++ const label = ["DSH Agent", running ? t("Working") : "", unread ? t("Unread activity") : ""].filter(Boolean).join(", "); ++ const open = active && wide; ++ return (0, import_react18.createElement)( ++ "div", ++ { className: "bt-agent" }, + (0, import_react18.createElement)( - "div", -- { className: "bt-agent-sub", "data-open": open || void 0 }, -- (0, import_react8.createElement)( -+ { className: "bt-astack" }, ++ "button", ++ { ++ type: "button", ++ className: "bt-row", ++ "aria-label": label, ++ "aria-current": active ? "page" : void 0, ++ "data-agent-id": agent.id, ++ title: wide ? void 0 : "DSH Agent", ++ onClick: () => actions.openAgent(agent.id), ++ onContextMenu: (event) => { ++ event.preventDefault(); ++ actions.openMenu({ kind: "agent", id: agent.id, x: event.clientX, y: event.clientY }); ++ } ++ }, ++ (0, import_react18.createElement)("span", { className: `bt-agent-ava${running ? " bt-run" : ""}` }, (0, import_react18.createElement)(import_dsh_client_ui_primitives2.FishLogo, { size: 22 })), ++ wide ? (0, import_react18.createElement)( ++ "span", ++ { className: "bt-row-body" }, ++ (0, import_react18.createElement)("span", { className: "bt-row-name" }, "DSH Agent"), ++ preview ? (0, import_react18.createElement)("span", { className: "bt-row-preview" }, preview) : null ++ ) : null, ++ wide && unread && !active ? (0, import_react18.createElement)("span", { className: "bt-unread", "aria-hidden": true }) : null ++ ), ++ (0, import_react18.createElement)( ++ "div", ++ { className: "bt-agent-sub", "data-open": open || void 0 }, + (0, import_react18.createElement)( - "div", -- { className: "bt-agent-sub-in" }, -- (0, import_react8.createElement)( -- "button", -- { type: "button", className: "bt-agent-sub-row bt-agent-new", style: { "--i": 0 }, onClick: () => { -- void actions.newAgentSession(agent.id).catch((error) => actions.reportError(error, "agent-session")); -- } }, -- (0, import_react8.createElement)(PlusIcon), -- (0, import_react8.createElement)("span", { className: "bt-agent-sub-title" }, t("New session")) -- ), -- sessions.map((session, index) => (0, import_react8.createElement)(AgentSessionRow, { -- key: session.id, -- session, -- agent, -- actions, -- index: index + 1, -- renaming: renaming === session.id -- })) -+ { className: "bt-msg" }, ++ "div", ++ { className: "bt-agent-sub-in" }, + (0, import_react18.createElement)( -+ "div", -+ { className: "bt-msg-line" }, -+ text2 ? (0, import_react18.createElement)("div", { className: "bt-bubble" }, text2) : (0, import_react18.createElement)( -+ "div", -+ { className: "bt-bubble bt-busy-dots", "aria-hidden": "true" }, -+ (0, import_react18.createElement)("span", null), -+ (0, import_react18.createElement)("span", null), -+ (0, import_react18.createElement)("span", null) -+ ) -+ ) - ) - ) -- ); -+ ), host); - } --var GLIDE = springEasing(1e3, 63); --function useListGlide(listRef, layout, key) { -- const tops = (0, import_react8.useRef)({ layout, map: /* @__PURE__ */ new Map() }); -- (0, import_react8.useLayoutEffect)(() => { -- const list = listRef.current; -- if (!list) return; -- const next = /* @__PURE__ */ new Map(); -- const still = reducedMotion() || tops.current.layout !== layout; -- for (const row of list.querySelectorAll("[data-bot-id],[data-room-id],[data-agent-id]")) { -- const id = row.dataset.botId ?? row.dataset.roomId ?? row.dataset.agentId; -- const top = row.offsetTop; -- next.set(id, top); -- const before = tops.current.map.get(id); -- if (!still && before !== void 0 && Math.abs(before - top) > 0.5) { -- row.animate([{ transform: `translateY(${before - top}px)` }, { transform: "none" }], GLIDE); -- } -+var BUSY_ACK_CSS = ` -+.bt-busy-ack{display:flex;align-items:flex-end;gap:8px;width:100%;margin-top:var(--dsh-chat-flow-gap,12px);margin-bottom:4px} -+.bt-busy-ack .bt-astack{align-items:flex-start} -+.bt-busy-ack .bt-bubble{opacity:.92} -+.bt-busy-dots{display:inline-flex;align-items:center;gap:4px;height:22px} -+.bt-busy-dots>span{width:5px;height:5px;border-radius:50%;background:var(--bt-ink-3);animation:bt-busy-blink 1.2s ease-in-out infinite} -+.bt-busy-dots>span:nth-child(2){animation-delay:.2s}.bt-busy-dots>span:nth-child(3){animation-delay:.4s} -+@keyframes bt-busy-blink{0%,80%,100%{opacity:.25}40%{opacity:1}} -+@media (prefers-reduced-motion:reduce){.bt-busy-dots>span{animation:none;opacity:.6}} -+`; -+ -+// src/client/project-board.js -+var import_react19 = require("react"); -+var PROJECT_BOARD_CSS = ` -+/* #1750: the board was a short dialog in the page flow — no stylesheet defines 'bt-overlay', so the -+ bot tree and workflows stayed legible under its rows. It is a modal now, on the same tier as the -+ Board and Settings layers, with kanban's own 'bt-scrim' sibling and 'bt-mem' surface. */ -+.bt-project-overlay{position:fixed;inset:0;z-index:121;isolation:isolate;display:flex;align-items:center;justify-content:center;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",system-ui,sans-serif} -+.bt-mem.bt-project-dialog{width:min(1080px,calc(100vw - 32px));height:min(760px,calc(100vh - 48px))} -+.bt-project-body{flex:1 1 auto;min-height:0;overflow:auto} -+.bt-project-board{padding:16px 18px} -+.bt-project-group{margin:0 0 14px} -+.bt-project-group h4{margin:0 0 6px;font-size:12px;letter-spacing:.04em;text-transform:uppercase;opacity:.7} -+.bt-project-row{display:flex;gap:10px;align-items:center;padding:8px 10px;border-radius:8px;background:var(--bt-card,rgba(127,127,127,.08));margin:0 0 6px;cursor:default} -+.bt-project-row[data-column="done"]{opacity:.7} -+.bt-project-title-wrap{display:flex;flex-direction:column;gap:2px;min-width:0} -+.bt-project-audit{font-size:11px;line-height:1.45;white-space:pre-wrap;opacity:.65;font-family:var(--bt-mono,ui-monospace,monospace)} -+.bt-project-row .bt-project-ref{margin-left:auto;font-size:11px;opacity:.6} -+.bt-project-empty{padding:22px 4px;opacity:.7} -+/* #1767: the record beside the cards. The same surface as a row, one step quieter, and no new -+ component language: a heading, a list, and a monospace hash the reader can compare. */ -+.bt-project-record{margin:18px 0 0;border-top:1px solid var(--bt-line,rgba(127,127,127,.22));padding:12px 0 0} -+.bt-project-record h4{margin:0 0 6px;font-size:12px;letter-spacing:.04em;text-transform:uppercase;opacity:.7} -+.bt-project-record h5{margin:10px 0 4px;font-size:11px;letter-spacing:.04em;text-transform:uppercase;opacity:.55} -+.bt-project-decision,.bt-project-meeting{display:flex;gap:10px;align-items:baseline;padding:6px 10px;border-radius:8px;background:var(--bt-card,rgba(127,127,127,.08));margin:0 0 6px} -+.bt-project-decision .bt-project-date,.bt-project-meeting .bt-project-date{font-size:11px;opacity:.6;min-width:76px;font-variant-numeric:tabular-nums} -+.bt-project-decision .bt-project-hash{margin-left:auto;font-size:11px;opacity:.55;font-family:var(--bt-mono,ui-monospace,monospace)} -+.bt-project-decision .bt-project-title,.bt-project-meeting .bt-project-title{min-width:0} -+/* #2041: the decision row became a button, so it keeps the row's look and drops the control's. */ -+button.bt-project-decision{width:100%;font:inherit;color:inherit;text-align:left;border:0;cursor:pointer} -+button.bt-project-decision:hover{background:var(--bt-card-hover,rgba(127,127,127,.16))} -+button.bt-project-decision:focus-visible{outline:2px solid var(--bt-accent,#4c8dff);outline-offset:1px} -+button.bt-project-decision[data-open="true"]{outline:1px solid var(--bt-accent,#4c8dff)} -+.bt-project-decision-wrap{margin:0} -+.bt-project-decision-text{margin:0 0 8px;padding:8px 10px;border-radius:8px;background:var(--bt-card,rgba(127,127,127,.08));white-space:pre-wrap;overflow-wrap:anywhere;max-height:320px;overflow:auto;font-size:12px;font-family:var(--bt-mono,ui-monospace,monospace)} -+.bt-project-decision-refusal{opacity:.75} -+.bt-project-path{font-size:11px;opacity:.6;font-family:var(--bt-mono,ui-monospace,monospace)} -+.bt-project-missing{font-size:11px;opacity:.6} -+.bt-project-status{font-size:11px;text-transform:uppercase;letter-spacing:.04em;opacity:.6;margin-left:auto} -+/* #1831: the card's level, the people it is addressed to, and the identity a row or a comment -+ carries. A severe card is the only one painted louder, because it is the only one that waits. */ -+.bt-project-level{font-size:10px;text-transform:uppercase;letter-spacing:.06em;padding:1px 6px;border-radius:999px;border:1px solid var(--bt-line,rgba(127,127,127,.32));opacity:.75} -+.bt-project-level[data-level="severe"]{opacity:1;border-color:#c2410c;color:#c2410c} -+.bt-project-recipients{font-size:11px;opacity:.65} -+.bt-project-paused{font-size:11px;opacity:.75;color:#c2410c} -+.bt-project-holder{font-size:11px;opacity:.7} -+.bt-project-speaks-for{font-size:11px;opacity:.7;font-style:italic} -+.bt-project-actions{display:flex;gap:8px;margin:0 0 14px} -+.bt-project-metrics{margin:0 0 14px} -+.bt-project-metric-cards{display:grid;grid-template-columns:repeat(auto-fit,minmax(132px,1fr));gap:8px} -+.bt-project-metric{display:flex;flex-direction:column;gap:2px;padding:8px 10px;border:1px solid rgba(127,127,127,.24);border-radius:8px} -+.bt-project-metric-value{font-size:18px;font-weight:600} -+.bt-project-metric-label{font-size:12px;font-weight:600} -+.bt-project-metric-caption{font-size:10px;opacity:.6;line-height:1.3} -+.bt-project-metrics-note{margin:0 0 6px;font-size:11px;opacity:.7} -+.bt-project-running{margin:0 0 14px} -+.bt-project-running-head,.bt-project-running-row{display:grid;grid-template-columns:2fr 1fr 1fr .8fr .8fr 1.2fr;gap:8px;font-size:12px;padding:3px 0} -+.bt-project-running-head{opacity:.6;font-size:10px;letter-spacing:.03em} -+.bt-project-running-row[role="button"]{cursor:pointer} -+.bt-project-running-row[data-overdue="true"]{color:#c2410c} -+.bt-project-counts{display:flex;gap:14px;align-items:center;margin:0 0 12px;font-size:12px;letter-spacing:.03em;opacity:.8} -+.bt-project-counts b{font-weight:600} -+.bt-project-group[data-queue="waiting"] .bt-project-row{cursor:default} -+.bt-project-role{font-size:11px;opacity:.65;font-family:var(--bt-mono,ui-monospace,monospace)} -+.bt-project-wait{font-size:11px;opacity:.65;margin-left:auto} -+.bt-project-state{font-size:11px;margin-left:auto} -+.bt-project-row[data-state="stalled"]{border-left:2px solid #d9822b} -+.bt-project-reason{font-size:11px;opacity:.7} -+ -+/* #1855: the finished cards are one group of their own, closed until the reader opens it — a -+ Project with months of history reads as the work in flight, not as its archive. */ -+.bt-project-done{margin:18px 0 0;border-top:1px solid var(--bt-line,rgba(127,127,127,.22));padding:12px 0 0} -+.bt-project-done>summary{cursor:pointer;font-size:12px;letter-spacing:.04em;text-transform:uppercase;opacity:.7;list-style:none} -+.bt-project-done>summary::-webkit-details-marker{display:none} -+.bt-project-done>summary::before{content:'\\25B8';display:inline-block;width:12px;opacity:.6} -+.bt-project-done[open]>summary::before{content:'\\25BE'} -+.bt-project-done[open]>summary{margin:0 0 8px} -+ -+/* #1749: management panels. Same surface as the board, one form row per control, and a status -+ line that quotes the host's own answer — a refused save reads where the reader pressed it. */ -+.bt-project-panel{padding:16px 18px;display:flex;flex-direction:column;gap:12px} -+.bt-project-panel h3{margin:0} -+.bt-project-field{display:flex;flex-direction:column;gap:4px} -+.bt-project-field label{font-size:12px;letter-spacing:.04em;text-transform:uppercase;opacity:.7} -+.bt-project-field input,.bt-project-field select,.bt-project-field textarea{font:inherit;padding:6px 8px;border-radius:8px;border:1px solid var(--bt-line,rgba(127,127,127,.32));background:var(--bt-card,rgba(127,127,127,.08));color:inherit} -+.bt-project-field textarea{min-height:88px;resize:vertical} -+/* #1887: the counter under the memory box. It stays quiet until the text passes the bound, and -+ past it turns the one colour that says so; the disabled Save is the state a reader acts on. */ -+.bt-project-count{font-size:11px;opacity:.6;font-variant-numeric:tabular-nums} -+.bt-project-count[data-over="true"]{opacity:1;color:#c2410c} -+.bt-project-row-actions{display:flex;gap:8px;align-items:center} -+.bt-project-members{list-style:none;margin:0;padding:0;display:flex;flex-direction:column;gap:6px} -+.bt-project-member{display:flex;gap:10px;align-items:baseline;padding:8px 10px;border-radius:8px;background:var(--bt-card,rgba(127,127,127,.08))} -+.bt-project-member .bt-project-member-kind{margin-left:auto;font-size:11px;opacity:.6} -+.bt-project-status-line{font-size:12px;opacity:.75;min-height:16px} -+.bt-project-note{font-size:12px;opacity:.65;margin:0} -+ -+/* #1860: the Project team — who runs the Project, who speaks for the reader, and the role pool. -+ One quiet block above the cards, and a Bot row is a button because a reader is looking a -+ person up. The pool row keeps the rank it plays and the ranks the host said it may switch to. */ -+.bt-project-team{margin:0 0 14px} -+.bt-project-team h4{margin:0 0 6px;font-size:12px;letter-spacing:.04em;text-transform:uppercase;opacity:.7} -+.bt-project-team-row{display:flex;gap:10px;align-items:baseline;flex-wrap:wrap;padding:8px 10px;border-radius:8px;background:var(--bt-card,rgba(127,127,127,.08));margin:0 0 6px} -+.bt-project-team-row[data-open-bot]{cursor:pointer} -+.bt-project-team-row[data-open-bot]:hover{background:var(--bt-hover,rgba(127,127,127,.14))} -+.bt-project-team-label{font-size:12px;opacity:.7;min-width:96px} -+.bt-project-team-bot{font-size:12px;font-family:var(--bt-mono,ui-monospace,monospace)} -+.bt-project-team-roles{display:flex;gap:10px;flex-wrap:wrap;font-size:11px;opacity:.75} -+.bt-project-team-role{font-family:var(--bt-mono,ui-monospace,monospace)} -+.bt-project-team-myrole{margin-left:auto;font-size:11px;opacity:.65} -+ -+/* #1864: the team dossier. A Dossier control on each team row opens one read-only panel; the -+ owner's own chat header carries the same entry to the Project assistant's chat. */ -+.bt-project-team-dossier{margin-left:auto;font-size:11px;padding:1px 8px;border-radius:999px;border:1px solid var(--bt-line,rgba(127,127,127,.32));background:transparent;color:inherit;cursor:pointer} -+.bt-project-team-dossier:hover{background:var(--bt-hover,rgba(127,127,127,.14))} -+.bt-project-dossier{padding:16px 18px;display:flex;flex-direction:column;gap:12px} -+.bt-project-dossier-section{display:flex;flex-direction:column;gap:4px} -+.bt-project-dossier-section h4{margin:0;font-size:12px;letter-spacing:.04em;text-transform:uppercase;opacity:.7} -+.bt-project-dossier-text{margin:0;font-size:13px;line-height:1.5;white-space:pre-wrap;overflow-wrap:anywhere} -+.bt-project-dossier-meta{display:flex;gap:12px;flex-wrap:wrap;font-size:11px;opacity:.7} -+.bt-project-dossier-topics{display:flex;gap:6px;flex-wrap:wrap} -+.bt-project-dossier-topic{font-size:11px;padding:1px 6px;border-radius:999px;background:var(--bt-card,rgba(127,127,127,.08))} -+.bt-project-agent-entry{display:inline-flex;align-items:center;gap:4px;font-size:11px;padding:1px 8px;border-radius:999px;border:1px solid var(--bt-line,rgba(127,127,127,.32));background:transparent;color:inherit;cursor:pointer} -+.bt-project-agent-entry:hover{background:var(--bt-hover,rgba(127,127,127,.14))} -+`; -+var PROJECT_SOURCES = ["bot", "group", "workflow"]; -+var SOURCE_LABELS = { bot: "Bots", group: "Groups", workflow: "Workflows" }; -+var text = (value) => typeof value === "string" ? value.trim() : ""; -+var MEMORY_TEXT_MAX = 600; -+var memoryLength = (value) => [...text(value)].length; -+var DONE_COLUMN = "done"; -+var boardOfRow = (row, task) => { -+ const stated = row?.board; -+ if (stated !== null && typeof stated === "object") { -+ const id = text(stated.id); -+ if (id !== "" && (stated.kind === "group" || stated.kind === "bot")) return { kind: stated.kind, id }; ++ "button", ++ { type: "button", className: "bt-agent-sub-row bt-agent-new", style: { "--i": 0 }, onClick: () => { ++ void actions.newAgentSession(agent.id).catch((error) => actions.reportError(error, "agent-session")); ++ } }, ++ (0, import_react18.createElement)(PlusIcon), ++ (0, import_react18.createElement)("span", { className: "bt-agent-sub-title" }, t("New session")) ++ ), ++ sessions.map((session, index) => (0, import_react18.createElement)(AgentSessionRow, { ++ key: session.id, ++ session, ++ agent, ++ actions, ++ index: index + 1, ++ renaming: renaming === session.id ++ })) ++ ) ++ ) ++ ); ++} ++var GLIDE = springEasing(1e3, 63); ++function useListGlide(listRef, layout, key) { ++ const tops = (0, import_react18.useRef)({ layout, map: /* @__PURE__ */ new Map() }); ++ (0, import_react18.useLayoutEffect)(() => { ++ const list = listRef.current; ++ if (!list) return; ++ const next = /* @__PURE__ */ new Map(); ++ const still = reducedMotion() || tops.current.layout !== layout; ++ for (const row of list.querySelectorAll("[data-bot-id],[data-room-id],[data-agent-id]")) { ++ const id = row.dataset.botId ?? row.dataset.roomId ?? row.dataset.agentId; ++ const top = row.offsetTop; ++ next.set(id, top); ++ const before = tops.current.map.get(id); ++ if (!still && before !== void 0 && Math.abs(before - top) > 0.5) { ++ row.animate([{ transform: `translateY(${before - top}px)` }, { transform: "none" }], GLIDE); ++ } + } +- case "list_bots": +- return { label: t("Checking the team"), state: "searching" }; +- case "read_group_chat": +- return { label: t("Reading {name}", { name: arg("group") ?? t("a group chat") }), state: "searching" }; +- case "read_own_chat": +- return { label: t("Looking back through the chat"), state: "searching" }; +- case "remember": +- return { label: t("Saving to memory"), state: "working" }; +- case "forget": +- return { label: t("Updating memory"), state: "working" }; +- case "recall": +- return { label: t("Checking memory"), state: "searching" }; +- default: +- if (GROUP_PHRASES[name]) return { label: t(GROUP_PHRASES[name]), state: "sending" }; +- const phrase = TOOL_PHRASES.find(([pattern]) => pattern.test(name))?.[1]; +- return { label: phrase ? t(phrase) : null, state: toolState(name) }; +- } ++ tops.current = { layout, map: next }; ++ }, [layout, key]); + } +-function workPhrase(tool) { +- if (!tool) return t("Thinking about it"); +- return toolActivity(tool, () => void 0, () => void 0)?.label ?? t("On it"); ++var NodeActions = class extends import_react18.Component { ++ constructor(props) { ++ super(props); ++ this.state = { failed: false }; + } -+ if (task === void 0) return null; -+ if (row.source === "group") { -+ const id = text(task.origin?.roomId); -+ return id === "" ? null : { kind: "group", id }; ++ static getDerivedStateFromError() { ++ return { failed: true }; + } -+ if (row.source === "bot") { -+ const id = text(task.ownerBotId); -+ return id === "" ? null : { kind: "bot", id }; ++ componentDidCatch(error) { ++ console.error("flybot: nodeActions failed", error); ++ } ++ render() { ++ return this.state.failed ? null : (0, import_react18.createElement)(this.props.component, { bot: this.props.bot }); + } -+ return null; +}; -+var decisionOf = (file) => { -+ if (file === null || typeof file !== "object") return null; -+ const path = text(file.path); -+ if (path === "") return null; -+ return { -+ key: `decision:${path}`, -+ path, -+ name: text(file.name) || path.slice(path.lastIndexOf("/") + 1), -+ date: text(file.date), -+ title: text(file.title), -+ sha256: text(file.sha256), -+ shortHash: text(file.sha256).slice(0, 12) ++function TreeNode({ node, rowProps, markedUnread, collapsed, toggle, actions }) { ++ const { bot, depth, children } = node; ++ const closed = collapsed[bot.id] === true; ++ return (0, import_react18.createElement)( ++ import_react18.Fragment, ++ null, ++ (0, import_react18.createElement)( ++ "div", ++ { className: "bt-tree-item", "data-depth": depth, style: { paddingLeft: `${depth * 16}px` } }, ++ children.length ? (0, import_react18.createElement)( ++ "button", ++ { type: "button", className: "bt-tree-toggle", "aria-expanded": !closed, "aria-label": `${closed ? t("Expand") : t("Collapse")} ${bot.name}`, onClick: () => toggle(bot.id) }, ++ (0, import_react18.createElement)("span", { className: "bt-tree-chev", "aria-hidden": true }, "▾") ++ ) : (0, import_react18.createElement)("span", { className: "bt-tree-toggle bt-tree-leaf", "aria-hidden": true }), ++ (0, import_react18.createElement)(BotRow, { ...rowProps, bot, asRow: true, marked: markedUnread[bot.id] === true }), ++ typeof actions === "function" ? (0, import_react18.createElement)(NodeActions, { component: actions, bot }) : null ++ ), ++ closed ? null : children.map((child) => (0, import_react18.createElement)(TreeNode, { key: child.bot.id, node: child, rowProps, markedUnread, collapsed, toggle, actions })) ++ ); + } +-function useLiveNote(actions, sessionId, id, entry) { +- const key = entry ? JSON.stringify(entry) : ""; +- (0, import_react6.useEffect)(() => { +- if (!key) return void 0; +- actions.noteLive(sessionId, id, JSON.parse(key)); +- return () => actions.noteLive(sessionId, id, null); +- }, [sessionId, id, key]); ++function TeamSidebar(props) { ++ const { wide, useRoster, useUi, useSessions, actions } = props; ++ const listRef = (0, import_react18.useRef)(null); ++ const roster = useRoster((value) => value); ++ const markedUnread = useUi((value) => value.unread); ++ props.useRegistry((value) => value); ++ const updated = useSessions((list) => { ++ const stamps = {}; ++ for (const id of list.ids) stamps[id] = list.byId[id]?.updatedAt ?? 0; ++ return JSON.stringify(stamps); ++ }); ++ const order = (0, import_react18.useMemo)(() => { ++ const stamps = JSON.parse(updated); ++ const entries2 = [ ++ // Own Bots, friends' Bots, groups and face rooms share one list. A remote chat ++ // still uses BotRow's globe; gone / hidden / home-level remotes stay out. ++ ...roster.bots.filter((bot) => bot.id !== roster.mainBotId && isShownRemoteChat(bot)).map((bot) => ({ kind: "bot", item: bot, at: stamps[currentPart(bot)] ?? bot.createdAt })), ++ ...roster.rooms.filter((room2) => !isPastFaceRoom(room2)).map((room2) => ({ kind: "room", item: room2, at: stamps[room2.id] ?? room2.createdAt })), ++ // The Agent sorts by its most recent Session, falling back to when it was added. ++ ...(roster.agents ?? []).map((agent) => ({ kind: "agent", item: agent, at: Math.max(0, ...agent.sessions.map((id) => stamps[id] ?? 0)) || agent.createdAt })) ++ ]; ++ const foreign = (entry) => entry.kind === "bot" && entry.item.remote || entry.kind === "room" && (entry.item.origin === "face-to-face" || entry.item.sharedFrom); ++ return entries2.sort((a, b) => Number(Boolean(b.item.pinned)) - Number(Boolean(a.item.pinned)) || Number(foreign(a)) - Number(foreign(b)) || b.at - a.at); ++ }, [roster, updated]); ++ const main = roster.byId[roster.mainBotId]; ++ const readOnlySaid = typeof roster.readOnly === "string" && roster.readOnly.trim() !== ""; ++ const structure = useSidebarStructure(); ++ const [collapsed, setCollapsed] = (0, import_react18.useState)({}); ++ const toggle = (id) => setCollapsed((value) => ({ ...value, [id]: !value[id] })); ++ const inTree = (entry) => entry.kind === "bot" && !entry.item.remote; ++ const listed = (entry) => inTree(entry) && structure?.include?.(entry.item.id) !== false; ++ const tree = structure && wide && main ? botTree([main, ...order.filter(listed).map((entry) => entry.item)], main.id, (id) => structure.parentOf(id)) : null; ++ useListGlide(listRef, wide, order.map((entry) => entry.item.id).join(",")); ++ useLandOnMainBot(roster, useSessions, actions); ++ const rowProps = { roster, actions, useSessions, useUi, useSessionStatus: props.useSessionStatus, useSessionRetainInfo: props.useSessionRetainInfo, useActivity: props.useActivity, useWork: props.useWork, wide }; ++ const typeToSearch = (event) => { ++ if (event.defaultPrevented || event.key.length !== 1 || event.key === " " || event.metaKey || event.ctrlKey || event.altKey || event.nativeEvent.isComposing) return; ++ if (event.target instanceof Element && event.target.closest("input,textarea,[contenteditable]")) return; ++ event.preventDefault(); ++ actions.openPalette(event.key); + }; -+}; -+var meetingOf = (meeting, status) => { -+ if (meeting === null || typeof meeting !== "object") return null; -+ const id = text(meeting.id); -+ if (id === "") return null; -+ return { -+ key: `meeting:${id}`, -+ id, -+ date: text(meeting.date), -+ question: text(meeting.question) || text(meeting.title), -+ owner: text(meeting.owner) || null, -+ status, -+ path: text(meeting.path), -+ blockingQuestion: text(meeting.blockingQuestion) || null, -+ // #1831: the level and recipients the host answered, and the refusal to advance a severe card. -+ // Nothing is inferred here: a record written before the slice carries an ordinary card and no -+ // recipients, exactly as the host reads it. -+ level: text(meeting.level) || "ordinary", -+ recipients: Array.isArray(meeting.recipients) ? meeting.recipients.map((item) => text(item)).filter((name) => name !== "") : [], -+ advance: meeting.advance !== null && typeof meeting.advance === "object" ? { allowed: meeting.advance.allowed === true, reason: text(meeting.advance.reason) || null } : { allowed: true, reason: null }, -+ // #1861: the host's participants with who each position speaks for, so the row can say a -+ // Project Chief or an agent held it for someone rather than for themselves. -+ participants: Array.isArray(meeting.participants) ? meeting.participants.map((participant) => ({ name: text(participant?.name), speaksFor: text(participant?.speaksFor) || null })).filter((participant) => participant.name !== "") : [] ++ return (0, import_react18.createElement)( ++ "div", ++ { className: wide ? "bt-side" : "bt-side bt-rail", onKeyDown: typeToSearch }, ++ wide ? (0, import_react18.createElement)( ++ "div", ++ { className: "bt-side-top" }, ++ (0, import_react18.createElement)("span", { className: "bt-brand" }, (0, import_react18.createElement)(BrandMark, { size: 22 }), (0, import_react18.createElement)(BrandName)), ++ (0, import_react18.createElement)(UpdateButton, { useUpdate: props.useUpdate, actions }), ++ (0, import_react18.createElement)("button", { type: "button", className: "bt-round", "aria-label": t("Search"), title: t("Search"), onClick: () => actions.openPalette() }, (0, import_react18.createElement)(SearchIcon)), ++ (0, import_react18.createElement)("button", { type: "button", className: "bt-round", "aria-label": t("New…"), title: t("New…"), onClick: () => actions.openNewChat("new") }, (0, import_react18.createElement)(PlusIcon)) ++ ) : (0, import_react18.createElement)( ++ import_react18.Fragment, ++ null, ++ (0, import_react18.createElement)(UpdateButton, { useUpdate: props.useUpdate, actions }), ++ (0, import_react18.createElement)("button", { type: "button", className: "bt-round", "aria-label": t("New…"), title: t("New…"), onClick: () => actions.openNewChat("new") }, (0, import_react18.createElement)(PlusIcon)) ++ ), ++ wide && readOnlySaid ? (0, import_react18.createElement)("div", { className: "bt-hint bt-read-only", role: "status" }, hostText(roster.readOnly)) : null, ++ !wide && readOnlySaid ? (0, import_react18.createElement)("div", { className: "bt-hint bt-read-only", role: "status", title: hostText(roster.readOnly), "aria-label": hostText(roster.readOnly) }, hostText(roster.readOnly)) : null, ++ (0, import_react18.createElement)(InboxRow, { wide, useRoster, useWork: props.useWork, useUi, actions }), ++ (0, import_react18.createElement)(KanbanRow, { wide, useUi, actions }), ++ (0, import_react18.createElement)(ProjectsNav, { wide }), ++ (0, import_react18.createElement)( ++ "div", ++ { ref: listRef, className: "bt-list", role: "region", "aria-label": t("Bot list") }, ++ tree ? (0, import_react18.createElement)("div", { className: "bt-tree", role: "group", "aria-label": t("Bot tree") }, tree.map((node) => (0, import_react18.createElement)(TreeNode, { key: node.bot.id, node, rowProps, markedUnread, collapsed, toggle, actions: structure?.nodeActions }))) : main ? (0, import_react18.createElement)(BotRow, { ...rowProps, bot: main, marked: markedUnread[main.id] === true }) : roster.ready ? null : (0, import_react18.createElement)("div", { className: "bt-hint" }, t("Setting up your Main Bot…")), ++ order.filter((entry) => !(tree && inTree(entry)) && !(inTree(entry) && structure?.include?.(entry.item.id) === false)).map((entry) => entry.kind === "bot" ? (0, import_react18.createElement)(BotRow, { key: entry.item.id, ...rowProps, bot: entry.item, marked: markedUnread[entry.item.id] === true }) : entry.kind === "room" ? (0, import_react18.createElement)(RoomRow, { key: entry.item.id, ...rowProps, room: entry.item, marked: markedUnread[entry.item.id] === true }) : (0, import_react18.createElement)(AgentRow, { key: entry.item.id, ...rowProps, agent: entry.item, marked: markedUnread[entry.item.id] === true })), ++ wide ? (0, import_react18.createElement)(FlyBotSections, { roster, actions, renderBot: (bot) => (0, import_react18.createElement)(BotRow, { key: bot.id, ...rowProps, bot, asRow: true, marked: markedUnread[bot.id] === true }) }) : null ++ ) ++ ); + } +-function GroupReplies({ sessionId, callId, done, content, roster, actions, useUi, useWork }) { +- const [live, setLive] = (0, import_react6.useState)({ replies: [], speaking: null }); +- (0, import_react6.useEffect)(() => { +- if (done) return void 0; +- let stop = false; +- let timer; +- let seen = ""; +- const tick = async () => { +- const result = await actions.roomProgress(sessionId); +- if (stop) return; +- const entry = (result ?? []).find((item) => item.callId === callId) ?? (result ?? [])[0]; +- const key = entry ? JSON.stringify([entry.replies, entry.speaking]) : ""; +- if (entry && key !== seen) { +- seen = key; +- setLive({ replies: entry.replies ?? [], speaking: entry.speaking ?? null }); ++function UpdateButton({ useUpdate, actions }) { ++ const update = useUpdate((value) => value); ++ const [open, setOpen] = (0, import_react18.useState)(false); ++ const [box, setBox] = (0, import_react18.useState)(null); ++ const [error, setError] = (0, import_react18.useState)(""); ++ const [dshError, setDshError] = (0, import_react18.useState)(""); ++ const button = (0, import_react18.useRef)(null); ++ (0, import_react18.useEffect)(() => { ++ if (!open) return void 0; ++ const onDown = (event) => { ++ if (event.target instanceof Element && (event.target.closest(".bt-update-pop") || button.current?.contains(event.target))) return; ++ setOpen(false); ++ }; ++ const onKey = (event) => { ++ if (event.key === "Escape") { ++ event.stopPropagation(); ++ setOpen(false); ++ button.current?.focus(); + } +- timer = setTimeout(tick, document.hidden ? 5e3 : 1e3); + }; +- void tick(); ++ window.addEventListener("mousedown", onDown); ++ window.addEventListener("keydown", onKey, true); + return () => { +- stop = true; +- clearTimeout(timer); ++ window.removeEventListener("mousedown", onDown); ++ window.removeEventListener("keydown", onKey, true); + }; +- }, [done, sessionId, callId]); +- let replies = live.replies; +- if (done) { +- try { +- replies = JSON.parse(contentText(content)).replies ?? []; +- } catch { +- replies = []; +- } +- } +- const pending = done || !live.speaking ? [] : [live.speaking]; +- const speakerTool = useWork?.((work) => work.bots?.[live.speaking]?.tool) ?? void 0; +- if (replies.length === 0 && pending.length === 0) return null; +- const room2 = roster.roomsById[sessionId]; +- return (0, import_react6.createElement)( +- "div", +- { className: "bt-group-replies" }, +- replies.map((reply, index) => { +- const bot = roster.byId[reply.botId] ?? { id: reply.botId, name: reply.name, color: reply.color ?? "gray" }; +- if (reply.kind === "user") { +- return (0, import_react6.createElement)("div", { key: `user-${index}`, className: "bt-urow" }, (0, import_react6.createElement)( +- "div", +- { className: "bt-ububble" }, +- (0, import_react6.createElement)(import_dsh_client_ui_primitives.MarkdownText, { text: reply.text, labels: mdLabels(), variant: "compact" }) +- )); +- } +- if (reply.kind === "failed") { +- return (0, import_react6.createElement)( +- "div", +- { key: `${reply.botId}-${index}`, className: "bt-event bt-event-error", role: "status", title: [reply.code, reply.status, reply.text].filter(Boolean).join(" · ") }, +- t("{name} could not answer: {reason}", { name: bot.name, reason: failureLabel(reply.code, reply.text) }) +- ); +- } +- if (reply.kind === "lookup") { +- return (0, import_react6.createElement)( +- "div", +- { key: `${reply.botId}-${index}`, className: "bt-event" }, +- reply.text ? t("{name} looked up its own chat for “{text}”", { name: bot.name, text: reply.text }) : t("{name} looked up its own chat", { name: bot.name }) +- ); +- } +- if (reply.kind === "missed") { +- return (0, import_react6.createElement)("div", { key: `${reply.botId}-${index}`, className: "bt-event" }, t("{name} didn't answer in time", { name: bot.name })); +- } +- if (reply.kind === "resumed") { +- return (0, import_react6.createElement)("div", { key: `${reply.botId}-${index}`, className: "bt-event" }, t("{name} was cut off by a restart and picked its work back up", { name: bot.name })); +- } +- if (reply.kind === "cut") { +- return (0, import_react6.createElement)("div", { key: `${reply.botId}-${index}`, className: "bt-event" }, t("{name} was cut off by a restart again and stopped", { name: bot.name })); +- } +- if (reply.kind === "approval" || reply.kind === "working") { +- return (0, import_react6.createElement)( ++ }, [open]); ++ const phase = update?.phase ?? "idle"; ++ const dsh = update?.dsh; ++ const dshPhase = dsh?.phase ?? "idle"; ++ const dshRow = showsDshRow(dsh, update?.app); ++ if (!update?.available && !dshRow && phase === "idle" && dshPhase === "idle") return null; ++ const cloudPhase = phase === "installing" || dshPhase === "installing" ? "installing" : phase !== "idle" ? phase : dshPhase; ++ const toggle = () => { ++ const rect = button.current?.getBoundingClientRect(); ++ if (rect) setBox({ left: Math.max(8, Math.min(rect.left, window.innerWidth - 288)), top: rect.bottom + 6 }); ++ setOpen((value) => !value); + }; -+}; -+var listOf = (value) => Array.isArray(value) ? value.map((item) => decisionOf(item)).filter((item) => item !== null) : []; -+function projectBoardRecord({ decisions, meetings } = {}) { -+ const dir = text(decisions?.dir) || null; -+ const files = listOf(decisions?.files); -+ const decided = Array.isArray(meetings?.decided) ? meetings.decided.map((item) => meetingOf(item, "decided")).filter((item) => item !== null) : []; -+ const pending = Array.isArray(meetings?.pending) ? meetings.pending.map((item) => meetingOf(item, item?.status ?? "deliberating")).filter((item) => item !== null) : []; -+ const totalOf = (value) => typeof value === "number" && Number.isFinite(value) ? value : null; -+ const hiddenOf = (total, rows) => total === null ? 0 : Math.max(0, total - rows); -+ const decisionsTotal = totalOf(decisions?.total); -+ const decidedTotal = totalOf(meetings?.decidedTotal); -+ const pendingTotal = totalOf(meetings?.pendingTotal); -+ return { -+ decisions: { -+ dir, -+ files, -+ count: files.length, -+ total: decisionsTotal, -+ hidden: hiddenOf(decisionsTotal, files.length), -+ empty: files.length === 0, -+ emptyText: text(decisions?.reason) || (dir === null ? "This Project names no working folder." : `No decision file in ${dir}/decisions.`) -+ }, -+ meetings: { -+ decided, -+ pending, -+ count: decided.length + pending.length, -+ decidedTotal, -+ pendingTotal, -+ decidedHidden: hiddenOf(decidedTotal, decided.length), -+ pendingHidden: hiddenOf(pendingTotal, pending.length), -+ empty: decided.length === 0 && pending.length === 0, -+ emptyText: text(meetings?.reason) || "No meeting of this Project is recorded." - } -- tops.current = { layout, map: next }; -- }, [layout, key]); ++ const start = () => { ++ setError(""); ++ void actions.installUpdate().catch((failure) => setError(hostText(failure?.message ?? String(failure)))); ++ }; ++ const startDsh = () => { ++ setDshError(""); ++ void actions.installDshUpdate().catch((failure) => setDshError(hostText(failure?.message ?? String(failure)))); + }; ++ const label = phase === "installing" ? t("Updating FlyBot core…") : dshPhase === "installing" ? t("Updating DSH…") : phase === "installed" ? t("FlyBot core {version} is installed. Restart DSH to use it.", { version: update.installed ?? update.latest }) : dshPhase === "installed" ? t("Restart DSH to start using DSH {version}.", { version: dsh.installed ?? dsh.verified }) : update?.available ? t("FlyBot core {version} is available", { version: update.latest }) : t("DSH {version} is available", { version: dsh?.verified }); ++ const message = error || (phase === "failed" ? update.error : ""); ++ const dshMessage = dshError || (dshPhase === "failed" ? dsh?.error : ""); ++ const dshText = dshPhase === "installed" ? t("Restart DSH to start using DSH {version}.", { version: dsh.installed ?? dsh.verified }) : dsh?.available === false && dsh?.blockedBy ? t("Update FlyBot core {version} first.", { version: dsh.blockedBy }) : t("You have {version}.", { version: dsh?.current }); ++ return (0, import_react18.createElement)( ++ import_react18.Fragment, ++ null, ++ (0, import_react18.createElement)("button", { ++ ref: button, ++ type: "button", ++ className: "bt-round bt-update", ++ "data-phase": cloudPhase, ++ "aria-label": label, ++ title: label, ++ "aria-haspopup": "dialog", ++ "aria-expanded": open, ++ onClick: toggle ++ }, (0, import_react18.createElement)(CloudDownloadIcon, { done: phase === "installed" }), phase === "failed" || dshPhase === "failed" ? (0, import_react18.createElement)("span", { className: "bt-update-dot", "aria-hidden": true }) : null), ++ open && box ? (0, import_react_dom4.createPortal)((0, import_react18.createElement)( ++ "div", ++ { className: "bt-update-pop", role: "dialog", "aria-label": update?.available || phase !== "idle" ? t("FlyBot core update") : t("DSH update"), style: box }, ++ update?.available || phase !== "idle" ? (0, import_react18.createElement)( ++ import_react18.Fragment, ++ null, ++ (0, import_react18.createElement)("div", { className: "bt-update-title" }, phase === "installed" ? t("Update installed") : t("FlyBot core {version}", { version: update.latest })), ++ (0, import_react18.createElement)("div", { className: "bt-update-text" }, phase === "installed" ? t("Restart DSH to start using FlyBot core {version}.", { version: update.installed ?? update.latest }) : t("You have {version}.", { version: update.current })), ++ message ? (0, import_react18.createElement)("div", { className: "bt-update-error", role: "alert" }, t("The update did not finish: {reason}", { reason: message })) : null, ++ (0, import_react18.createElement)( + "div", +- { key: `${reply.botId}-${index}`, className: "bt-event" }, +- (0, import_react6.createElement)( +- "button", +- { type: "button", className: "bt-event-whole", onClick: () => actions.showDetails(sessionId) }, +- (0, import_react6.createElement)(BotMark, { bot, size: 16 }), +- (0, import_react6.createElement)("span", { className: "bt-event-name" }, t(reply.kind === "working" ? "{name} started on it" : "{name} paused for your approval", { name: bot.name })) +- ) +- ); +- } +- if (reply.kind === "stopped") { +- return (0, import_react6.createElement)("div", { key: `${reply.botId}-${index}`, className: "bt-event" }, t("You stopped {name}'s work", { name: bot.name })); +- } +- return (0, import_react6.createElement)(GroupMessage, { key: `${reply.botId}-${index}`, bot, text: reply.text, room: room2, roster, actions, msgKey: callId ? `${sessionId}:${callId}:${index}` : void 0, useUi }); +- }), +- pending.map((id) => { +- const bot = roster.byId[id]; +- if (!bot) return null; +- return (0, import_react6.createElement)( ++ { className: "bt-update-actions" }, ++ update.notesUrl ? (0, import_react18.createElement)("a", { className: "bt-update-notes", href: update.notesUrl, target: "_blank", rel: "noopener noreferrer" }, t("What's new")) : null, ++ phase === "installed" ? null : (0, import_react18.createElement)("button", { ++ type: "button", ++ className: "bt-update-go", ++ disabled: phase === "installing", ++ onClick: start ++ }, phase === "installing" ? t("Updating…") : phase === "failed" ? t("Try again") : t("Update")) ++ ) ++ ) : null, ++ dshRow ? (0, import_react18.createElement)( + "div", +- { key: `pending-${id}`, className: "bt-activity" }, +- (0, import_react6.createElement)(BotMark, { bot, size: 22, state: speakerTool ? toolState(speakerTool) : "thinking" }), +- (0, import_react6.createElement)(Shimmer, null, speakerTool ? `${bot.name} · ${workPhrase(speakerTool)}…` : t("{name} is typing…", { name: bot.name })) +- ); +- }) ++ { className: "bt-update-dsh" }, ++ (0, import_react18.createElement)("div", { className: "bt-update-title" }, t("DSH {version}", { version: dsh.verified })), ++ (0, import_react18.createElement)("div", { className: "bt-update-text" }, dshText), ++ dshMessage ? (0, import_react18.createElement)("div", { className: "bt-update-error", role: "alert" }, t("The update did not finish: {reason}", { reason: dshMessage })) : null, ++ (0, import_react18.createElement)( ++ "div", ++ { className: "bt-update-actions" }, ++ dsh.kind === "app" ? dsh.available ? (0, import_react18.createElement)("button", { type: "button", className: "bt-update-go", onClick: () => void actions.openAppUpdates(dsh.verified).catch(() => { ++ }) }, t("Open updates")) : null : !dsh.available && dshPhase === "idle" ? null : dshPhase === "installed" ? null : (0, import_react18.createElement)("button", { ++ type: "button", ++ className: "bt-update-go", ++ disabled: dshPhase === "installing", ++ onClick: startDsh ++ }, dshPhase === "installing" ? t("Updating…") : dshPhase === "failed" ? t("Try again") : t("Update")) ++ ) ++ ) : null ++ ), document.body) : null + ); } --function TeamSidebar(props) { -- const { wide, useRoster, useUi, useSessions, actions } = props; -- const listRef = (0, import_react8.useRef)(null); -- const roster = useRoster((value) => value); -- const markedUnread = useUi((value) => value.unread); -- props.useRegistry((value) => value); -- const updated = useSessions((list) => { -- const stamps = {}; -- for (const id of list.ids) stamps[id] = list.byId[id]?.updatedAt ?? 0; -- return JSON.stringify(stamps); -+function projectBoardView({ project, rows, done, canManage, tasks, decisions, meetings, queues, metrics } = {}) { -+ const isRow = (row) => row !== null && typeof row === "object" && text(row.id) !== ""; -+ const byId = new Map((Array.isArray(tasks) ? tasks : []).filter((task) => task !== null && typeof task === "object" && text(task.id) !== "").map((task) => [text(task.id), task])); -+ const list = []; -+ const archived = []; -+ const seen = /* @__PURE__ */ new Set(); -+ for (const row of [...Array.isArray(done) ? done : [], ...Array.isArray(rows) ? rows : []]) { -+ if (!isRow(row)) continue; -+ const id = text(row.id); -+ if (seen.has(id)) continue; -+ seen.add(id); -+ const column = text(row.column) || "not-started"; -+ (column === DONE_COLUMN ? archived : list).push(row); -+ } -+ const projectRow = (row) => { -+ const task = byId.get(text(row.id)); -+ return { -+ id: text(row.id), -+ key: `task:${text(row.id)}`, -+ column: text(row.column) || "not-started", -+ assignee: text(row.assignee) || null, -+ runId: text(row.runId) || null, -+ nodeId: text(row.nodeId) || null, -+ title: text(row.title) || text(task?.title) || text(task?.note) || null, -+ priority: text(row.priority) || text(task?.priority) || null, -+ audit: text(row.audit) || null, -+ // #1831: who is doing the card, and who is speaking for whom. Both are host truth; -+ // a card carrying neither renders exactly as before. -+ holder: text(row.holder) || null, -+ speaksFor: text(row.speaksFor) || null, -+ board: boardOfRow(row, task) -+ }; +-var DRAWN = /* @__PURE__ */ new Set(["message_bot", "stop_bot", "create_bot", "update_bot", "group_relay", "create_group", "update_group", "delete_group", "post_to_group", "ask_user", "request_secret", "ask_user_question", "read_group_chat", "remember", "forget"]); +-var CARDS = /* @__PURE__ */ new Set(["ask_user", "request_secret"]); +-function callTree(root) { +- const calls = []; +- const seen = /* @__PURE__ */ new Set(); +- const visit = (call2) => { +- const id = call2.callId ?? call2; +- if (seen.has(id)) return; +- seen.add(id); +- calls.push(call2); +- for (const child of call2.subCalls ?? []) visit(child); +- }; +- visit(root); +- return calls; ++function ConnectApps({ wide, actions }) { ++ if (!wide) return null; ++ return (0, import_react18.createElement)( ++ "span", ++ { className: "bt-connect-wrap" }, ++ (0, import_react18.createElement)( ++ "button", ++ { type: "button", className: "bt-connect bt-connect-foot", onClick: () => actions.openConnectors() }, ++ (0, import_react18.createElement)(PlugIcon), ++ t("Connect apps") ++ ) ++ ); + } +-function useShownCards(actions, sessionId, callIds) { +- const key = JSON.stringify(callIds); +- (0, import_react6.useLayoutEffect)(() => { +- const ids = JSON.parse(key); +- if (ids.length === 0 || !actions.noteCards) return void 0; +- actions.noteCards(sessionId, ids, 1); +- return () => actions.noteCards(sessionId, ids, -1); +- }, [sessionId, key]); ++function YouAvatar() { ++ return (0, import_react18.createElement)( ++ "span", ++ { className: "bt-you-wrap" }, ++ (0, import_react18.createElement)("span", { className: "bt-you", "aria-hidden": true }, (0, import_react18.createElement)(BotMark, { look: MAIN_LOOK, size: 24 })), ++ (0, import_react18.createElement)("span", { style: { position: "absolute", width: 1, height: 1, overflow: "hidden", clip: "rect(0 0 0 0)" } }, t("Settings")) ++ ); + } +-function renderCall(call2, { sessionId, roster, actions, useUi, useWork, findBot }) { +- const tool = toolName(call2); +- const done = call2.kind === "tool-result"; +- const { text: arg, value: argValue } = toolArgs(call2); +- const self = roster.byId[sessionId]; +- switch (tool) { +- case "message_bot": { +- const target = findBot(arg("to")) ?? { name: arg("to") ?? "…", color: "gray" }; +- if (done && call2.isError) return (0, import_react6.createElement)("div", { className: "bt-event" }, t("Couldn't message {name}", { name: target.name })); +- return (0, import_react6.createElement)(EventLine, { prefix: t("Messaged"), bots: [target], onOpen: (bot) => bot.id && actions.openExchange(self?.id ?? sessionId, bot.id), whole: true }); +- } +- case "stop_bot": { +- const target = findBot(arg("bot")) ?? { name: arg("bot") ?? "…", color: "gray" }; +- if (done && call2.isError) return (0, import_react6.createElement)("div", { className: "bt-event" }, t("Couldn't stop {name}", { name: target.name })); +- return (0, import_react6.createElement)(EventLine, { prefix: t("Stopped"), bots: [target], onOpen: (bot) => bot.id && actions.openExchange(self?.id ?? sessionId, bot.id), whole: true }); +- } +- case "create_bot": { +- const created = findBot(arg("name")); +- if (!done || created === void 0) return null; +- return (0, import_react6.createElement)(EventLine, { prefix: t("Created"), bots: [created], onOpen: (bot) => actions.openSession(bot.id) }); +- } +- case "update_bot": { +- const target = findBot(arg("name")) ?? findBot(arg("bot")) ?? (arg("bot") ? void 0 : self); +- return done && target ? (0, import_react6.createElement)(EventLine, { prefix: t("Updated"), bots: [target], onOpen: (bot) => actions.openSession(bot.id) }) : null; +- } +- case "group_relay": +- return (0, import_react6.createElement)(GroupReplies, { sessionId, callId: call2.callId, done, content: call2.content, roster, actions, useUi, useWork }); +- case "create_group": +- case "update_group": +- case "delete_group": +- case "post_to_group": { +- const verbs = { +- create_group: ["Created group", "Couldn't create the group"], +- update_group: ["Updated group", "Couldn't update the group"], +- delete_group: ["Deleted group", "Couldn't delete the group"], +- post_to_group: ["Posted in", "Couldn't post in the group"] +- }[tool]; +- if (!done) return null; +- const result = contentText(call2.content); +- const named = /^(Created group|Updated|Nothing changed in|Deleted|Posted in) "([^"]+)"/.exec(result); +- if (call2.isError || !named) return (0, import_react6.createElement)("div", { className: "bt-event", title: result }, t(verbs[1])); +- const name = named[2]; +- const room2 = roster.rooms.find((entry) => entry.name === name); +- return (0, import_react6.createElement)(GroupEvent, { prefix: t(verbs[0]), room: tool === "delete_group" ? void 0 : room2, name, roster, actions }); +- } +- case "ask_user": { +- const question = roster.questions?.[sessionId]; +- if (!question || question.callId !== call2.callId) return (0, import_react6.createElement)("span", { className: "bt-q-gone", hidden: true }); +- return (0, import_react6.createElement)(PendingQuestion, { sessionId, question, actions, inline: true }); +- } +- case "request_secret": +- return (0, import_react6.createElement)(SecretToolCell, { sessionId, callId: call2.callId, name: arg("name"), done, content: call2.content, roster, actions }); +- case "ask_user_question": { +- if (!done) return null; +- let parsed = null; +- try { +- parsed = JSON.parse(contentText(call2.content)); +- } catch { +- parsed = null; +- } +- const answer = parsed?.answers?.[0]; +- const question = argValue("questions")?.[0]; +- if (!answer) return null; +- return (0, import_react6.createElement)("div", { className: "bt-q-inline" }, (0, import_react6.createElement)(SettledQuestion, { question, answer })); +- } +- case "read_group_chat": { +- if (!done) return null; +- const room2 = roster.rooms.find((entry) => entry.name === arg("group")); +- return room2 ? (0, import_react6.createElement)(GroupEvent, { prefix: t("Read"), room: room2, roster, actions }) : null; ++var openShellSettings = null; ++var openShellOnboarding = null; ++var shellShortcuts = null; ++function holdShellShortcuts(shortcuts) { ++ shellShortcuts = shortcuts; ++ return () => { ++ if (shellShortcuts === shortcuts) shellShortcuts = null; + }; -+ const groups = PROJECT_SOURCES.map((source) => ({ -+ source, -+ label: SOURCE_LABELS[source], -+ rows: list.filter((row) => row.source === source).map(projectRow) -+ })).filter((group) => group.rows.length > 0); -+ const countOf = (value) => value === void 0 || value === null ? null : value; -+ const queueItems = (items, annotate) => (Array.isArray(items) ? items : []).filter((item) => item !== null && typeof item === "object" && text(item.id) !== "").map((item) => { -+ const id = text(item.id); -+ const source = text([...list, ...archived].find((row) => text(row.id) === id)?.source); -+ const task = byId.get(id); -+ return { id, key: `task:${id}`, title: text(task?.title) || text(task?.note) || null, priority: text(task?.priority) || null, board: boardOfRow({ source }, task), ...annotate(item) }; - }); -- const order = (0, import_react8.useMemo)(() => { -- const stamps = JSON.parse(updated); -- const entries2 = [ -- ...roster.bots.filter((bot) => bot.id !== roster.mainBotId).map((bot) => ({ kind: "bot", item: bot, at: stamps[currentPart(bot)] ?? bot.createdAt })), -- ...roster.rooms.map((room2) => ({ kind: "room", item: room2, at: stamps[room2.id] ?? room2.createdAt })), -- // The Agent sorts by its most recent Session, falling back to when it was added. -- ...(roster.agents ?? []).map((agent) => ({ kind: "agent", item: agent, at: Math.max(0, ...agent.sessions.map((id) => stamps[id] ?? 0)) || agent.createdAt })) -- ]; -- return entries2.sort((a, b) => Number(Boolean(b.item.pinned)) - Number(Boolean(a.item.pinned)) || b.at - a.at); -- }, [roster, updated]); -- const main = roster.byId[roster.mainBotId]; -- useListGlide(listRef, wide, order.map((entry) => entry.item.id).join(",")); -- useLandOnMainBot(roster, useSessions, actions); -- const rowProps = { roster, actions, useSessions, useUi, useSessionStatus: props.useSessionStatus, useSessionRetainInfo: props.useSessionRetainInfo, useActivity: props.useActivity, useWork: props.useWork, wide }; -- const typeToSearch = (event) => { -- if (event.defaultPrevented || event.key.length !== 1 || event.key === " " || event.metaKey || event.ctrlKey || event.altKey || event.nativeEvent.isComposing) return; -- if (event.target instanceof Element && event.target.closest("input,textarea,[contenteditable]")) return; -- event.preventDefault(); -- actions.openPalette(event.key); -+ const waiting = queueItems(queues?.waiting, (item) => ({ role: text(item.role) || null, waitedMs: Number.isFinite(item.waitedMs) ? item.waitedMs : null })); -+ const inflight = queueItems(queues?.inflight, (item) => ({ holder: text(item.holder) || null, state: item.stalled === true ? "stalled" : "running" })); -+ const blocked = queueItems(queues?.blocked, (item) => ({ reason: text(item.reason) || null, waitedMs: Number.isFinite(item.waitedMs) ? item.waitedMs : null })); -+ const queueGroups = [ -+ { group: "waiting", label: "Waiting", items: waiting }, -+ { group: "inflight", label: "In flight", items: inflight }, -+ { group: "blocked", label: "Blocked", items: blocked } -+ ].filter((entry) => entry.items.length > 0); -+ const rawCounts = queues?.counts ?? null; -+ return { -+ projectId: text(project?.id) || null, -+ title: text(project?.name) || "Project", -+ canManage: canManage === true, -+ groups, -+ // #1855: one group, closed until the reader opens it. Rows keep the row shape above, so a -+ // finished card is still a card — only its place on the board changed. -+ done: { group: "done", label: "Done cards", collapsed: true, rows: archived.map(projectRow) }, -+ queues: queueGroups, -+ counts: rawCounts === null ? null : { waiting: countOf(rawCounts.waiting), inflight: countOf(rawCounts.inflight), blocked: countOf(rawCounts.blocked), done: countOf(rawCounts.done) }, -+ count: list.length, -+ empty: list.length === 0 && queueGroups.length === 0 && archived.length === 0, -+ emptyText: "No cards yet.", -+ record: projectBoardRecord({ decisions, meetings }), -+ metrics: metricsView(metrics, { tasks }) - }; -- return (0, import_react8.createElement)( +} -+function projectRowNode(row, onOpen) { -+ const open = row.board === null ? void 0 : () => onOpen?.(row.board); -+ return (0, import_react19.createElement)( - "div", -- { className: wide ? "bt-side" : "bt-side bt-rail", onKeyDown: typeToSearch }, -- wide ? (0, import_react8.createElement)( -- "div", -- { className: "bt-side-top" }, -- (0, import_react8.createElement)("span", { className: "bt-brand" }, (0, import_react8.createElement)(BrandMark, { size: 22 }), (0, import_react8.createElement)(BrandName)), -- (0, import_react8.createElement)(UpdateButton, { useUpdate: props.useUpdate, actions }), -- (0, import_react8.createElement)("button", { type: "button", className: "bt-round", "aria-label": t("Search"), title: t("Search"), onClick: () => actions.openPalette() }, (0, import_react8.createElement)(SearchIcon)), -- (0, import_react8.createElement)("button", { type: "button", className: "bt-round", "aria-label": t("New…"), title: t("New…"), onClick: () => actions.openNewChat("new") }, (0, import_react8.createElement)(PlusIcon)) -- ) : (0, import_react8.createElement)( -- import_react8.Fragment, -- null, -- (0, import_react8.createElement)(UpdateButton, { useUpdate: props.useUpdate, actions }), -- (0, import_react8.createElement)("button", { type: "button", className: "bt-round", "aria-label": t("New…"), title: t("New…"), onClick: () => actions.openNewChat("new") }, (0, import_react8.createElement)(PlusIcon)) -+ { -+ key: row.key, -+ className: "bt-project-row", -+ "data-task-id": row.id, -+ "data-column": row.column, -+ "data-priority": row.priority ?? void 0, -+ role: open === void 0 ? void 0 : "button", -+ tabIndex: open === void 0 ? void 0 : 0, -+ onClick: open, -+ onKeyDown: open === void 0 ? void 0 : (event) => { -+ if (event.key === "Enter" || event.key === " ") open(); -+ } -+ }, -+ (0, import_react19.createElement)("span", { className: "bt-project-column" }, t(row.column)), -+ (0, import_react19.createElement)( -+ "span", -+ { className: "bt-project-title-wrap" }, -+ (0, import_react19.createElement)("span", { className: "bt-project-title" }, row.title ?? (row.assignee ? `@${row.assignee}` : "—")), -+ // The audit text the row already carries (#1730): shown inside the card detail, never a -+ // second read. A row without one renders exactly as before. -+ row.audit === null ? null : (0, import_react19.createElement)("span", { className: "bt-project-audit" }, row.audit) - ), -- wide && roster.readOnly ? (0, import_react8.createElement)("div", { className: "bt-hint bt-read-only", role: "status" }, hostText(roster.readOnly)) : null, -- (0, import_react8.createElement)( -- "div", -- { ref: listRef, className: "bt-list", role: "region", "aria-label": t("Bot list") }, -- main ? (0, import_react8.createElement)(BotRow, { ...rowProps, bot: main, marked: markedUnread[main.id] === true }) : roster.ready ? null : (0, import_react8.createElement)("div", { className: "bt-hint" }, t("Setting up your Main Bot…")), -- order.map((entry) => entry.kind === "bot" ? (0, import_react8.createElement)(BotRow, { key: entry.item.id, ...rowProps, bot: entry.item, marked: markedUnread[entry.item.id] === true }) : entry.kind === "room" ? (0, import_react8.createElement)(RoomRow, { key: entry.item.id, ...rowProps, room: entry.item, marked: markedUnread[entry.item.id] === true }) : (0, import_react8.createElement)(AgentRow, { key: entry.item.id, ...rowProps, agent: entry.item, marked: markedUnread[entry.item.id] === true })) -- ) -+ // "Who is doing" is the claim, never the assignee label; a card the Assistant speaks for -+ // says so, so the three identities (Bot, Assistant, user) never read as one. -+ row.holder === null ? null : (0, import_react19.createElement)("span", { className: "bt-project-holder", "data-holder": row.holder }, `@${row.holder}`), -+ row.speaksFor === null ? null : (0, import_react19.createElement)("span", { className: "bt-project-speaks-for", "data-speaks-for": row.speaksFor }, t("for {user}", { user: row.speaksFor })), -+ row.priority === null ? null : (0, import_react19.createElement)("span", { className: "bt-project-priority" }, row.priority), -+ row.runId === null ? null : (0, import_react19.createElement)("span", { className: "bt-project-ref" }, row.nodeId === null ? row.runId : `${row.runId} · ${row.nodeId}`) - ); ++function openHarnessSettings() { ++ requestAnimationFrame(() => { ++ if (openShellSettings) { ++ openShellSettings(); ++ return; + } +- case "remember": +- case "forget": +- return done && !call2.isError ? (0, import_react6.createElement)(MemoryEvent, { sessionId, scope: arg("scope"), result: contentText(call2.content), roster, actions, findBot }) : null; +- default: +- return null; +- } ++ const desktop = document.querySelector('button[aria-haspopup="menu"][data-signed-out]'); ++ if (desktop) openThroughMenu(desktop); ++ else pressSettingsShortcut(); ++ }); } --function UpdateButton({ useUpdate, actions }) { -- const update = useUpdate((value) => value); -- const [open, setOpen] = (0, import_react8.useState)(false); -- const [box, setBox] = (0, import_react8.useState)(null); -- const [error, setError] = (0, import_react8.useState)(""); -- const [dshError, setDshError] = (0, import_react8.useState)(""); -- const button = (0, import_react8.useRef)(null); -- (0, import_react8.useEffect)(() => { -- if (!open) return void 0; -- const onDown = (event) => { -- if (event.target instanceof Element && (event.target.closest(".bt-update-pop") || button.current?.contains(event.target))) return; -- setOpen(false); -- }; -- const onKey = (event) => { -- if (event.key === "Escape") { -- event.stopPropagation(); -- setOpen(false); -- button.current?.focus(); +-function ToolCell({ node, sessionId, useRoster, useUi, useWork, actions }) { +- const roster = useRoster((value) => value); +- const root = node.data.root; +- const turn = node.location?.turn?.turn ?? root.turn; +- const hidden = isHiddenTurn(roster, sessionId, turn); +- const findBot = (nameOrId) => roster.byId[nameOrId] ?? roster.bots.find((bot) => bot.name.toLowerCase() === String(nameOrId ?? "").toLowerCase()); +- const calls = hidden ? [] : callTree(root); +- const drawn = calls.filter((call2) => DRAWN.has(toolName(call2))); +- const running = root.kind === "tool-result" ? void 0 : calls.findLast((call2) => call2.kind !== "tool-result"); +- const note = running ? toolActivity(toolName(running), toolArgs(running).text, findBot, roster.byId[sessionId]) : null; +- useLiveNote(actions, sessionId, `tool:${root.callId ?? node.key}`, note); +- useShownCards(actions, sessionId, drawn.filter((call2) => CARDS.has(toolName(call2)) && call2.callId).map((call2) => call2.callId)); +- if (drawn.length === 0) return null; +- const env = { sessionId, roster, actions, useUi, useWork, findBot }; +- return (0, import_react6.createElement)(import_react6.Fragment, null, drawn.map((call2, index) => (0, import_react6.createElement)(import_react6.Fragment, { key: call2.callId ?? index }, renderCall(call2, env)))); ++function openHarnessOnboarding(id) { ++ requestAnimationFrame(() => { ++ if (openShellOnboarding) openShellOnboarding(id); ++ else openHarnessSettings(); ++ }); + } +- +-// src/client/activity-row.js +-var import_react7 = require("react"); +-var import_react_dom2 = require("react-dom"); +-var PHRASE_HOLD_MS = 800; +-var drawPhrase = (pool) => t(pool[Math.min(pool.length - 1, Math.floor(Math.random() * pool.length))]); +-function elapsedLabel(ms) { +- const minutes = Math.floor(ms / 6e4); +- if (minutes < 60) return `${Math.max(1, minutes)}m`; +- const hours = Math.floor(minutes / 60); +- return minutes % 60 === 0 ? `${hours}h` : `${hours}h ${minutes % 60}m`; ++var MODELS_STEP_ID = "flybot-models"; ++var MODELS_STEP_ORDER = 1e3; ++function ModelsSettingsStep({ explicit, complete, openSection }) { ++ (0, import_react18.useLayoutEffect)(() => { ++ if (explicit) openSection("models"); ++ complete(); ++ }, []); ++ return null; + } +-function useRunningHost(active) { +- const [host, setHost] = (0, import_react7.useState)(null); +- (0, import_react7.useEffect)(() => { +- if (!active) { +- setHost(null); +- return void 0; +- } +- let frame = 0; +- const find = () => { +- frame = 0; +- const node = document.querySelector("[data-chat-flow] > [data-chat-running]"); +- setHost((prev) => prev === node ? prev : node); ++function openHarnessModels() { ++ openHarnessOnboarding(MODELS_STEP_ID); ++} ++function openThroughMenu(trigger) { ++ const before = new Set(document.querySelectorAll('[role="menu"]')); ++ trigger.click(); ++ let frames = 0; ++ const look = () => { ++ const menu = [...document.querySelectorAll('[role="menu"]')].find((node) => !before.has(node)); ++ const item = menu?.querySelector('button[role="menuitem"]'); ++ if (item) item.click(); ++ else if (++frames < 30) requestAnimationFrame(look); ++ else pressSettingsShortcut(); ++ }; ++ requestAnimationFrame(look); ++} ++function pressSettingsShortcut() { ++ const binding = shellShortcuts?.catalog.getSnapshot().find((row) => row.id === "settings.open")?.binding; ++ if (!binding) return; ++ const held = new Set(binding.modifiers); ++ window.dispatchEvent(new KeyboardEvent("keydown", { ++ code: binding.code, ++ key: binding.code === "Comma" ? "," : "", ++ bubbles: true, ++ cancelable: true, ++ ctrlKey: held.has("control"), ++ altKey: held.has("alt"), ++ shiftKey: held.has("shift"), ++ metaKey: held.has("meta") ++ })); ++} ++function AccountLauncher({ seat = true, settingsOpen, openSettings, openOnboarding, actions }) { ++ const [open, setOpen] = (0, import_react18.useState)(false); ++ (0, import_react18.useLayoutEffect)(() => { ++ if (!seat) return void 0; ++ actions.seatAccount(true); ++ return () => actions.seatAccount(false); ++ }, [seat]); ++ const [box, setBox] = (0, import_react18.useState)(null); ++ const button = (0, import_react18.useRef)(null); ++ (0, import_react18.useEffect)(() => { ++ if (settingsOpen) setOpen(false); ++ }, [settingsOpen]); ++ (0, import_react18.useEffect)(() => { ++ if (!openSettings) return void 0; ++ openShellSettings = openSettings; ++ return () => { ++ if (openShellSettings === openSettings) openShellSettings = null; + }; +- const observer = new MutationObserver(() => { +- if (!frame) frame = requestAnimationFrame(find); +- }); +- observer.observe(document.querySelector("[data-conversation-scroll]") ?? document.body, { childList: true, subtree: true }); +- find(); ++ }, [openSettings]); ++ (0, import_react18.useEffect)(() => { ++ if (!openOnboarding) return void 0; ++ openShellOnboarding = openOnboarding; + return () => { +- cancelAnimationFrame(frame); +- observer.disconnect(); ++ if (openShellOnboarding === openOnboarding) openShellOnboarding = null; + }; +- }, [active]); +- return host; +-} +-function useRowEntrances(replacing) { +- const check = (0, import_react7.useRef)(replacing); +- check.current = replacing; +- (0, import_react7.useEffect)(() => { +- const seen = /* @__PURE__ */ new WeakSet(); +- const started = Date.now(); +- let column = null; +- let frame = 0; +- const settle = (event) => { +- if (event.target !== event.currentTarget || !/^bt-row/.test(event.animationName)) return; +- delete event.currentTarget.dataset.btEnter; +- event.currentTarget.removeEventListener("animationend", settle); ++ }, [openOnboarding]); ++ (0, import_react18.useEffect)(() => { ++ if (!open) return void 0; ++ const onDown = (event) => { ++ if (event.target instanceof Element && (event.target.closest(".bt-account-menu") || button.current?.contains(event.target))) return; ++ setOpen(false); + }; +- const scan = () => { +- frame = 0; +- const next = document.querySelector("[data-chat-flow]"); +- if (next !== column) { +- observer.disconnect(); +- column = next; +- if (column) observer.observe(column, { childList: true, subtree: true }); - } -- }; -- window.addEventListener("mousedown", onDown); -- window.addEventListener("keydown", onKey, true); -- return () => { -- window.removeEventListener("mousedown", onDown); -- window.removeEventListener("keydown", onKey, true); -+function ProjectBoardRows({ view, onOpen }) { -+ if (view.empty) return (0, import_react19.createElement)("div", { className: "bt-project-empty" }, t(view.emptyText)); -+ return view.groups.map((group) => (0, import_react19.createElement)( -+ "section", -+ { key: group.source, className: "bt-project-group", "data-source": group.source }, -+ (0, import_react19.createElement)("h4", null, t(group.label)), -+ group.rows.map((row) => projectRowNode(row, onOpen)) -+ )); -+} -+function ProjectDoneGroup({ view, onOpen }) { -+ if (view.done.rows.length === 0) return null; -+ return (0, import_react19.createElement)( -+ "details", -+ { className: "bt-project-group bt-project-done", "data-group": "done" }, -+ (0, import_react19.createElement)("summary", { className: "bt-project-done-summary" }, `${t(view.done.label)} · ${view.done.rows.length}`), -+ view.done.rows.map((row) => projectRowNode(row, onOpen)) -+ ); -+} -+function waitedLabel(ms) { -+ if (!Number.isFinite(ms) || ms < 0) return null; -+ if (ms < 6e4) return `${ms === 0 ? 0 : Math.max(1, Math.floor(ms / 1e3))}s`; -+ const minutes = Math.floor(ms / 6e4); -+ if (minutes < 60) return `${minutes}m`; -+ const hours = Math.floor(minutes / 60); -+ if (hours < 24) return `${hours}h`; -+ return `${Math.floor(hours / 24)}d`; -+} -+var METRIC_CARDS = [ -+ { key: "prsOpenedToday", label: "PRs opened today", caption: "Natural day in your time zone, by created_at." }, -+ { key: "prsMergedToday", label: "PRs merged today", caption: "Natural day in your time zone, by merged_at." }, -+ { key: "issuesOpenedToday", label: "Issues opened today", caption: "Natural day in your time zone, by created_at." }, -+ { key: "issuesClosedToday", label: "Issues closed today", caption: "Natural day in your time zone, by closed_at." }, -+ { key: "prMergeMedianMs", label: "PR merge time", caption: "Median of the last 7 days, opened to merged." }, -+ { key: "issueCloseMedianMs", label: "Issue close time", caption: "Median of the last 7 days, opened to closed." }, -+ { key: "taskDoneMedianMs", label: "Task completion", caption: "Median of the last 7 days, created to done." } -+]; -+var METRIC_DASH = "—"; -+var DURATION_KEYS = /* @__PURE__ */ new Set(["prMergeMedianMs", "issueCloseMedianMs", "taskDoneMedianMs"]); -+var RUNNING_OVERDUE_MS = 2 * 60 * 60 * 1e3; -+var metricText = (key, value) => { -+ if (!Number.isFinite(value)) return METRIC_DASH; -+ if (DURATION_KEYS.has(key)) return waitedLabel(value) ?? METRIC_DASH; -+ return String(value); -+}; -+function metricsView(metrics, { tasks } = {}) { -+ const source = metrics !== null && typeof metrics === "object" ? metrics : null; -+ const values = source?.metrics !== null && typeof source?.metrics === "object" ? source.metrics : {}; -+ const byId = new Map((Array.isArray(tasks) ? tasks : []).filter((task) => task !== null && typeof task === "object" && text(task.id) !== "").map((task) => [text(task.id), task])); -+ const running = (Array.isArray(source?.running) ? source.running : []).filter((row) => row !== null && typeof row === "object" && text(row.id) !== "").map((row) => { -+ const id = text(row.id); -+ const task = byId.get(id); -+ return { -+ id, -+ key: "task:" + id, -+ title: text(row.title) || text(task?.title) || text(task?.note) || id, -+ status: text(row.status), -+ who: text(row.who) || null, -+ sinceMs: Number.isFinite(row.sinceMs) ? row.sinceMs : null, -+ source: text(row.source) || null, -+ lastActivityAt: Number.isFinite(row.lastActivityAt) ? row.lastActivityAt : null, -+ overdue: Number.isFinite(row.sinceMs) && row.sinceMs > RUNNING_OVERDUE_MS, -+ board: boardOfRow({ source: text(row.source) }, task) +- if (!column) return; +- const items = [...column.querySelectorAll(":scope > [data-chat-flow-key]")]; +- let last = -1; +- items.forEach((item, index) => { +- if (seen.has(item)) last = index; +- }); +- const fresh = items.filter((item) => !seen.has(item) && item.offsetHeight > 0); +- for (const item of fresh) seen.add(item); +- if (last < 0 || Date.now() - started < 800 || fresh.length > 3) return; +- for (const item of fresh) { +- if (items.indexOf(item) < last) continue; +- const reply = [void 0, "assistant-step", "tool-call"].includes(item.dataset.chatFlowKind); +- item.dataset.btEnter = reply && check.current() ? "after-collapse" : "new"; +- item.addEventListener("animationend", settle); ++ const onKey = (event) => { ++ if (event.key === "Escape") { ++ event.stopPropagation(); ++ setOpen(false); ++ button.current?.focus(); + } }; -- }, [open]); -- const phase = update?.phase ?? "idle"; -- const dsh = update?.dsh; -- const dshPhase = dsh?.phase ?? "idle"; -- const dshRow = showsDshRow(dsh, update?.app); -- if (!update?.available && !dshRow && phase === "idle" && dshPhase === "idle") return null; -- const cloudPhase = phase === "installing" || dshPhase === "installing" ? "installing" : phase !== "idle" ? phase : dshPhase; -- const toggle = () => { -- const rect = button.current?.getBoundingClientRect(); -- if (rect) setBox({ left: Math.max(8, Math.min(rect.left, window.innerWidth - 288)), top: rect.bottom + 6 }); -- setOpen((value) => !value); -- }; -- const start = () => { -- setError(""); -- void actions.installUpdate().catch((failure) => setError(hostText(failure?.message ?? String(failure)))); -- }; -- const startDsh = () => { -- setDshError(""); -- void actions.installDshUpdate().catch((failure) => setDshError(hostText(failure?.message ?? String(failure)))); -+ }); -+ return { -+ available: source?.ok === true, -+ reason: text(source?.reason) || null, -+ cards: METRIC_CARDS.map((card) => ({ ...card, value: metricText(card.key, values[card.key]) })), -+ running, -+ empty: source === null, -+ emptyText: "No metrics read yet." - }; -- const label = phase === "installing" ? t("Updating DS Bot…") : dshPhase === "installing" ? t("Updating DSH…") : phase === "installed" ? t("DS Bot {version} is installed. Restart DSH to use it.", { version: update.installed ?? update.latest }) : dshPhase === "installed" ? t("Restart DSH to start using DSH {version}.", { version: dsh.installed ?? dsh.verified }) : update?.available ? t("DS Bot {version} is available", { version: update.latest }) : t("DSH {version} is available", { version: dsh?.verified }); -- const message = error || (phase === "failed" ? update.error : ""); -- const dshMessage = dshError || (dshPhase === "failed" ? dsh?.error : ""); -- const dshText = dshPhase === "installed" ? t("Restart DSH to start using DSH {version}.", { version: dsh.installed ?? dsh.verified }) : dsh?.available === false && dsh?.blockedBy ? t("Update DS Bot {version} first.", { version: dsh.blockedBy }) : t("You have {version}.", { version: dsh?.current }); -- return (0, import_react8.createElement)( -- import_react8.Fragment, -- null, -- (0, import_react8.createElement)("button", { -- ref: button, -- type: "button", -- className: "bt-round bt-update", -- "data-phase": cloudPhase, -- "aria-label": label, -- title: label, -- "aria-haspopup": "dialog", -- "aria-expanded": open, -- onClick: toggle -- }, (0, import_react8.createElement)(CloudDownloadIcon, { done: phase === "installed" }), phase === "failed" || dshPhase === "failed" ? (0, import_react8.createElement)("span", { className: "bt-update-dot", "aria-hidden": true }) : null), -- open && box ? (0, import_react_dom3.createPortal)((0, import_react8.createElement)( -+} -+function ProjectMetrics({ view }) { -+ return (0, import_react19.createElement)( -+ "section", -+ { className: "bt-project-metrics", "data-available": view.available ? "true" : "false" }, -+ view.available || view.reason === null ? null : (0, import_react19.createElement)("p", { className: "bt-project-metrics-note" }, t("Metrics unavailable") + ": " + view.reason), -+ (0, import_react19.createElement)( - "div", -- { className: "bt-update-pop", role: "dialog", "aria-label": update?.available || phase !== "idle" ? t("DS Bot update") : t("DSH update"), style: box }, -- update?.available || phase !== "idle" ? (0, import_react8.createElement)( -- import_react8.Fragment, -- null, -- (0, import_react8.createElement)("div", { className: "bt-update-title" }, phase === "installed" ? t("Update installed") : t("DS Bot {version}", { version: update.latest })), -- (0, import_react8.createElement)("div", { className: "bt-update-text" }, phase === "installed" ? t("Restart DSH to start using DS Bot {version}.", { version: update.installed ?? update.latest }) : t("You have {version}.", { version: update.current })), -- message ? (0, import_react8.createElement)("div", { className: "bt-update-error", role: "alert" }, t("The update did not finish: {reason}", { reason: message })) : null, -- (0, import_react8.createElement)( -- "div", -- { className: "bt-update-actions" }, -- update.notesUrl ? (0, import_react8.createElement)("a", { className: "bt-update-notes", href: update.notesUrl, target: "_blank", rel: "noopener noreferrer" }, t("What's new")) : null, -- phase === "installed" ? null : (0, import_react8.createElement)("button", { -- type: "button", -- className: "bt-update-go", -- disabled: phase === "installing", -- onClick: start -- }, phase === "installing" ? t("Updating…") : phase === "failed" ? t("Try again") : t("Update")) -- ) -- ) : null, -- dshRow ? (0, import_react8.createElement)( -+ { className: "bt-project-metric-cards" }, -+ view.cards.map((card) => (0, import_react19.createElement)( - "div", -- { className: "bt-update-dsh" }, -- (0, import_react8.createElement)("div", { className: "bt-update-title" }, t("DSH {version}", { version: dsh.verified })), -- (0, import_react8.createElement)("div", { className: "bt-update-text" }, dshText), -- dshMessage ? (0, import_react8.createElement)("div", { className: "bt-update-error", role: "alert" }, t("The update did not finish: {reason}", { reason: dshMessage })) : null, -- (0, import_react8.createElement)( -- "div", -- { className: "bt-update-actions" }, -- dsh.kind === "app" ? dsh.available ? (0, import_react8.createElement)("button", { type: "button", className: "bt-update-go", onClick: () => void actions.openAppUpdates(dsh.verified).catch(() => { -- }) }, t("Open updates")) : null : !dsh.available && dshPhase === "idle" ? null : dshPhase === "installed" ? null : (0, import_react8.createElement)("button", { -- type: "button", -- className: "bt-update-go", -- disabled: dshPhase === "installing", -- onClick: startDsh -- }, dshPhase === "installing" ? t("Updating…") : dshPhase === "failed" ? t("Try again") : t("Update")) -- ) -- ) : null -- ), document.body) : null -+ { key: card.key, className: "bt-project-metric", "data-metric": card.key }, -+ (0, import_react19.createElement)("span", { className: "bt-project-metric-value" }, card.value), -+ (0, import_react19.createElement)("span", { className: "bt-project-metric-label" }, t(card.label)), -+ (0, import_react19.createElement)("span", { className: "bt-project-metric-caption" }, t(card.caption)) -+ )) -+ ) - ); - } --function ConnectPlugins({ wide, actions }) { -- const tipId = (0, import_react8.useId)(); -- const [tip, setTip] = (0, import_react8.useState)(false); -- const timer = (0, import_react8.useRef)(0); -- const button = (0, import_react8.useRef)(null); -- const [box, setBox] = (0, import_react8.useState)(null); -- (0, import_react8.useEffect)(() => () => clearTimeout(timer.current), []); -- if (!wide) return null; -- const show = () => { -- clearTimeout(timer.current); -- timer.current = setTimeout(() => { -- const rect = button.current?.getBoundingClientRect(); -- if (!rect) return; -- const left = Math.max(8, Math.min(rect.left, window.innerWidth - 268)); -- setBox({ left, bottom: window.innerHeight - rect.top + 10 }); -- setTip(true); -- }, 250); -+function ProjectRunningTable({ view, onOpen }) { -+ if (view.running.length === 0) return (0, import_react19.createElement)("section", { className: "bt-project-running", "data-running": "0" }, (0, import_react19.createElement)("h4", null, t("Running tasks")), (0, import_react19.createElement)("div", { className: "bt-project-empty" }, t("Nothing is running."))); -+ return (0, import_react19.createElement)( -+ "section", -+ { className: "bt-project-running", "data-running": String(view.running.length) }, -+ (0, import_react19.createElement)("h4", null, t("Running tasks")), -+ (0, import_react19.createElement)( -+ "div", -+ { className: "bt-project-running-head" }, -+ (0, import_react19.createElement)("span", null, t("Task")), -+ (0, import_react19.createElement)("span", null, t("Status")), -+ (0, import_react19.createElement)("span", null, t("Who")), -+ (0, import_react19.createElement)("span", null, t("Claimed for")), -+ (0, import_react19.createElement)("span", null, t("Source")), -+ (0, import_react19.createElement)("span", null, t("Last activity")) +- const observer = new MutationObserver(() => { +- if (!frame) frame = requestAnimationFrame(scan); +- }); +- scan(); ++ window.addEventListener("mousedown", onDown); ++ window.addEventListener("keydown", onKey, true); + return () => { +- cancelAnimationFrame(frame); +- observer.disconnect(); ++ window.removeEventListener("mousedown", onDown); ++ window.removeEventListener("keydown", onKey, true); + }; +- }, []); ++ }, [open]); ++ const toggle = () => { ++ const rect = button.current?.getBoundingClientRect(); ++ if (rect) setBox({ left: rect.left, bottom: window.innerHeight - rect.top + 5 }); ++ setOpen((value) => !value); ++ }; ++ const groups = [ ++ [ ++ [t("Usage"), GaugeIcon, () => actions.openSettings("usage")], ++ [t("Connect apps"), ConnectorIcon, () => actions.openConnectors()], ++ [t("Plugins"), PlugIcon, () => actions.selectPanel("plugins")], ++ [t("Scheduled tasks"), ClockIcon, () => actions.openSettings("schedules")], ++ [t("Settings"), GearIcon, () => actions.openSettings("general")] ++ ], ++ [ ++ [t("Switch to classic Agent mode"), SwitchIcon, () => actions.setSurface("agent")] ++ ] ++ ]; ++ return (0, import_react18.createElement)( ++ import_react18.Fragment, ++ null, ++ (0, import_react18.createElement)( ++ "button", ++ { ref: button, type: "button", className: "bt-account", "aria-label": t("Account"), "aria-haspopup": "menu", "aria-expanded": open, onClick: toggle }, ++ (0, import_react18.createElement)("span", { className: "bt-you", "aria-hidden": true }, (0, import_react18.createElement)(BotMark, { look: MAIN_LOOK, size: 24 })) + ), -+ view.running.map((row) => { -+ const open = row.board === null ? void 0 : () => onOpen?.(row.board); -+ return (0, import_react19.createElement)( -+ "div", -+ { -+ key: row.key, -+ className: "bt-project-running-row", -+ "data-task-id": row.id, -+ "data-overdue": row.overdue ? "true" : "false", -+ role: open === void 0 ? void 0 : "button", -+ tabIndex: open === void 0 ? void 0 : 0, -+ onClick: open -+ }, -+ (0, import_react19.createElement)("span", { className: "bt-project-running-title" }, row.title), -+ (0, import_react19.createElement)("span", { className: "bt-project-running-status" }, t(row.status)), -+ (0, import_react19.createElement)("span", null, row.who ?? METRIC_DASH), -+ (0, import_react19.createElement)("span", null, metricText("since", row.sinceMs)), -+ (0, import_react19.createElement)("span", null, row.source === null ? METRIC_DASH : t(SOURCE_LABELS[row.source] ?? row.source)), -+ (0, import_react19.createElement)("span", null, row.lastActivityAt === null ? METRIC_DASH : new Date(row.lastActivityAt).toLocaleString(dateLocale())) -+ ); -+ }) -+ ); -+} -+function ProjectQueueGroups({ view, onOpen }) { -+ return view.queues.map((entry) => (0, import_react19.createElement)( -+ "section", -+ { key: `queue:${entry.group}`, className: "bt-project-group", "data-queue": entry.group }, -+ (0, import_react19.createElement)("h4", null, t(entry.label)), -+ entry.items.map((row) => { -+ const open = row.board === null ? void 0 : () => onOpen?.(row.board); -+ const note = entry.group === "waiting" ? (0, import_react19.createElement)("span", { className: "bt-project-wait" }, [row.role === null ? null : (0, import_react19.createElement)("span", { key: "role", className: "bt-project-role" }, row.role), waitedLabel(row.waitedMs) === null ? null : ` · ${waitedLabel(row.waitedMs)}`]) : entry.group === "inflight" ? (0, import_react19.createElement)("span", { className: "bt-project-state" }, row.holder === null ? t(row.state) : `${row.holder} · ${t(row.state)}`) : (0, import_react19.createElement)("span", { className: "bt-project-reason" }, row.reason === null ? null : waitedLabel(row.waitedMs) === null ? row.reason : `${row.reason} · ${waitedLabel(row.waitedMs)}`); -+ return (0, import_react19.createElement)( -+ "div", -+ { -+ key: row.key, -+ className: "bt-project-row", -+ "data-task-id": row.id, -+ "data-state": row.state ?? void 0, -+ role: open === void 0 ? void 0 : "button", -+ tabIndex: open === void 0 ? void 0 : 0, -+ onClick: open, -+ onKeyDown: open === void 0 ? void 0 : (event) => { -+ if (event.key === "Enter" || event.key === " ") open(); ++ open && box ? (0, import_react_dom4.createPortal)((0, import_react18.createElement)( ++ "div", ++ { className: "bt-menu bt-menu-up bt-account-menu", role: "menu", "aria-label": t("Account"), style: box }, ++ (0, import_react18.createElement)(FlyBotAccountHeader, { actions }), ++ groups.map((group, index) => (0, import_react18.createElement)( ++ import_react18.Fragment, ++ { key: index }, ++ index > 0 ? (0, import_react18.createElement)("div", { className: "bt-menu-sep", role: "separator" }) : null, ++ group.map(([label, Icon, run, hint]) => (0, import_react18.createElement)("button", { ++ key: label, ++ type: "button", ++ role: "menuitem", ++ onClick: () => { ++ setOpen(false); ++ void Promise.resolve(run()).catch((error) => actions.reportError(error, "menu")); + } -+ }, -+ (0, import_react19.createElement)( -+ "span", -+ { className: "bt-project-title-wrap" }, -+ (0, import_react19.createElement)("span", { className: "bt-project-title" }, row.title ?? `#${row.id}`) -+ ), -+ note -+ ); -+ }) -+ )); -+} -+var countText = (value) => value === null ? t("unknown") : String(value); -+function ProjectBoardRecord({ record, projectId, call: call4 }) { -+ const { decisions, meetings } = record; -+ const [open, setOpen] = (0, import_react19.useState)(null); -+ const toggle = (path) => { -+ if (open !== null && open.path === path) { -+ setOpen(null); -+ return; -+ } -+ if (typeof call4 !== "function") return; -+ setOpen({ path, answer: null }); -+ const settle = (answer) => setOpen((current) => current !== null && current.path === path ? { path, answer } : current); -+ call4("project-decision", { id: projectId, path }).then(settle, (error) => settle({ ok: false, reason: String(error?.message ?? error) })); - }; -- const hide = () => { -- clearTimeout(timer.current); -- setTip(false); -+ const decisionBody = (file) => { -+ if (open === null || open.path !== file.path) return null; -+ const answer = open.answer; -+ if (answer === null) return (0, import_react19.createElement)("div", { className: "bt-project-decision-text", "data-open-decision": file.path }, t("Reading the decision…")); -+ if (answer.ok !== true) return (0, import_react19.createElement)("div", { className: "bt-project-decision-text bt-project-decision-refusal", "data-open-decision": file.path, "data-refused": "true" }, text(answer.reason) || t("not listed")); -+ return (0, import_react19.createElement)("pre", { className: "bt-project-decision-text", "data-open-decision": file.path }, typeof answer.text === "string" ? answer.text : ""); - }; -- return (0, import_react8.createElement)( -- "span", -- { className: "bt-connect-wrap" }, -- (0, import_react8.createElement)( -+ const decisionRows = decisions.files.map((file) => (0, import_react19.createElement)( -+ "div", -+ { key: file.key, className: "bt-project-decision-wrap" }, -+ (0, import_react19.createElement)( - "button", - { -- ref: button, - type: "button", -- className: "bt-connect bt-connect-foot", -- "aria-describedby": tip ? tipId : void 0, -- onMouseEnter: show, -- onMouseLeave: hide, -- onFocus: show, -- onBlur: hide, -- onClick: () => actions.selectPanel("plugins") -+ className: "bt-project-decision", -+ "data-path": file.path, -+ "data-sha256": file.sha256, -+ "data-open": open !== null && open.path === file.path ? "true" : void 0, -+ "aria-expanded": open !== null && open.path === file.path ? "true" : "false", -+ onClick: () => toggle(file.path) - }, -- (0, import_react8.createElement)(PlugIcon), -- t("Connect plugins"), -- (0, import_react8.createElement)("span", { className: "bt-connect-warn", "aria-hidden": true }, "!") -+ (0, import_react19.createElement)("span", { className: "bt-project-date" }, file.date), -+ (0, import_react19.createElement)("span", { className: "bt-project-title" }, file.title === "" ? file.name : file.title), -+ (0, import_react19.createElement)("span", { className: "bt-project-hash", title: file.sha256 }, file.shortHash) - ), -- tip && box ? (0, import_react_dom3.createPortal)((0, import_react8.createElement)( -- "span", -- { role: "tooltip", id: tipId, className: "bt-connect-tip", style: box }, -- t("Plugin compatibility is not fully verified.") -- ), document.body) : null -- ); --} --function YouAvatar() { -- return (0, import_react8.createElement)( -- "span", -- { className: "bt-you-wrap" }, -- (0, import_react8.createElement)("span", { className: "bt-you", "aria-hidden": true }, (0, import_react8.createElement)(BotMark, { look: MAIN_LOOK, size: 24 })), -- (0, import_react8.createElement)("span", { style: { position: "absolute", width: 1, height: 1, overflow: "hidden", clip: "rect(0 0 0 0)" } }, t("Settings")) -+ decisionBody(file) -+ )); -+ const recipientText = (list) => list.length === 0 ? null : list.map((name) => `@${name}`).join(", "); -+ const proxyText = (meeting) => meeting.participants.filter((participant) => participant.speaksFor !== null).map((participant) => `${participant.name} ${t("for {user}", { user: participant.speaksFor })}`).join(", "); -+ const proxyRow = (meeting) => proxyText(meeting) === "" ? null : (0, import_react19.createElement)("span", { className: "bt-project-speaks-for", "data-speaks-for": meeting.participants.filter((p) => p.speaksFor !== null).map((p) => p.speaksFor).join(", ") }, proxyText(meeting)); -+ const levelOf = (meeting) => (0, import_react19.createElement)("span", { className: "bt-project-level", "data-level": meeting.level }, t(meeting.level)); -+ const decidedRows = meetings.decided.map((meeting) => (0, import_react19.createElement)( -+ "div", -+ { key: meeting.key, className: "bt-project-meeting", "data-status": "decided" }, -+ (0, import_react19.createElement)("span", { className: "bt-project-date" }, meeting.date), -+ (0, import_react19.createElement)("span", { className: "bt-project-title" }, meeting.question), -+ meeting.owner === null ? null : (0, import_react19.createElement)("span", { className: "bt-project-owner" }, `@${meeting.owner}`), -+ proxyRow(meeting), -+ // A decided meeting whose file is gone still reads as decided; the missing path says so -+ // instead of the row disappearing with the file. -+ meeting.path === "" ? (0, import_react19.createElement)("span", { className: "bt-project-missing" }, "decision file gone") : (0, import_react19.createElement)("span", { className: "bt-project-path" }, meeting.path) -+ )); -+ const pendingRows = meetings.pending.map((meeting) => (0, import_react19.createElement)( -+ "div", -+ { key: meeting.key, className: "bt-project-meeting", "data-status": meeting.status, "data-level": meeting.level }, -+ (0, import_react19.createElement)("span", { className: "bt-project-date" }, meeting.date), -+ (0, import_react19.createElement)("span", { className: "bt-project-title" }, meeting.blockingQuestion === null ? meeting.question : meeting.blockingQuestion), -+ levelOf(meeting), -+ recipientText(meeting.recipients) === null ? null : (0, import_react19.createElement)("span", { className: "bt-project-recipients" }, recipientText(meeting.recipients)), -+ meeting.advance.allowed ? null : (0, import_react19.createElement)("span", { className: "bt-project-paused" }, meeting.advance.reason), -+ proxyRow(meeting), -+ (0, import_react19.createElement)("span", { className: "bt-project-status" }, meeting.status) -+ )); -+ const moreLine = (hidden, key) => hidden > 0 ? (0, import_react19.createElement)("div", { key, className: "bt-project-more" }, t("{count} more not shown", { count: hidden })) : null; -+ return (0, import_react19.createElement)( -+ "section", -+ { -+ className: "bt-project-record", -+ // `data-decisions` stays "rows drawn"; the total rides beside it so the view can be asserted. -+ "data-decisions": String(decisions.count), -+ ...decisions.total === null ? {} : { "data-decisions-total": String(decisions.total) }, -+ "data-meetings": String(meetings.count) -+ }, -+ (0, import_react19.createElement)("h4", null, t("Decisions")), -+ decisions.empty ? (0, import_react19.createElement)("div", { className: "bt-project-empty bt-project-record-empty" }, t(decisions.emptyText)) : decisionRows, -+ moreLine(decisions.hidden, "decisions-more"), -+ (0, import_react19.createElement)("h4", null, t("Meetings")), -+ meetings.empty ? (0, import_react19.createElement)("div", { className: "bt-project-empty bt-project-record-empty" }, t(meetings.emptyText)) : decidedRows, -+ moreLine(meetings.decidedHidden, "decided-more"), -+ meetings.pending.length === 0 ? null : (0, import_react19.createElement)("h5", null, t("Not decided")), -+ meetings.pending.length === 0 ? null : pendingRows, -+ moreLine(meetings.pendingHidden, "pending-more") - ); ++ }, (0, import_react18.createElement)(Icon), (0, import_react18.createElement)("span", { className: "bt-menu-label" }, label), hint ? (0, import_react18.createElement)("span", { className: "bt-menu-hint" }, hint) : null)) ++ )) ++ ), document.body) : null ++ ); } --var openShellSettings = null; --var openShellOnboarding = null; --var shellShortcuts = null; --function holdShellShortcuts(shortcuts) { -- shellShortcuts = shortcuts; -- return () => { -- if (shellShortcuts === shortcuts) shellShortcuts = null; -+var botIdOf = (entry) => entry !== null && typeof entry === "object" ? text(entry.botId) : ""; -+var idList = (value) => Array.isArray(value) ? value.map((id) => text(id)).filter((id) => id !== "") : []; -+function projectTeamView(project) { -+ const stored = project?.roles !== null && typeof project?.roles === "object" ? project.roles : {}; -+ const owner = []; -+ const addOwner = (id) => { -+ const clean = text(id); -+ if (clean !== "" && !owner.includes(clean)) owner.push(clean); - }; --} --function openHarnessSettings() { -- requestAnimationFrame(() => { -- if (openShellSettings) { -- openShellSettings(); -- return; -- } -- const desktop = document.querySelector('button[aria-haspopup="menu"][data-signed-out]'); -- if (desktop) openThroughMenu(desktop); -- else pressSettingsShortcut(); -- }); --} --function openHarnessOnboarding(id) { -- requestAnimationFrame(() => { -- if (openShellOnboarding) openShellOnboarding(id); -- else openHarnessSettings(); -- }); +-function ActivityIndicator({ sessionId, useRoster, useSessionStatus, useLive, useSession, actions }) { +- const roster = useRoster((value) => value); +- const running = useSessionStatus((map) => map.get(sessionId)?.running === true); +- const liveKey = useLive((state) => JSON.stringify(state[sessionId] ?? {})); +- const pendingInput = useSession((snapshot) => Math.max(0, ...(snapshot?.pendingSubmissions ?? []).filter((sub) => sub.placement !== "queued").map((sub) => sub.time))); +- const bot = roster.byId[sessionId]; +- const room2 = roster.roomsById[sessionId]; +- const entries2 = (0, import_react7.useMemo)(() => Object.values(JSON.parse(liveKey)), [liveKey]); +- const streaming = entries2.some((entry) => entry.text); +- const tools = entries2.filter((entry) => !entry.text).sort((a, b) => a.at - b.at); +- const relaying = tools.some((entry) => entry.relay); +- const visible = running && !streaming && !relaying && (bot !== void 0 || room2 !== void 0); +- const tool = tools.at(-1); +- const inputRef = (0, import_react7.useRef)(0); +- const inputAt = Math.max(inputRef.current, pendingInput); +- inputRef.current = inputAt; +- const want = !visible ? null : inputAt > (tool?.at ?? 0) ? { key: `input:${inputAt}`, pool: PHRASES.thinking, state: "thinking" } : tool ? { key: tool.label ? `tool:${tool.label}` : "working", label: tool.label, pool: PHRASES.working, state: tool.state ?? "working" } : { key: "thinking", pool: PHRASES.thinking, state: "thinking" }; +- const [shown, setShown] = (0, import_react7.useState)(null); +- const wantKey = want?.key ?? null; +- const markState = (0, import_react7.useRef)("thinking"); +- if (want) markState.current = want.state; +- (0, import_react7.useEffect)(() => { +- if (wantKey === null || shown?.key === wantKey) return void 0; +- const make = () => setShown({ key: wantKey, text: want.label ?? drawPhrase(want.pool), at: Date.now() }); +- const wait = shown ? shown.at + PHRASE_HOLD_MS - Date.now() : 0; +- if (wait <= 0) { +- make(); ++function useLandOnMainBot(roster, useSessions, actions) { ++ const mainView = useSessions((list) => { ++ const row = Object.values(list.byId).find((session) => (session?.retainedBy?.mainView ?? 0) > 0); ++ return row ? `${row.id}|${row.blank ? 1 : 0}` : ""; ++ }); ++ const lastBot = (0, import_react18.useRef)(null); ++ (0, import_react18.useEffect)(() => { ++ if (!roster.ready || !roster.mainBotId) return void 0; ++ const [id, blank3] = mainView.split("|"); ++ const bot = id === "" ? void 0 : roster.byId[id]; ++ if (bot !== void 0) { ++ lastBot.current = { botId: bot.id, sessionId: id }; ++ if (currentPart(bot) !== id) actions.landOn(bot.id); + return void 0; + } +- const timer = setTimeout(make, wait); +- return () => clearTimeout(timer); +- }, [wantKey, shown]); +- const [phase, setPhase] = (0, import_react7.useState)(null); +- const endedAt = (0, import_react7.useRef)(0); +- const shift = (0, import_react7.useRef)(null); +- const leaving = (0, import_react7.useRef)(false); +- leaving.current = phase !== null && !visible; +- (0, import_react7.useEffect)(() => { +- if (visible) { +- setPhase("in"); ++ if (id !== "" && (roster.roomsById[id] || (actions.isAgentSession?.(id) ?? roster.agentOf[id] !== void 0) || blank3 !== "1")) { ++ lastBot.current = null; + return void 0; + } +- setPhase((current) => current === "in" ? "out" : current); +- const timer = setTimeout(() => { +- shift.current = document.querySelector("[data-conversation-scroll]")?.scrollTop ?? null; +- setPhase((current) => current === "out" ? null : current); +- }, 140); ++ const last = lastBot.current; ++ const timer = setTimeout(() => actions.landOn(roster.mainBotId, last, { stay: id === "", view: id }), last ? 0 : 400); + return () => clearTimeout(timer); +- }, [visible]); +- (0, import_react7.useLayoutEffect)(() => { +- if (phase !== null) return; +- endedAt.current = Date.now(); +- setShown(null); +- const before = shift.current; +- shift.current = null; +- const scroller = document.querySelector("[data-conversation-scroll]"); +- if (before === null || !scroller || reducedMotion()) return; +- const delta = before - scroller.scrollTop; +- if (delta > 0) scroller.querySelector("[data-chat-flow]")?.animate([{ transform: `translateY(${-delta}px)` }, { transform: "none" }], { duration: 140, easing: "cubic-bezier(.23,1,.32,1)" }); +- }, [phase]); +- (0, import_react7.useEffect)(() => { +- if (!running) actions.clearLive(sessionId); +- }, [running, sessionId]); +- useRowEntrances(() => leaving.current || Date.now() - endedAt.current < 300); +- const [now, setNow] = (0, import_react7.useState)(Date.now); +- (0, import_react7.useEffect)(() => { +- if (phase !== "in") return void 0; +- const timer = setInterval(() => setNow(Date.now()), 1e4); +- return () => clearInterval(timer); +- }, [phase]); +- const host = useRunningHost(running); +- if (!host || phase === null || !shown) return null; +- const elapsed = now - shown.at >= 6e4 ? elapsedLabel(now - shown.at) : null; +- return (0, import_react_dom2.createPortal)((0, import_react7.createElement)( +- "div", +- { className: "bt-typing", "data-exiting": phase === "out" ? "" : void 0, "aria-hidden": true }, +- bot ? (0, import_react7.createElement)(BotMark, { bot, size: 28, state: markState.current, live: true, pokeable: true }) : (0, import_react7.createElement)(RoomAvatar, { room: room2, roster, size: 28 }), +- (0, import_react7.createElement)( +- "span", +- { className: "bt-typing-text" }, +- (0, import_react7.createElement)( +- "span", +- { key: `${shown.key}:${shown.at}`, className: "bt-typing-label" }, +- (0, import_react7.createElement)(Shimmer, null, `${shown.text}…`), +- elapsed ? (0, import_react7.createElement)("span", { className: "bt-typing-time" }, `· ${elapsed}`) : null +- ) +- ) +- ), host); -} --function openThroughMenu(trigger) { -- const before = new Set(document.querySelectorAll('[role="menu"]')); -- trigger.click(); -- let frames = 0; -- const look = () => { -- const menu = [...document.querySelectorAll('[role="menu"]')].find((node) => !before.has(node)); -- const item = menu?.querySelector('button[role="menuitem"]'); -- if (item) item.click(); -- else if (++frames < 30) requestAnimationFrame(look); -- else pressSettingsShortcut(); -+ addOwner(project?.ownerUserId); -+ for (const id of idList(project?.ownerUserIds)) addOwner(id); -+ const maintainers = idList(stored.maintainers).filter((id) => !owner.includes(id)); -+ const members = idList(stored.members).filter((id) => !owner.includes(id) && !maintainers.includes(id)); -+ return { -+ chief: botIdOf(project?.chief), -+ agent: botIdOf(project?.assistant), -+ roles: { owner, maintainers, members }, -+ empty: owner.length === 0 && maintainers.length === 0 && members.length === 0, -+ // Both come from the `projects` listing (#1828), which the overlay keeps on the record: the -+ // `project-read` record answers no rank, and the panel never re-derives the host's policy. -+ role: text(project?.role), -+ choices: idList(project?.choices) - }; -- requestAnimationFrame(look); +- +-// src/client/dsh-offer.js +-var APP_OFFER = /* @__PURE__ */ new Set(["available", "downloading", "verifying", "installing", "ready"]); +-var bare = (value) => String(value ?? "").trim().replace(/^v/i, "").split("+")[0]; +-var sameVersion = (a, b) => bare(a) !== "" && bare(a) === bare(b); +-function showsDshRow(dsh, app) { +- if (!dsh?.verified || !dsh.current || sameVersion(dsh.verified, dsh.current)) return false; +- if (dsh.kind === "npm") return true; +- return dsh.kind === "app" && APP_OFFER.has(app?.phase) && sameVersion(app.version, dsh.verified); ++ }, [mainView, roster]); } --function pressSettingsShortcut() { -- const binding = shellShortcuts?.catalog.getSnapshot().find((row) => row.id === "settings.open")?.binding; -- if (!binding) return; -- const held = new Set(binding.modifiers); -- window.dispatchEvent(new KeyboardEvent("keydown", { -- code: binding.code, -- key: binding.code === "Comma" ? "," : "", -- bubbles: true, -- cancelable: true, -- ctrlKey: held.has("control"), -- altKey: held.has("alt"), -- shiftKey: held.has("shift"), -- metaKey: held.has("meta") -- })); --} --function AccountLauncher({ seat = true, settingsOpen, openSettings, openOnboarding, actions }) { -- const [open, setOpen] = (0, import_react8.useState)(false); -- (0, import_react8.useLayoutEffect)(() => { -- if (!seat) return void 0; -- actions.seatAccount(true); -- return () => actions.seatAccount(false); -- }, [seat]); -- const [box, setBox] = (0, import_react8.useState)(null); -- const button = (0, import_react8.useRef)(null); -- (0, import_react8.useEffect)(() => { -- if (settingsOpen) setOpen(false); -- }, [settingsOpen]); -- (0, import_react8.useEffect)(() => { -- if (!openSettings) return void 0; -- openShellSettings = openSettings; -- return () => { -- if (openShellSettings === openSettings) openShellSettings = null; -- }; -- }, [openSettings]); -- (0, import_react8.useEffect)(() => { -- if (!openOnboarding) return void 0; -- openShellOnboarding = openOnboarding; -- return () => { -- if (openShellOnboarding === openOnboarding) openShellOnboarding = null; -- }; -- }, [openOnboarding]); +- +-// src/client/sidebar.js +-var import_react8 = require("react"); +-var import_react_dom3 = require("react-dom"); +-var import_dsh_client_ui_primitives2 = require("@deepseek-ai/dsh-client-ui-primitives"); +-function useMarkedUnread(id, active, marked, actions) { - (0, import_react8.useEffect)(() => { -- if (!open) return void 0; -- const onDown = (event) => { -- if (event.target instanceof Element && (event.target.closest(".bt-account-menu") || button.current?.contains(event.target))) return; -- setOpen(false); -- }; -- const onKey = (event) => { -- if (event.key === "Escape") { -- event.stopPropagation(); -- setOpen(false); -- button.current?.focus(); -+var teamRow = (role, label, botId, onOpenBot, onOpenDossier) => (0, import_react19.createElement)( -+ "div", -+ { -+ key: role, -+ className: "bt-project-team-row", -+ "data-team-role": role, -+ // A row with no Bot is the fallback the slice specifies: still drawn, with nothing to open. -+ ...botId === "" ? { "data-team-state": "not-set" } : { -+ "data-open-bot": botId, -+ role: "button", -+ tabIndex: 0, -+ "aria-label": t("Open {name}'s chat", { name: botId }), -+ onClick: () => onOpenBot?.(botId), -+ onKeyDown: (event) => { -+ if (event.key === "Enter" || event.key === " ") { -+ event.preventDefault(); -+ onOpenBot?.(botId); -+ } -+ } -+ } -+ }, -+ (0, import_react19.createElement)("span", { key: "label", className: "bt-project-team-label" }, label), -+ botId === "" ? (0, import_react19.createElement)("span", { key: "value", className: "bt-project-missing" }, t("Not set")) : (0, import_react19.createElement)("span", { key: "value", className: "bt-project-team-bot" }, `@${botId}`), -+ // #1864: the dossier control sits inside the row, so the row still opens the chat (#1860) and a -+ // reader can look a Bot up first. A row with no Bot keeps the control: its panel reads "not set". -+ (0, import_react19.createElement)("button", { -+ key: "dossier", -+ type: "button", -+ className: "bt-project-team-dossier", -+ "data-dossier-role": role, -+ ...botId === "" ? {} : { "data-dossier-bot": botId }, -+ "aria-label": t("Open {name}'s dossier", { name: botId === "" ? label : botId }), -+ onClick: (event) => { -+ event?.stopPropagation(); -+ onOpenDossier?.({ role, label, botId }); -+ }, -+ onKeyDown: (event) => { -+ if (event.key === "Enter" || event.key === " ") { -+ event?.stopPropagation(); -+ onOpenDossier?.({ role, label, botId }); -+ } -+ } -+ }, t("Dossier")) -+); -+var POOL_RANKS = [["owner", "owner"], ["maintainer", "maintainers"], ["member", "members"]]; -+function ProjectTeam({ project, onOpenBot, onOpenDossier }) { -+ const view = projectTeamView(project); -+ const poolId = (id) => (0, import_react19.createElement)("span", { key: id }, `@${id}`); -+ const ranks = view.empty ? (0, import_react19.createElement)("span", { className: "bt-project-missing" }, t("No one in this pool yet.")) : (0, import_react19.createElement)( -+ "span", -+ { className: "bt-project-team-roles" }, -+ POOL_RANKS.map(([role, key]) => view.roles[key].length === 0 ? null : (0, import_react19.createElement)( -+ "span", -+ { key: role, className: "bt-project-team-role", "data-pool-role": role }, -+ `${roleText(role)}: `, -+ view.roles[key].map((id) => poolId(id)) -+ )) -+ ); -+ return (0, import_react19.createElement)( -+ "section", -+ { className: "bt-project-team", "data-team": "chief,agent,pool" }, -+ (0, import_react19.createElement)("h4", null, t("Project team")), -+ teamRow("chief", t("Project Chief"), view.chief, onOpenBot, onOpenDossier), -+ teamRow("agent", t("My agent"), view.agent, onOpenBot, onOpenDossier), -+ (0, import_react19.createElement)( -+ "div", -+ { key: "pool", className: "bt-project-team-row", "data-team-role": "pool" }, -+ (0, import_react19.createElement)("span", { key: "label", className: "bt-project-team-label" }, t("Role pool")), -+ ranks, -+ // The caller's own rank and what the host said they may switch to: the same answer the -+ // sidebar menu reads, printed where the pool is. -+ view.role === "" ? null : (0, import_react19.createElement)( -+ "span", -+ { key: "mine", className: "bt-project-team-myrole", "data-team-my-role": view.role }, -+ [ -+ t("My role"), -+ ": ", -+ roleText(view.role), -+ view.choices.length === 0 ? null : ` · ${t("Can switch to {roles}", { roles: view.choices.map(roleText).join(", ") })}` -+ ] -+ ) -+ ) -+ ); -+} -+function dossierRowOf(team, botId) { -+ const id = text(botId); -+ if (id === "") return null; -+ const rows = Array.isArray(team?.rows) ? team.rows : []; -+ return rows.find((row) => row !== null && typeof row === "object" && text(row.botId) === id) ?? null; +- if (active && marked) actions.clearUnread(id); +- }, [active, marked]); ++function FooterAccount(props) { ++ const seated = props.useUi((value) => value.accountSeated === true); ++ if (seated) return null; ++ return (0, import_react18.createElement)("span", { className: "bt-foot-account" }, (0, import_react18.createElement)(AccountLauncher, { ...props, seat: false })); + } +-var PinMark = () => (0, import_react8.createElement)("span", { className: "bt-pin", "aria-hidden": true }, (0, import_react8.createElement)(PinMarkIcon)); +-var useApproval = (useWork, match) => useWork ? useWork((work) => (work.approvals ?? []).some(match)) : false; +-var useGroupWork = (useWork, pick2) => useWork ? useWork((work) => pick2(work.rooms ?? {}).join(",")) : ""; +-var workKeys = (text) => text === "" ? [] : text.split(",").map((key) => { +- const [id, tool] = key.split("|"); +- return { id, tool: tool || void 0 }; +-}); +-var workLine = (lead, tool, more) => (0, import_react8.createElement)(Shimmer, null, `${lead} · ${workPhrase(tool)}…${more > 0 ? ` +${more}` : ""}`); +-function BotRow({ bot, roster, actions, useSessions, useSessionStatus, useSessionRetainInfo, useActivity, useWork, wide, marked }) { +- const id = bot.id; +- const part = currentPart(bot); +- const preview = useSessions((list) => previewOf(list, part, roster.exchangeTurns[part])); +- const running = useSessionStatus((map) => map.get(part)?.running === true); +- const active = useSessionRetainInfo(part, (info) => (info?.retainedBy?.mainView ?? 0) > 0); +- const unread = useSessionStatus((map) => map.get(part)?.completionUnread === true || map.get(part)?.pendingInteraction !== void 0) || marked && !active; +- useMarkedUnread(id, active, marked, actions); +- const main = isMainOf(roster, id); +- const tile = roster.mainBotId === id; +- const approval = useApproval(useWork, (entry) => entry.botId === id); +- const alert = roster.questions?.[part] !== void 0 || approval; +- const doing = useActivity((map) => map[id]); +- const groups = workKeys(useGroupWork(useWork, (rooms) => Object.entries(rooms).flatMap(([roomId, entries2]) => entries2.filter((entry) => entry.botId === id).map((entry) => `${roomId}|${entry.tool ?? ""}`)))); +- const away = !running && groups.length > 0; +- const state = botState(running || away, doing, alert); +- const awayLine = away ? workLine(roster.roomsById[groups[0].id]?.name ?? t("a group chat"), groups[0].tool, groups.length - 1) : null; +- if (bot.hidden && !active && !main) return null; +- const onContextMenu = (event) => { +- event.preventDefault(); +- actions.openMenu({ kind: "bot", id, x: event.clientX, y: event.clientY }); +- }; +- const pinned = bot.pinned === true && !tile; +- const label = [bot.name, main ? t("Main Bot") : "", pinned ? t("Pinned") : "", running || away ? t("Working") : "", approval ? t("Waiting for your approval") : alert ? t("Waiting for your answer") : "", unread ? t("Unread activity") : ""].filter(Boolean).join(", "); +- if (tile && wide) { +- return (0, import_react8.createElement)("div", { className: "bt-tile-wrap" }, (0, import_react8.createElement)( +- "button", +- { +- type: "button", +- className: "bt-tile", +- "aria-label": label, +- "aria-current": active ? "page" : void 0, +- "data-bot-id": id, +- title: preview || bot.name, +- onClick: () => actions.openSession(id), +- onContextMenu +- }, +- (0, import_react8.createElement)(BotAvatar, { bot, size: 60, state, main: true }), +- (0, import_react8.createElement)("span", { className: "bt-tile-name" }, bot.name), +- bot.role ? (0, import_react8.createElement)("span", { className: "bt-chip" }, bot.role) : null +- )); +- } +- return (0, import_react8.createElement)( ++function BotReturn({ wide, useSurface, actions }) { ++ const mode = useSurface((value) => value.mode); ++ if (mode !== "agent") return null; ++ return (0, import_react18.createElement)( + "button", +- { +- type: "button", +- className: "bt-row", +- "aria-label": label, +- "aria-current": active ? "page" : void 0, +- "data-bot-id": id, +- title: wide ? void 0 : bot.name, +- onClick: () => actions.openSession(id), +- onContextMenu +- }, +- (0, import_react8.createElement)(BotAvatar, { bot, size: 36, state, main }), +- pinned && !wide ? (0, import_react8.createElement)(PinMark) : null, +- wide ? (0, import_react8.createElement)( +- "span", +- { className: "bt-row-body" }, +- (0, import_react8.createElement)("span", { className: "bt-row-name" }, (0, import_react8.createElement)("span", { className: "bt-row-title" }, bot.name), pinned ? (0, import_react8.createElement)(PinMark) : null), +- awayLine ? (0, import_react8.createElement)("span", { className: "bt-row-preview" }, awayLine) : preview ? (0, import_react8.createElement)("span", { className: "bt-row-preview" }, preview) : null +- ) : null, +- wide && unread && !active ? (0, import_react8.createElement)("span", { className: "bt-unread", "aria-hidden": true }) : null ++ { type: "button", className: "bt-return", "aria-label": t("FlyBot"), title: t("FlyBot"), onClick: () => actions.setSurface("bot") }, ++ (0, import_react18.createElement)(FlyBotMark, { size: 20 }), ++ wide ? (0, import_react18.createElement)("span", { className: "bt-return-name" }, t("FlyBot")) : null + ); + } +-function RoomRow({ room: room2, roster, actions, useSessions, useSessionStatus, useSessionRetainInfo, useWork, wide, marked }) { +- const id = room2.id; +- const fallback = useSessions((list) => previewOf(list, id)); +- const preview = room2.preview?.replace(/\s+/g, " ").trim() || fallback; +- const active = useSessionRetainInfo(id, (info) => (info?.retainedBy?.mainView ?? 0) > 0); +- const unread = useSessionStatus((map) => map.get(id)?.completionUnread === true) || marked && !active; +- useMarkedUnread(id, active, marked, actions); +- const approval = useApproval(useWork, (entry) => entry.roomId === id); +- const members = workKeys(useGroupWork(useWork, (rooms) => (rooms[id] ?? []).map((entry) => `${entry.botId}|${entry.tool ?? ""}`))); +- const first = roster.byId[members[0]?.id]; +- const workingLine = first ? workLine(first.name, members[0].tool, members.length - 1) : null; +- if (room2.hidden && !active) return null; +- const pinned = room2.pinned === true; +- const label = [room2.name, pinned ? t("Pinned") : "", members.length > 0 ? t("Working") : "", approval ? t("Waiting for your approval") : ""].filter(Boolean).join(", "); +- return (0, import_react8.createElement)( +- "button", +- { +- type: "button", +- className: "bt-row", +- "aria-label": label, +- "aria-current": active ? "page" : void 0, +- "data-room-id": id, +- onClick: () => actions.openSession(id), +- onContextMenu: (event) => { +- event.preventDefault(); +- actions.openMenu({ kind: "room", id, x: event.clientX, y: event.clientY }); +- } +- }, +- (0, import_react8.createElement)(RoomAvatar, { room: room2, roster, size: 36 }), +- pinned && !wide ? (0, import_react8.createElement)(PinMark) : null, +- wide ? (0, import_react8.createElement)( +- "span", +- { className: "bt-row-body" }, +- (0, import_react8.createElement)("span", { className: "bt-row-name" }, (0, import_react8.createElement)("span", { className: "bt-row-title" }, room2.name), pinned ? (0, import_react8.createElement)(PinMark) : null), +- workingLine ? (0, import_react8.createElement)("span", { className: "bt-row-preview" }, workingLine) : preview ? (0, import_react8.createElement)("span", { className: "bt-row-preview" }, preview) : null +- ) : null, +- wide && approval && !active ? (0, import_react8.createElement)("span", { className: "bt-unread bt-unread-wait", "aria-hidden": true }) : wide && unread && !active ? (0, import_react8.createElement)("span", { className: "bt-unread", "aria-hidden": true }) : null +- ); ++var RETURN_CSS = ` ++.bt-return{display:inline-flex;align-items:center;justify-content:center;gap:8px;min-height:36px;padding:0 14px;border:0;border-radius:999px;background:none;color:inherit;font:inherit;font-size:14px;line-height:20px;cursor:pointer;transition:background-color .12s ease} ++.bt-return:hover{background:rgba(127,127,127,.14)} ++.bt-return:active{transform:scale(.96)} ++.bt-return .bt-mark,.bt-return .bt-flybot-mark{display:inline-flex;flex:none;line-height:0} ++.bt-return .bt-mark svg,.bt-return .bt-flybot-mark{width:20px;height:20px;overflow:visible} ++/* Shell styles carry --bt-ink-deepseek and Agent mode withdraws them; pin it here. */ ++.bt-return .bt-mark{--bt-ink-deepseek:#4D6BFE} ++.bt-return-name{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} ++`; ++function BrandMark({ size = 24 }) { ++ return (0, import_react18.createElement)("span", { className: "bt-brand-mark" }, (0, import_react18.createElement)(FlyBotMark, { size })); +} -+var dossierSection = (title, children) => (0, import_react19.createElement)( -+ "div", -+ { key: title, className: "bt-project-dossier-section" }, -+ (0, import_react19.createElement)("h4", null, title), -+ children -+); -+function ProjectDossierPanel({ row, label, onClose, onOpenBot } = {}) { -+ const known = row !== null && typeof row === "object"; -+ const botId = known ? text(row.botId) : ""; -+ const topics = known && Array.isArray(row.memory?.topics) ? row.memory.topics.map((name) => text(name)).filter((name) => name !== "") : []; -+ const skills = known && Array.isArray(row.skills) ? row.skills.map((name) => text(name)).filter((name) => name !== "") : []; -+ const meta = known ? [text(row.role), text(row.model)].filter((value) => value !== "") : []; -+ return (0, import_react19.createElement)( -+ "div", -+ { className: "bt-project-panel bt-project-dossier", "data-panel": "project-dossier", "data-dossier-bot": botId === "" ? void 0 : botId }, -+ (0, import_react19.createElement)("h3", null, label === "" || label === void 0 ? t("Dossier") : `${label} · ${t("Dossier")}`), -+ known ? [ -+ dossierSection(t("Persona"), [ -+ meta.length === 0 ? null : (0, import_react19.createElement)("div", { key: "meta", className: "bt-project-dossier-meta" }, meta.map((value) => (0, import_react19.createElement)("span", { key: value }, value))), -+ (0, import_react19.createElement)("p", { key: "persona", className: "bt-project-dossier-text" }, text(row.persona) || t("Not set")) -+ ]), -+ dossierSection(t("Memory"), [ -+ (0, import_react19.createElement)("p", { key: "head", className: "bt-project-dossier-text" }, text(row.memory?.head) || t("No memory yet.")), -+ topics.length === 0 ? null : (0, import_react19.createElement)("div", { key: "topics", className: "bt-project-dossier-topics" }, topics.map((topic) => (0, import_react19.createElement)("span", { key: topic, className: "bt-project-dossier-topic" }, topic))) -+ ]), -+ dossierSection(t("Skills"), (0, import_react19.createElement)("p", { key: "skills", className: "bt-project-dossier-text" }, skills.length === 0 ? t("No skills attached.") : skills.join(", "))) -+ ] : (0, import_react19.createElement)("p", { className: "bt-project-missing" }, t("Not set")), -+ botId === "" || !known ? null : (0, import_react19.createElement)( -+ "div", -+ { key: "actions", className: "bt-project-row-actions" }, -+ (0, import_react19.createElement)("button", { -+ type: "button", -+ className: "bt-soft bt-soft-sm", -+ "data-open-bot": botId, -+ onClick: () => onOpenBot?.(botId) -+ }, t("Open chat")) -+ ), -+ (0, import_react19.createElement)( -+ "div", -+ { className: "bt-project-row-actions" }, -+ (0, import_react19.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: () => onClose?.() }, t("Back to board")) -+ ) -+ ); ++function BrandName() { ++ return (0, import_react18.createElement)("span", { style: { fontWeight: 600, fontSize: 14 } }, t("FlyBot")); +} -+function projectAgentFor(bot, selfId) { -+ if (bot === null || typeof bot !== "object" || bot.remote !== void 0) return null; -+ const projectId = text(bot.projectId); -+ if (projectId === "") return null; -+ const owner = text(bot.ownerId); -+ const self = text(selfId); -+ if (self !== "" && owner !== "" && owner !== self) return null; -+ return { projectId }; ++var SIDEBAR_CSS = ` ++.bt-side-top .bt-brand{cursor:default;padding-left:6px} ++.bt-side-top .bt-brand:hover{background:none} ++.bt-brand .bt-mark{flex:none} ++.bt-pin{flex:none;display:inline-flex;color:var(--bt-ink-3)} ++.bt-rail .bt-row{position:relative} ++.bt-rail .bt-pin{position:absolute;top:3px;left:calc(50% + 12px)} ++.bt-side .bt-read-only{margin:4px 8px 6px;padding:8px 10px;border-radius:10px;background:var(--bt-hover);font-size:12px;line-height:1.45;text-align:left} ++.bt-rail .bt-read-only{width:100%;margin:4px 0 6px;padding:4px 2px;font-size:10px;line-height:1.3;text-align:center;white-space:nowrap;overflow:hidden;text-overflow:ellipsis} ++[class*="_footArea"]:has(.bt-foot-account){display:flex!important;flex-direction:row!important;align-items:center;gap:6px} ++[class*="_footArea"]:has(.bt-foot-account)>[class*="_footerActions"],[class*="_footArea"]:has(.bt-foot-account)>[class*="_footerActions"]>div{display:contents!important} ++.bt-foot-account{order:-1;flex:none;display:inline-flex} ++[class*="_footArea"]:has(.bt-foot-account) .bt-connect-foot{order:0;flex:1 1 auto;min-width:0;width:auto} ++[class*="_footArea"]:has(.bt-foot-account)>[class*="_settingsArea"]{order:1;flex:none} ++[class*="_footArea"]:has(.bt-foot-account)>[class*="_settingsArea"] button[class*="_trigger"] [class*="_label"]{display:none!important} ++ ++/* The update cloud springs in when a newer DS Bot shows up; its arrow drops in a ++ loop while the install runs. */ ++.bt-round.bt-update{position:relative;color:var(--bt-accent,#4D6BFE);animation:bt-update-in ${SPRING_SHAPE.duration}ms ${SPRING_SHAPE.easing}} ++.bt-round.bt-update svg{width:17px;height:17px} ++.bt-round.bt-update[data-phase=installed]{color:var(--bt-ink-2)} ++@keyframes bt-update-in{from{opacity:0;transform:scale(.6);filter:blur(4px)}} ++.bt-update[data-phase=installing] .bt-cloud-arrow{animation:bt-cloud-drop 1.1s cubic-bezier(.45,0,.55,1) infinite} ++@keyframes bt-cloud-drop{0%{transform:translateY(-3px);opacity:0}35%{opacity:1}70%{transform:translateY(1px);opacity:1}100%{transform:translateY(2px);opacity:0}} ++.bt-update-dot{position:absolute;top:6px;right:6px;width:7px;height:7px;border-radius:50%;background:#E5484D;box-shadow:0 0 0 1.5px var(--bt-sidebar)} ++.bt-update-pop{position:fixed;z-index:130;width:280px;padding:14px 14px 12px;border-radius:14px;background:var(--bt-main);border:.5px solid var(--bt-line-2);box-shadow:0 10px 20px -3px rgba(0,0,0,.1),0 4px 6px -4px rgba(0,0,0,.1);color:var(--bt-ink);box-sizing:border-box;transform-origin:top left;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",system-ui,sans-serif;animation:bt-update-pop ${SPRING_SHAPE.duration}ms ${SPRING_SHAPE.easing}} ++@keyframes bt-update-pop{from{opacity:0;transform:scale(.96);filter:blur(4px)}} ++.bt-update-title{font-size:14px;line-height:20px;font-weight:600} ++.bt-update-text{margin-top:2px;font-size:12.5px;line-height:18px;color:var(--bt-ink-2)} ++.bt-update-error{margin-top:8px;font-size:12px;line-height:17px;color:#c21d2e;overflow-wrap:anywhere;max-height:90px;overflow:auto} ++.bt-update-actions{display:flex;align-items:center;justify-content:flex-end;gap:10px;margin-top:12px} ++.bt-update-notes{margin-right:auto;font-size:12.5px;color:var(--bt-ink-2);text-decoration:none} ++.bt-update-notes:hover{color:var(--bt-ink);text-decoration:underline} ++.bt-update-go{height:30px;padding:0 14px;border:0;border-radius:999px;background:var(--bt-accent,#4D6BFE);color:var(--bt-accent-ink,#fff);font:inherit;font-size:13px;font-weight:500;cursor:pointer;transition:opacity .12s ease,transform .12s ease} ++.bt-update-go:hover:not(:disabled){opacity:.9} ++.bt-update-go:active:not(:disabled){transform:scale(.96)} ++.bt-update-go:disabled{opacity:.6;cursor:default} ++.bt-update-dsh:not(:first-child){margin-top:12px;padding-top:12px;border-top:.5px solid var(--bt-line-2)} ++@media (prefers-reduced-motion:reduce){.bt-round.bt-update,.bt-update-pop,.bt-update .bt-cloud-arrow{animation:none}} ++ ++/* The DSH Agent row and its expanding Session list. The black whale is the ++ harness's own mark, so the row reads as "plain dsh" next to the Bot marks. */ ++.bt-agent-ava{flex:none;width:36px;height:36px;border-radius:50%;display:inline-flex;align-items:center;justify-content:center;background:var(--bt-hover);color:var(--bt-ink)} ++.bt-agent-ava.bt-run{animation:bt-agent-pulse 1.4s ease-in-out infinite} ++@keyframes bt-agent-pulse{0%,100%{opacity:1}50%{opacity:.55}} ++.bt-agent-sub{display:grid;grid-template-rows:0fr;transition:grid-template-rows ${SPRING_SHAPE.duration}ms ${SPRING_SHAPE.easing}} ++.bt-agent-sub[data-open]{grid-template-rows:1fr} ++.bt-agent-sub-in{overflow:hidden;min-height:0;margin-left:44px} ++.bt-agent-sub-row{display:flex;align-items:center;gap:8px;width:100%;padding:5px 10px 5px 8px;border:0;border-radius:8px;background:none;color:var(--bt-ink-2);font:inherit;font-size:13px;line-height:20px;text-align:left;cursor:pointer} ++.bt-agent-sub-row:hover{background:var(--bt-hover);color:var(--bt-ink)} ++.bt-agent-sub-row[aria-current=page]{background:var(--bt-hover);color:var(--bt-ink);font-weight:500} ++.bt-agent-sub-title{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} ++.bt-agent-sub-time{flex:none;font-size:11px;color:var(--bt-ink-3)} ++.bt-agent-new{color:var(--bt-ink-3)} ++.bt-agent-new svg{width:14px;height:14px;flex:none} ++.bt-agent-edit{padding:3px 10px 3px 4px} ++.bt-agent-edit input{width:100%;border:0;border-radius:6px;background:var(--bt-hover);color:var(--bt-ink);font:inherit;font-size:13px;line-height:20px;padding:2px 8px;outline:none;box-shadow:inset 0 0 0 .5px var(--bt-line-2)} ++.bt-agent-sub[data-open] .bt-agent-sub-row{animation:bt-agent-row-in .24s ease both;animation-delay:calc(var(--i, 0)*30ms)} ++@keyframes bt-agent-row-in{from{opacity:0;filter:blur(4px)}to{opacity:1;filter:blur(0)}} ++@media (prefers-reduced-motion:reduce){.bt-agent-sub{transition:none}.bt-agent-sub .bt-agent-sub-row{animation:none}} ++ ++.bt-row-wrap{position:relative;display:flex;align-items:center;width:100%;min-width:0} ++.bt-row-wrap>.bt-row{flex:1 1 auto;min-width:0;width:100%} ++.bt-tree-item>.bt-row-wrap{flex:1 1 auto;width:auto;min-width:0} ++/* Gear sits over the row so hover/selected fill the same full-width band as a plain row (#384). */ ++.bt-row-settings{position:absolute;right:6px;top:50%;transform:translateY(-50%);z-index:1;flex:none;display:inline-flex;align-items:center;justify-content:center;width:28px;height:28px;padding:0;border:0;border-radius:8px;background:transparent;color:var(--bt-ink-3);cursor:pointer;opacity:0;pointer-events:none;transition:background-color .12s ease,color .12s ease,opacity .12s ease} ++.bt-row-wrap:hover>.bt-row,.bt-row-wrap:focus-within>.bt-row{padding-right:36px} ++.bt-row-wrap:hover .bt-row-settings,.bt-row-wrap:focus-within .bt-row-settings{opacity:1;pointer-events:auto;color:var(--bt-ink-2);background:var(--bt-hover)} ++.bt-row-settings:hover{opacity:1;color:var(--bt-ink-2);background:var(--bt-hover)} ++.bt-row-settings svg{width:15px;height:15px} ++`; ++ ++// src/client/busy-ack-logic.js ++function busyAckText(hostAck) { ++ return typeof hostAck?.text === "string" ? hostAck.text.trim() : ""; +} -+function ProjectAgentEntry({ projectId, read, onOpen } = {}) { -+ const [agentId, setAgentId] = (0, import_react19.useState)(""); -+ const id = text(projectId); -+ const open = async () => { -+ let target = agentId; -+ if (target === "") { -+ try { -+ const record = await (read ?? call)("project-read", { id }); -+ target = botIdOf(record?.project?.assistant); -+ } catch { -+ target = ""; - } -- }; -- window.addEventListener("mousedown", onDown); -- window.addEventListener("keydown", onKey, true); -- return () => { -- window.removeEventListener("mousedown", onDown); -- window.removeEventListener("keydown", onKey, true); ++function busyAckPending(hostAck) { ++ return busyAckText(hostAck) === ""; + } +-function AgentSessionRow({ session, agent, actions, renaming, index }) { +- const input = (0, import_react8.useRef)(null); +- (0, import_react8.useLayoutEffect)(() => { +- if (!renaming) return; +- input.current?.focus(); +- input.current?.select(); +- }, [renaming]); +- if (renaming) { +- const commit = (keep) => { +- const value = input.current?.value ?? ""; +- if (!keep || value.trim() === "" || value.trim() === session.title) { +- actions.endAgentRename(); +- return; +- } +- void (async () => { +- try { +- await actions.renameAgentSession(agent.id, session.id, value); +- actions.endAgentRename(); +- } catch (error) { +- actions.reportError(error, "agent-rename"); +- } +- })(); - }; -- }, [open]); -- const toggle = () => { -- const rect = button.current?.getBoundingClientRect(); -- if (rect) setBox({ left: rect.left, bottom: window.innerHeight - rect.top + 5 }); -- setOpen((value) => !value); -+ if (target !== "") setAgentId(target); -+ } -+ if (target !== "") onOpen?.(target); - }; -- const groups = [ -- [ -- [t("Usage"), GaugeIcon, () => actions.openSettings("usage")], -- [t("Connectors"), ConnectorIcon, () => actions.openConnectors()], -- [t("Automation tasks"), ClockIcon, () => actions.selectPanel("schedules")], -- [t("Bot settings"), GearIcon, () => actions.openSettings("general")] -- ], -- [ -- [t("Switch to classic Agent mode"), SwitchIcon, () => actions.setSurface("agent")] -- ] -- ]; +- return (0, import_react8.createElement)( +- "div", +- { className: "bt-agent-sub-row bt-agent-edit", style: { "--i": index } }, +- (0, import_react8.createElement)("input", { +- ref: input, +- defaultValue: session.title, +- "aria-label": t("Rename"), +- onKeyDown: (event) => { +- if (event.nativeEvent.isComposing) return; +- if (event.key === "Enter") commit(true); +- else if (event.key === "Escape") commit(false); +- }, +- onBlur: () => commit(true) +- }) +- ); +- } - return (0, import_react8.createElement)( -- import_react8.Fragment, -- null, -- (0, import_react8.createElement)( -- "button", -- { ref: button, type: "button", className: "bt-account", "aria-label": t("Account"), "aria-haspopup": "menu", "aria-expanded": open, onClick: toggle }, -- (0, import_react8.createElement)("span", { className: "bt-you", "aria-hidden": true }, (0, import_react8.createElement)(BotMark, { look: MAIN_LOOK, size: 24 })) -+ if (id === "") return null; -+ return (0, import_react19.createElement)("button", { -+ type: "button", -+ className: "bt-project-agent-entry", -+ "data-project-agent": id, -+ "data-open-bot": agentId === "" ? void 0 : agentId, -+ title: t("Open my agent"), -+ "aria-label": t("Open my agent"), -+ onClick: open -+ }, t("My agent")); +- "button", +- { +- type: "button", +- className: "bt-agent-sub-row", +- "aria-current": session.active ? "page" : void 0, +- style: { "--i": index }, +- onClick: () => actions.openAgentSession(session.id), +- onContextMenu: (event) => { +- event.preventDefault(); +- event.stopPropagation(); +- actions.openMenu({ kind: "agent-session", id: session.id, agentId: agent.id, x: event.clientX, y: event.clientY }); +- } +- }, +- (0, import_react8.createElement)("span", { className: "bt-agent-sub-title" }, session.title), +- (0, import_react8.createElement)("span", { className: "bt-agent-sub-time" }, session.at > 0 ? relativeShort(session.at) : "") +- ); ++function pendingBusyPlacement(submissions) { ++ if (!Array.isArray(submissions)) return false; ++ return submissions.some((item) => item?.placement === "queued" || item?.placement === "steering"); + } +-function AgentRow({ agent, actions, useSessions, useSessionStatus, useUi, wide, marked }) { +- const info = useSessions((list) => JSON.stringify(agent.sessions.map((id) => { +- const row = list.byId[id]; +- return { +- id, +- // A blank Session's title is the workspace folder name; show the label instead. +- title: row?.blank === true ? "" : row?.displayTitle ?? row?.title ?? "", +- at: row?.updatedAt ?? 0, +- running: row?.running === true, +- active: (row?.retainedBy?.mainView ?? 0) > 0 ++function busyAckVisible({ hostAck, pendingBusy, streamingText }) { ++ if (streamingText) return false; ++ return busyAckText(hostAck) !== "" || pendingBusy === true; +} -+function ProjectBoardPane({ project, rows, done, canManage, tasks, decisions, meetings, queues, metrics, onOpen, onSettings, onMembers, onOpenBot, onOpenDossier, call: call4 }) { -+ const view = projectBoardView({ project, rows, done, canManage, tasks, decisions, meetings, queues, metrics }); -+ return (0, import_react19.createElement)( -+ "div", -+ { className: "bt-project-board", "data-can-manage": view.canManage ? "true" : "false" }, -+ (0, import_react19.createElement)("h3", null, view.title), -+ (0, import_react19.createElement)(ProjectTeam, { project, onOpenBot, onOpenDossier }), -+ // Header counts (#1768): four numbers read from the host, an unreadable one shown as unknown. -+ view.counts === null ? null : (0, import_react19.createElement)( -+ "div", -+ { className: "bt-project-counts", "data-counts": "waiting,inflight,blocked,done" }, -+ (0, import_react19.createElement)("span", null, [t("Waiting"), ": ", (0, import_react19.createElement)("b", { key: "w" }, countText(view.counts.waiting))]), -+ (0, import_react19.createElement)("span", null, [t("In flight"), ": ", (0, import_react19.createElement)("b", { key: "f" }, countText(view.counts.inflight))]), -+ (0, import_react19.createElement)("span", null, [t("Blocked"), ": ", (0, import_react19.createElement)("b", { key: "b" }, countText(view.counts.blocked))]), -+ (0, import_react19.createElement)("span", null, [t("Done"), ": ", (0, import_react19.createElement)("b", { key: "d" }, countText(view.counts.done))]) - ), -- open && box ? (0, import_react_dom3.createPortal)((0, import_react8.createElement)( -+ // #1749: both buttons open a panel and neither is hidden from a non-owner. The host refuses -+ // every write a non-owner attempts, so the refusal is an answer the reader sees, not a -+ // control that silently disappeared. -+ (0, import_react19.createElement)( - "div", -- { className: "bt-menu bt-menu-up bt-account-menu", role: "menu", "aria-label": t("Account"), style: box }, -- groups.map((group, index) => (0, import_react8.createElement)( -- import_react8.Fragment, -- { key: index }, -- index > 0 ? (0, import_react8.createElement)("div", { className: "bt-menu-sep", role: "separator" }) : null, -- group.map(([label, Icon, run, hint]) => (0, import_react8.createElement)("button", { -- key: label, -+ { className: "bt-project-actions" }, -+ (0, import_react19.createElement)("button", { type: "button", "data-action": "project-settings", onClick: () => onSettings?.() }, t("Project settings")), -+ (0, import_react19.createElement)("button", { type: "button", "data-action": "project-members", onClick: () => onMembers?.() }, t("Members")) -+ ), -+ (0, import_react19.createElement)(ProjectMetrics, { view: view.metrics }), -+ (0, import_react19.createElement)(ProjectRunningTable, { view: view.metrics, onOpen }), -+ (0, import_react19.createElement)(ProjectBoardRows, { view, onOpen }), -+ (0, import_react19.createElement)(ProjectQueueGroups, { view, onOpen }), -+ (0, import_react19.createElement)(ProjectDoneGroup, { view, onOpen }), -+ (0, import_react19.createElement)(ProjectBoardRecord, { record: view.record, projectId: view.projectId, call: call4 }) -+ ); ++function roomBusyAck(room2) { ++ const entries2 = Object.entries(room2?.busyAcks ?? {}).filter(([, ack2]) => ack2 !== void 0 && ack2 !== null); ++ if (entries2.length === 0) return null; ++ const [botId, ack] = entries2.sort((a, b) => (b[1].at ?? 0) - (a[1].at ?? 0))[0]; ++ return { botId, text: busyAckText(ack), at: ack.at ?? 0 }; +} -+function ProjectSettingsPanel({ project, assistantMine, call: request, onClose, onSaved } = {}) { -+ const save = request ?? call; -+ const [kind, setKind] = (0, import_react19.useState)("bot"); -+ const [memberId, setMemberId] = (0, import_react19.useState)(""); -+ const [note, setNote] = (0, import_react19.useState)(""); -+ const noteLength = memoryLength(note); -+ const noteOver = noteLength > MEMORY_TEXT_MAX; -+ const noteSaveDisabled = text(note) === "" || noteOver; -+ const [assistantOff, setAssistantOff] = (0, import_react19.useState)(assistantMine === void 0 ? project?.assistant?.off === true : assistantMine?.off === true); -+ const [status, setStatus] = (0, import_react19.useState)(""); -+ const id = text(project?.id); -+ const run = async (endpoint, payload, done) => { -+ try { -+ const result = await save(endpoint, payload); -+ setStatus(done); -+ onSaved?.(result); -+ return result; -+ } catch (error) { -+ setStatus(hostText(error?.message ?? String(error))); -+ return null; ++function roomBusyAckVisible({ ack, pendingBusy }) { ++ if (ack === null || ack === void 0) return false; ++ return ack.text !== "" || pendingBusy === true; ++} ++ ++// src/client/busy-ack.js ++var import_react19 = require("react"); ++var import_react_dom5 = require("react-dom"); ++function useRunningHost2(active) { ++ const [host, setHost] = (0, import_react19.useState)(null); ++ (0, import_react19.useEffect)(() => { ++ if (!active) { ++ setHost(null); ++ return void 0; + } -+ }; -+ return (0, import_react19.createElement)( -+ "div", -+ { className: "bt-project-panel", "data-panel": "project-settings" }, -+ (0, import_react19.createElement)("h3", null, t("Project settings")), -+ (0, import_react19.createElement)( -+ "div", -+ { className: "bt-project-field" }, -+ (0, import_react19.createElement)("label", { htmlFor: "bt-project-member-id" }, t("Bind a member to this Project")), -+ (0, import_react19.createElement)( -+ "div", -+ { className: "bt-project-row-actions" }, -+ (0, import_react19.createElement)( -+ "select", -+ { "aria-label": t("Member kind"), value: kind, onChange: (event) => setKind(event.target.value) }, -+ (0, import_react19.createElement)("option", { value: "bot" }, t("Bot")), -+ (0, import_react19.createElement)("option", { value: "group" }, t("Group")) -+ ), -+ (0, import_react19.createElement)("input", { id: "bt-project-member-id", value: memberId, placeholder: t("Member id"), onChange: (event) => setMemberId(event.target.value) }), -+ (0, import_react19.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", "data-action": "project-bind", onClick: () => run("project-bind", { kind, id: text(memberId), projectId: id }, t("Member bound.")) }, t("Bind")), -+ (0, import_react19.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", "data-action": "project-unbind", onClick: () => run("project-bind", { kind, id: text(memberId), projectId: null }, t("Member unbound.")) }, t("Unbind")) -+ ) -+ ), -+ (0, import_react19.createElement)( -+ "div", -+ { className: "bt-project-field" }, -+ (0, import_react19.createElement)("label", null, t("Project Assistant")), -+ (0, import_react19.createElement)( -+ "div", -+ { className: "bt-project-row-actions" }, -+ (0, import_react19.createElement)("label", null, (0, import_react19.createElement)("input", { type: "checkbox", "data-action": "assistant-off", checked: assistantOff, onChange: (event) => setAssistantOff(event.target.checked) }), " ", t("Pause the daily digest")), -+ (0, import_react19.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", "data-action": "project-assistant-save", onClick: () => run("project-assistant-owner", { id, settings: { off: assistantOff === true } }, t("Assistant settings saved.")) }, t("Save")) -+ ) -+ ), -+ (0, import_react19.createElement)( -+ "div", -+ { className: "bt-project-field" }, -+ // The host knows two memory kinds; the free text the panel takes is recorded as a decision, -+ // never as a third kind the host would refuse (#1820). -+ (0, import_react19.createElement)("label", { htmlFor: "bt-project-note" }, t("Add to the assistant memory")), -+ (0, import_react19.createElement)("textarea", { id: "bt-project-note", value: note, onChange: (event) => setNote(event.target.value) }), -+ (0, import_react19.createElement)("div", { className: "bt-project-count", "data-role": "project-memory-count", "data-over": noteOver ? "true" : "false" }, `${noteLength}/${MEMORY_TEXT_MAX}`), -+ (0, import_react19.createElement)( -+ "div", -+ { className: "bt-project-row-actions" }, -+ (0, import_react19.createElement)("button", { - type: "button", -- role: "menuitem", -+ className: "bt-soft bt-soft-sm", -+ "data-action": "project-memory-save", -+ disabled: noteSaveDisabled, -+ // A disabled button draws no click, but the guard is what a keyboard or a stale tree -+ // could still reach: neither state sends a request or writes anything. - onClick: () => { -- setOpen(false); -- void Promise.resolve(run()).catch((error) => actions.reportError(error, "menu")); -+ if (noteSaveDisabled) return; -+ run("project-memory-write", { id, kind: "decision", text: text(note) }, t("Memory saved.")); - } -- }, (0, import_react8.createElement)(Icon), (0, import_react8.createElement)("span", { className: "bt-menu-label" }, label), hint ? (0, import_react8.createElement)("span", { className: "bt-menu-hint" }, hint) : null)) -+ }, t("Save memory")) -+ ) -+ ), -+ (0, import_react19.createElement)("div", { className: "bt-project-status-line", role: "status" }, status), -+ (0, import_react19.createElement)( -+ "div", -+ { className: "bt-project-row-actions" }, -+ (0, import_react19.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: () => onClose?.() }, t("Back to board")) -+ ) -+ ); ++ let frame = 0; ++ const find = () => { ++ frame = 0; ++ const node = document.querySelector(RUNNING_HOST); ++ setHost((prev) => prev === node ? prev : node); + }; +- }).sort((a, b) => b.at - a.at))); +- const statusInfo = useSessionStatus((map) => JSON.stringify(agent.sessions.map((id) => ({ +- id, +- running: map.get(id)?.running === true, +- unread: map.get(id)?.completionUnread === true +- })))); +- const statuses = Object.fromEntries(JSON.parse(statusInfo).map((entry) => [entry.id, entry])); +- const sessions = JSON.parse(info).map((session) => ({ +- ...session, +- running: statuses[session.id]?.running ?? session.running, +- unread: statuses[session.id]?.unread ?? false, +- title: session.title === "" ? t("New session") : session.title +- })); +- const renaming = useUi((value) => value.renameAgent); +- const active = sessions.some((session) => session.active); +- const running = sessions.some((session) => session.running); +- const unread = sessions.some((session) => session.unread) || marked && !active; +- useMarkedUnread(agent.id, active, marked, actions); +- const preview = sessions[0]?.title ?? t("New session"); +- const label = ["DSH Agent", running ? t("Working") : "", unread ? t("Unread activity") : ""].filter(Boolean).join(", "); +- const open = active && wide; +- return (0, import_react8.createElement)( ++ const observer = new MutationObserver(() => { ++ if (!frame) frame = requestAnimationFrame(find); ++ }); ++ observer.observe(document.querySelector("[data-conversation-scroll]") ?? document.body, { childList: true, subtree: true }); ++ find(); ++ return () => { ++ cancelAnimationFrame(frame); ++ observer.disconnect(); ++ }; ++ }, [active]); ++ return host; +} -+function ProjectMembersPanel({ project, rows, onClose } = {}) { -+ const owners = []; -+ const add = (id) => { -+ const clean = text(id); -+ if (clean !== "" && !owners.includes(clean)) owners.push(clean); -+ }; -+ add(project?.ownerUserId); -+ for (const id of Array.isArray(project?.ownerUserIds) ? project.ownerUserIds : []) add(id); -+ const boardMembers = []; -+ for (const row of Array.isArray(rows) ? rows : []) { -+ const name = text(row?.assignee); -+ if (name !== "" && !boardMembers.includes(name)) boardMembers.push(name); -+ } -+ const chief = text(project?.chief?.botId); -+ return (0, import_react19.createElement)( -+ "div", -+ { className: "bt-project-panel", "data-panel": "project-members" }, -+ (0, import_react19.createElement)("h3", null, t("Members")), -+ (0, import_react19.createElement)( -+ "ul", -+ { className: "bt-project-members" }, -+ owners.map((owner) => (0, import_react19.createElement)( -+ "li", -+ { key: `owner:${owner}`, className: "bt-project-member", "data-member-role": "owner", "data-member-id": owner }, -+ (0, import_react19.createElement)("span", null, `@${owner}`), -+ (0, import_react19.createElement)("span", { className: "bt-project-member-kind" }, t("Owner")) -+ )), -+ chief === "" ? null : (0, import_react19.createElement)( -+ "li", -+ { key: `chief:${chief}`, className: "bt-project-member", "data-member-role": "chief", "data-member-id": chief }, -+ (0, import_react19.createElement)("span", null, t("Project Chief")), -+ (0, import_react19.createElement)("span", { className: "bt-project-member-kind" }, t("Chief of this Project")) -+ ), -+ boardMembers.map((name) => (0, import_react19.createElement)( -+ "li", -+ { key: `board:${name}`, className: "bt-project-member", "data-member-role": "board", "data-member-id": name }, -+ (0, import_react19.createElement)("span", null, `@${name}`), -+ (0, import_react19.createElement)("span", { className: "bt-project-member-kind" }, t("On the board")) - )) -- ), document.body) : null -+ ), -+ owners.length === 0 && boardMembers.length === 0 && chief === "" ? (0, import_react19.createElement)("div", { className: "bt-project-empty" }, t("No member is named yet.")) : null, -+ (0, import_react19.createElement)("p", { className: "bt-project-note" }, t("Member management is not part of this slice; owners and the members their cards name are listed here.")), ++function BusyAckBubble({ sessionId, useRoster, useBusyAcks, useSessionStatus, useSession, useLive }) { ++ const roster = useRoster((value) => value); ++ const room2 = roster.roomsById?.[sessionId]; ++ const memberAck = room2 ? roomBusyAck(room2) : null; ++ const hostAck = useBusyAcks((map) => map?.[sessionId] ?? null); ++ const running = useSessionStatus((map) => map.get(sessionId)?.running === true); ++ const pendingBusy = useSession((snapshot) => pendingBusyPlacement(snapshot?.pendingSubmissions)); ++ const streamingText = useLive((state) => Object.values(state[sessionId] ?? {}).some((entry) => entry?.text)); ++ const visible = room2 ? roomBusyAckVisible({ ack: memberAck, pendingBusy }) : running && busyAckVisible({ hostAck, pendingBusy, streamingText }); ++ const bot = room2 ? roster.byId[memberAck?.botId] : roster.byId[sessionId]; ++ const host = useRunningHost2(visible); ++ const shown = (0, import_react19.useRef)(null); ++ if (visible) shown.current = room2 ? { text: memberAck?.text ?? "", at: memberAck?.at ?? Date.now() } : { text: busyAckText(hostAck), at: hostAck?.at ?? Date.now() }; ++ const text2 = shown.current?.text ?? ""; ++ if (!visible || !host || !shown.current || !bot) return null; ++ return (0, import_react_dom5.createPortal)((0, import_react19.createElement)( + "div", +- { className: "bt-agent" }, +- (0, import_react8.createElement)( +- "button", +- { +- type: "button", +- className: "bt-row", +- "aria-label": label, +- "aria-current": active ? "page" : void 0, +- "data-agent-id": agent.id, +- title: wide ? void 0 : "DSH Agent", +- onClick: () => actions.openAgent(agent.id), +- onContextMenu: (event) => { +- event.preventDefault(); +- actions.openMenu({ kind: "agent", id: agent.id, x: event.clientX, y: event.clientY }); +- } +- }, +- (0, import_react8.createElement)("span", { className: `bt-agent-ava${running ? " bt-run" : ""}` }, (0, import_react8.createElement)(import_dsh_client_ui_primitives2.FishLogo, { size: 22 })), +- wide ? (0, import_react8.createElement)( +- "span", +- { className: "bt-row-body" }, +- (0, import_react8.createElement)("span", { className: "bt-row-name" }, "DSH Agent"), +- preview ? (0, import_react8.createElement)("span", { className: "bt-row-preview" }, preview) : null +- ) : null, +- wide && unread && !active ? (0, import_react8.createElement)("span", { className: "bt-unread", "aria-hidden": true }) : null +- ), +- (0, import_react8.createElement)( ++ { ++ className: "bt-busy-ack", ++ "data-flybot": "busy-ack", ++ role: "status", ++ "aria-live": "polite", ++ "aria-busy": text2 ? void 0 : "true", ++ "data-pending": text2 ? void 0 : "" ++ }, ++ (0, import_react19.createElement)(BotMark, { bot, size: 28, state: "thinking", live: true }), + (0, import_react19.createElement)( -+ "div", -+ { className: "bt-project-row-actions" }, -+ (0, import_react19.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: () => onClose?.() }, t("Back to board")) -+ ) - ); + "div", +- { className: "bt-agent-sub", "data-open": open || void 0 }, +- (0, import_react8.createElement)( ++ { className: "bt-astack" }, ++ (0, import_react19.createElement)( + "div", +- { className: "bt-agent-sub-in" }, +- (0, import_react8.createElement)( +- "button", +- { type: "button", className: "bt-agent-sub-row bt-agent-new", style: { "--i": 0 }, onClick: () => { +- void actions.newAgentSession(agent.id).catch((error) => actions.reportError(error, "agent-session")); +- } }, +- (0, import_react8.createElement)(PlusIcon), +- (0, import_react8.createElement)("span", { className: "bt-agent-sub-title" }, t("New session")) +- ), +- sessions.map((session, index) => (0, import_react8.createElement)(AgentSessionRow, { +- key: session.id, +- session, +- agent, +- actions, +- index: index + 1, +- renaming: renaming === session.id +- })) ++ { className: "bt-msg" }, ++ (0, import_react19.createElement)( ++ "div", ++ { className: "bt-msg-line" }, ++ text2 ? (0, import_react19.createElement)("div", { className: "bt-bubble" }, text2) : (0, import_react19.createElement)( ++ "div", ++ { className: "bt-bubble bt-busy-dots", "aria-hidden": "true" }, ++ (0, import_react19.createElement)("span", null), ++ (0, import_react19.createElement)("span", null), ++ (0, import_react19.createElement)("span", null) ++ ) ++ ) + ) + ) +- ); ++ ), host); } --function useLandOnMainBot(roster, useSessions, actions) { -- const mainView = useSessions((list) => { -- const row = Object.values(list.byId).find((session) => (session?.retainedBy?.mainView ?? 0) > 0); -- return row ? `${row.id}|${row.blank ? 1 : 0}` : ""; -- }); -- const lastBot = (0, import_react8.useRef)(null); -- (0, import_react8.useEffect)(() => { -- if (!roster.ready || !roster.mainBotId) return void 0; -- const [id, blank3] = mainView.split("|"); -- const bot = id === "" ? void 0 : roster.byId[id]; -- if (bot !== void 0) { -- lastBot.current = { botId: bot.id, sessionId: id }; -- if (currentPart(bot) !== id) actions.landOn(bot.id); -+async function readProjectBoard(read, wanted, knownTeam) { -+ const listing = await read("projects"); -+ const projects = Array.isArray(listing?.projects) ? listing.projects : []; -+ const project = wanted === "" ? projects[0] : projects.find((item) => item.id === wanted); -+ if (project === void 0) return { project: null, rows: [], done: [], canManage: false, tasks: [], team: { rows: [] }, teamFresh: true }; -+ const zone = typeof Intl === "undefined" || typeof Intl.DateTimeFormat !== "function" ? void 0 : Intl.DateTimeFormat().resolvedOptions().timeZone; -+ const [record, board, taskList, dossier] = await Promise.all([ -+ read("project-read", { id: project.id }), -+ read("project-board", { id: project.id, timeZone: zone }), -+ read("tasks", {}), -+ // #1910: #1902 turned this failure into an ordinary empty team, which the poll then served for -+ // the rest of the open. Keep the two answers apart: a refusal is not fresh, so nothing is cached -+ // and the next tick asks again, while a real `{ rows: [] }` answer is fresh and frozen as usual. -+ knownTeam !== void 0 ? { answer: knownTeam, fresh: true } : read("project-team", { id: project.id }).then( -+ (answer) => ({ answer, fresh: answer !== null && typeof answer === "object" }), -+ () => ({ answer: null, fresh: false }) -+ ) -+ ]); -+ const tasks = Array.isArray(taskList) ? taskList : taskList?.tasks ?? []; -+ const queues = { waiting: board?.waiting, inflight: board?.inflight, blocked: board?.blocked, counts: board?.counts ?? null }; -+ const base = record?.project ?? project; -+ const withRole = { ...base, role: project.role ?? null, choices: Array.isArray(project.choices) ? project.choices : [] }; +-var GLIDE = springEasing(1e3, 63); +-function useListGlide(listRef, layout, key) { +- const tops = (0, import_react8.useRef)({ layout, map: /* @__PURE__ */ new Map() }); +- (0, import_react8.useLayoutEffect)(() => { +- const list = listRef.current; +- if (!list) return; +- const next = /* @__PURE__ */ new Map(); +- const still = reducedMotion() || tops.current.layout !== layout; +- for (const row of list.querySelectorAll("[data-bot-id],[data-room-id],[data-agent-id]")) { +- const id = row.dataset.botId ?? row.dataset.roomId ?? row.dataset.agentId; +- const top = row.offsetTop; +- next.set(id, top); +- const before = tops.current.map.get(id); +- if (!still && before !== void 0 && Math.abs(before - top) > 0.5) { +- row.animate([{ transform: `translateY(${before - top}px)` }, { transform: "none" }], GLIDE); +- } ++var BUSY_ACK_CSS = ` ++.bt-busy-ack{display:flex;align-items:flex-end;gap:8px;width:100%;margin-top:var(--dsh-chat-flow-gap,12px);margin-bottom:4px} ++.bt-busy-ack .bt-astack{align-items:flex-start} ++.bt-busy-ack .bt-bubble{opacity:.92} ++.bt-busy-dots{display:inline-flex;align-items:center;gap:4px;height:22px} ++.bt-busy-dots>span{width:5px;height:5px;border-radius:50%;background:var(--bt-ink-3);animation:bt-busy-blink 1.2s ease-in-out infinite} ++.bt-busy-dots>span:nth-child(2){animation-delay:.2s}.bt-busy-dots>span:nth-child(3){animation-delay:.4s} ++@keyframes bt-busy-blink{0%,80%,100%{opacity:.25}40%{opacity:1}} ++@media (prefers-reduced-motion:reduce){.bt-busy-dots>span{animation:none;opacity:.6}} ++`; ++ ++// src/client/project-board.js ++var import_react20 = require("react"); ++var PROJECT_BOARD_CSS = ` ++/* #1750: the board was a short dialog in the page flow — no stylesheet defines 'bt-overlay', so the ++ bot tree and workflows stayed legible under its rows. It is a modal now, on the same tier as the ++ Board and Settings layers, with kanban's own 'bt-scrim' sibling and 'bt-mem' surface. */ ++.bt-project-overlay{position:fixed;inset:0;z-index:121;isolation:isolate;display:flex;align-items:center;justify-content:center;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",system-ui,sans-serif} ++.bt-mem.bt-project-dialog{width:min(1080px,calc(100vw - 32px));height:min(760px,calc(100vh - 48px))} ++.bt-project-body{flex:1 1 auto;min-height:0;overflow:auto} ++.bt-project-board{padding:16px 18px} ++.bt-project-group{margin:0 0 14px} ++.bt-project-group h4{margin:0 0 6px;font-size:12px;letter-spacing:.04em;text-transform:uppercase;opacity:.7} ++.bt-project-row{display:flex;gap:10px;align-items:center;padding:8px 10px;border-radius:8px;background:var(--bt-card,rgba(127,127,127,.08));margin:0 0 6px;cursor:default} ++.bt-project-row[data-column="done"]{opacity:.7} ++.bt-project-title-wrap{display:flex;flex-direction:column;gap:2px;min-width:0} ++.bt-project-audit{font-size:11px;line-height:1.45;white-space:pre-wrap;opacity:.65;font-family:var(--bt-mono,ui-monospace,monospace)} ++.bt-project-row .bt-project-ref{margin-left:auto;font-size:11px;opacity:.6} ++.bt-project-empty{padding:22px 4px;opacity:.7} ++/* #1767: the record beside the cards. The same surface as a row, one step quieter, and no new ++ component language: a heading, a list, and a monospace hash the reader can compare. */ ++.bt-project-record{margin:18px 0 0;border-top:1px solid var(--bt-line,rgba(127,127,127,.22));padding:12px 0 0} ++.bt-project-record h4{margin:0 0 6px;font-size:12px;letter-spacing:.04em;text-transform:uppercase;opacity:.7} ++.bt-project-record h5{margin:10px 0 4px;font-size:11px;letter-spacing:.04em;text-transform:uppercase;opacity:.55} ++.bt-project-decision,.bt-project-meeting{display:flex;gap:10px;align-items:baseline;padding:6px 10px;border-radius:8px;background:var(--bt-card,rgba(127,127,127,.08));margin:0 0 6px} ++.bt-project-decision .bt-project-date,.bt-project-meeting .bt-project-date{font-size:11px;opacity:.6;min-width:76px;font-variant-numeric:tabular-nums} ++.bt-project-decision .bt-project-hash{margin-left:auto;font-size:11px;opacity:.55;font-family:var(--bt-mono,ui-monospace,monospace)} ++.bt-project-decision .bt-project-title,.bt-project-meeting .bt-project-title{min-width:0} ++/* #2041: the decision row became a button, so it keeps the row's look and drops the control's. */ ++button.bt-project-decision{width:100%;font:inherit;color:inherit;text-align:left;border:0;cursor:pointer} ++button.bt-project-decision:hover{background:var(--bt-card-hover,rgba(127,127,127,.16))} ++button.bt-project-decision:focus-visible{outline:2px solid var(--bt-accent,#4c8dff);outline-offset:1px} ++button.bt-project-decision[data-open="true"]{outline:1px solid var(--bt-accent,#4c8dff)} ++.bt-project-decision-wrap{margin:0} ++.bt-project-decision-text{margin:0 0 8px;padding:8px 10px;border-radius:8px;background:var(--bt-card,rgba(127,127,127,.08));white-space:pre-wrap;overflow-wrap:anywhere;max-height:320px;overflow:auto;font-size:12px;font-family:var(--bt-mono,ui-monospace,monospace)} ++.bt-project-decision-refusal{opacity:.75} ++.bt-project-path{font-size:11px;opacity:.6;font-family:var(--bt-mono,ui-monospace,monospace)} ++.bt-project-missing{font-size:11px;opacity:.6} ++.bt-project-status{font-size:11px;text-transform:uppercase;letter-spacing:.04em;opacity:.6;margin-left:auto} ++/* #1831: the card's level, the people it is addressed to, and the identity a row or a comment ++ carries. A severe card is the only one painted louder, because it is the only one that waits. */ ++.bt-project-level{font-size:10px;text-transform:uppercase;letter-spacing:.06em;padding:1px 6px;border-radius:999px;border:1px solid var(--bt-line,rgba(127,127,127,.32));opacity:.75} ++.bt-project-level[data-level="severe"]{opacity:1;border-color:#c2410c;color:#c2410c} ++.bt-project-recipients{font-size:11px;opacity:.65} ++.bt-project-paused{font-size:11px;opacity:.75;color:#c2410c} ++.bt-project-holder{font-size:11px;opacity:.7} ++.bt-project-speaks-for{font-size:11px;opacity:.7;font-style:italic} ++.bt-project-actions{display:flex;gap:8px;margin:0 0 14px} ++.bt-project-metrics{margin:0 0 14px} ++.bt-project-metric-cards{display:grid;grid-template-columns:repeat(auto-fit,minmax(132px,1fr));gap:8px} ++.bt-project-metric{display:flex;flex-direction:column;gap:2px;padding:8px 10px;border:1px solid rgba(127,127,127,.24);border-radius:8px} ++.bt-project-metric-value{font-size:18px;font-weight:600} ++.bt-project-metric-label{font-size:12px;font-weight:600} ++.bt-project-metric-caption{font-size:10px;opacity:.6;line-height:1.3} ++.bt-project-metrics-note{margin:0 0 6px;font-size:11px;opacity:.7} ++.bt-project-running{margin:0 0 14px} ++.bt-project-running-head,.bt-project-running-row{display:grid;grid-template-columns:2fr 1fr 1fr .8fr .8fr 1.2fr;gap:8px;font-size:12px;padding:3px 0} ++.bt-project-running-head{opacity:.6;font-size:10px;letter-spacing:.03em} ++.bt-project-running-row[role="button"]{cursor:pointer} ++.bt-project-running-row[data-overdue="true"]{color:#c2410c} ++.bt-project-counts{display:flex;gap:14px;align-items:center;margin:0 0 12px;font-size:12px;letter-spacing:.03em;opacity:.8} ++.bt-project-counts b{font-weight:600} ++.bt-project-group[data-queue="waiting"] .bt-project-row{cursor:default} ++.bt-project-role{font-size:11px;opacity:.65;font-family:var(--bt-mono,ui-monospace,monospace)} ++.bt-project-wait{font-size:11px;opacity:.65;margin-left:auto} ++.bt-project-state{font-size:11px;margin-left:auto} ++.bt-project-row[data-state="stalled"]{border-left:2px solid #d9822b} ++.bt-project-reason{font-size:11px;opacity:.7} ++ ++/* #1855: the finished cards are one group of their own, closed until the reader opens it — a ++ Project with months of history reads as the work in flight, not as its archive. */ ++.bt-project-done{margin:18px 0 0;border-top:1px solid var(--bt-line,rgba(127,127,127,.22));padding:12px 0 0} ++.bt-project-done>summary{cursor:pointer;font-size:12px;letter-spacing:.04em;text-transform:uppercase;opacity:.7;list-style:none} ++.bt-project-done>summary::-webkit-details-marker{display:none} ++.bt-project-done>summary::before{content:'\\25B8';display:inline-block;width:12px;opacity:.6} ++.bt-project-done[open]>summary::before{content:'\\25BE'} ++.bt-project-done[open]>summary{margin:0 0 8px} ++ ++/* #1749: management panels. Same surface as the board, one form row per control, and a status ++ line that quotes the host's own answer — a refused save reads where the reader pressed it. */ ++.bt-project-panel{padding:16px 18px;display:flex;flex-direction:column;gap:12px} ++.bt-project-panel h3{margin:0} ++.bt-project-field{display:flex;flex-direction:column;gap:4px} ++.bt-project-field label{font-size:12px;letter-spacing:.04em;text-transform:uppercase;opacity:.7} ++.bt-project-field input,.bt-project-field select,.bt-project-field textarea{font:inherit;padding:6px 8px;border-radius:8px;border:1px solid var(--bt-line,rgba(127,127,127,.32));background:var(--bt-card,rgba(127,127,127,.08));color:inherit} ++.bt-project-field textarea{min-height:88px;resize:vertical} ++/* #1887: the counter under the memory box. It stays quiet until the text passes the bound, and ++ past it turns the one colour that says so; the disabled Save is the state a reader acts on. */ ++.bt-project-count{font-size:11px;opacity:.6;font-variant-numeric:tabular-nums} ++.bt-project-count[data-over="true"]{opacity:1;color:#c2410c} ++.bt-project-row-actions{display:flex;gap:8px;align-items:center} ++.bt-project-members{list-style:none;margin:0;padding:0;display:flex;flex-direction:column;gap:6px} ++.bt-project-member{display:flex;gap:10px;align-items:baseline;padding:8px 10px;border-radius:8px;background:var(--bt-card,rgba(127,127,127,.08))} ++.bt-project-member .bt-project-member-kind{margin-left:auto;font-size:11px;opacity:.6} ++.bt-project-status-line{font-size:12px;opacity:.75;min-height:16px} ++.bt-project-note{font-size:12px;opacity:.65;margin:0} ++ ++/* #1860: the Project team — who runs the Project, who speaks for the reader, and the role pool. ++ One quiet block above the cards, and a Bot row is a button because a reader is looking a ++ person up. The pool row keeps the rank it plays and the ranks the host said it may switch to. */ ++.bt-project-team{margin:0 0 14px} ++.bt-project-team h4{margin:0 0 6px;font-size:12px;letter-spacing:.04em;text-transform:uppercase;opacity:.7} ++.bt-project-team-row{display:flex;gap:10px;align-items:baseline;flex-wrap:wrap;padding:8px 10px;border-radius:8px;background:var(--bt-card,rgba(127,127,127,.08));margin:0 0 6px} ++.bt-project-team-row[data-open-bot]{cursor:pointer} ++.bt-project-team-row[data-open-bot]:hover{background:var(--bt-hover,rgba(127,127,127,.14))} ++.bt-project-team-label{font-size:12px;opacity:.7;min-width:96px} ++.bt-project-team-bot{font-size:12px;font-family:var(--bt-mono,ui-monospace,monospace)} ++.bt-project-team-roles{display:flex;gap:10px;flex-wrap:wrap;font-size:11px;opacity:.75} ++.bt-project-team-role{font-family:var(--bt-mono,ui-monospace,monospace)} ++.bt-project-team-myrole{margin-left:auto;font-size:11px;opacity:.65} ++ ++/* #1864: the team dossier. A Dossier control on each team row opens one read-only panel; the ++ owner's own chat header carries the same entry to the Project assistant's chat. */ ++.bt-project-team-dossier{margin-left:auto;font-size:11px;padding:1px 8px;border-radius:999px;border:1px solid var(--bt-line,rgba(127,127,127,.32));background:transparent;color:inherit;cursor:pointer} ++.bt-project-team-dossier:hover{background:var(--bt-hover,rgba(127,127,127,.14))} ++.bt-project-dossier{padding:16px 18px;display:flex;flex-direction:column;gap:12px} ++.bt-project-dossier-section{display:flex;flex-direction:column;gap:4px} ++.bt-project-dossier-section h4{margin:0;font-size:12px;letter-spacing:.04em;text-transform:uppercase;opacity:.7} ++.bt-project-dossier-text{margin:0;font-size:13px;line-height:1.5;white-space:pre-wrap;overflow-wrap:anywhere} ++.bt-project-dossier-meta{display:flex;gap:12px;flex-wrap:wrap;font-size:11px;opacity:.7} ++.bt-project-dossier-topics{display:flex;gap:6px;flex-wrap:wrap} ++.bt-project-dossier-topic{font-size:11px;padding:1px 6px;border-radius:999px;background:var(--bt-card,rgba(127,127,127,.08))} ++.bt-project-agent-entry{display:inline-flex;align-items:center;gap:4px;font-size:11px;padding:1px 8px;border-radius:999px;border:1px solid var(--bt-line,rgba(127,127,127,.32));background:transparent;color:inherit;cursor:pointer} ++.bt-project-agent-entry:hover{background:var(--bt-hover,rgba(127,127,127,.14))} ++`; ++var PROJECT_SOURCES = ["bot", "group", "workflow"]; ++var SOURCE_LABELS = { bot: "Bots", group: "Groups", workflow: "Workflows" }; ++var text = (value) => typeof value === "string" ? value.trim() : ""; ++var MEMORY_TEXT_MAX = 600; ++var memoryLength = (value) => [...text(value)].length; ++var DONE_COLUMN = "done"; ++var boardOfRow = (row, task) => { ++ const stated = row?.board; ++ if (stated !== null && typeof stated === "object") { ++ const id = text(stated.id); ++ if (id !== "" && (stated.kind === "group" || stated.kind === "bot")) return { kind: stated.kind, id }; ++ } ++ if (task === void 0) return null; ++ if (row.source === "group") { ++ const id = text(task.origin?.roomId); ++ return id === "" ? null : { kind: "group", id }; ++ } ++ if (row.source === "bot") { ++ const id = text(task.ownerBotId); ++ return id === "" ? null : { kind: "bot", id }; ++ } ++ return null; ++}; ++var decisionOf = (file) => { ++ if (file === null || typeof file !== "object") return null; ++ const path = text(file.path); ++ if (path === "") return null; + return { -+ project: withRole, -+ assistantMine: record?.assistantMine ?? null, -+ rows: board?.tasks ?? [], -+ done: board?.done ?? [], -+ canManage: record?.canManage === true, -+ tasks, -+ decisions: board?.decisions, -+ meetings: board?.meetings, -+ queues, -+ metrics: board?.metrics ?? null, -+ team: dossier.answer !== null && typeof dossier.answer === "object" ? dossier.answer : { rows: [] }, -+ teamFresh: dossier.fresh ++ key: `decision:${path}`, ++ path, ++ name: text(file.name) || path.slice(path.lastIndexOf("/") + 1), ++ date: text(file.date), ++ title: text(file.title), ++ sha256: text(file.sha256), ++ shortHash: text(file.sha256).slice(0, 12) + }; -+} -+var PROJECT_BOARD_POLL_MS = 5e3; -+var boardPollPaused = () => typeof document !== "undefined" && document.visibilityState === "hidden" || typeof window !== "undefined" && !window.location.hash.startsWith("#project"); -+function ProjectBoardOverlay({ actions, call: request } = {}) { -+ const read = request ?? call; -+ const [state, setState] = (0, import_react19.useState)(null); -+ const [panel, setPanel] = (0, import_react19.useState)(null); -+ const [dossier, setDossier] = (0, import_react19.useState)(null); -+ const team = (0, import_react19.useRef)(void 0); -+ const teamGen = (0, import_react19.useRef)(0); -+ const [hash, setHash] = (0, import_react19.useState)(() => typeof window === "undefined" ? "" : window.location.hash); -+ const close = (0, import_react19.useCallback)(() => { -+ if (typeof window !== "undefined") window.location.hash = ""; -+ setPanel(null); -+ setState(null); -+ }, []); -+ (0, import_react19.useEffect)(() => { -+ if (typeof window === "undefined") return void 0; -+ const onKey = (event) => { -+ if (event.key !== "Escape") return; -+ if (panel !== null) setPanel(null); -+ else close(); -+ }; -+ window.addEventListener("keydown", onKey); -+ return () => window.removeEventListener("keydown", onKey); -+ }, [panel, close]); -+ (0, import_react19.useEffect)(() => { -+ if (typeof window === "undefined") return void 0; -+ const sync = () => setHash(window.location.hash); -+ window.addEventListener("hashchange", sync); -+ sync(); -+ return () => window.removeEventListener("hashchange", sync); -+ }, []); -+ (0, import_react19.useEffect)(() => { -+ if (typeof window === "undefined") return void 0; -+ if (!hash.startsWith("#project")) { -+ setState(null); - return void 0; - } -- if (id !== "" && (roster.roomsById[id] || (actions.isAgentSession?.(id) ?? roster.agentOf[id] !== void 0) || blank3 !== "1")) { -- lastBot.current = null; -- return void 0; -+ const wanted = hash.slice("#project".length).replace(/^=/, ""); -+ let gone = false; -+ team.current = void 0; -+ teamGen.current = 0; -+ const loadBoard = () => readProjectBoard(read, wanted, team.current); -+ const refresh = async (first) => { -+ const gen = teamGen.current; -+ try { -+ const next = await loadBoard(); -+ if (!gone) { -+ const newer = teamGen.current !== gen; -+ if (next.teamFresh !== false && !newer) team.current = next.team; -+ setState(newer && team.current !== void 0 ? { ...next, team: team.current } : next); -+ } -+ } catch { -+ if (!gone && first) setState({ project: null, rows: [], canManage: false, tasks: [] }); -+ } -+ }; -+ void refresh(true); -+ const timer = setInterval(() => { -+ if (!boardPollPaused()) void refresh(false); -+ }, PROJECT_BOARD_POLL_MS); -+ return () => { -+ gone = true; -+ clearInterval(timer); -+ }; -+ }, [hash]); -+ const openDossier = async (value) => { -+ setDossier(value); -+ setPanel("dossier"); -+ const id = state?.project?.id; -+ if (typeof id !== "string" || id === "") return; -+ const gen = teamGen.current + 1; -+ teamGen.current = gen; -+ try { -+ const answer = await read("project-team", { id }); -+ if (teamGen.current !== gen || answer === null || typeof answer !== "object") return; -+ team.current = answer; -+ setState((previous) => previous === null ? previous : { ...previous, team: answer }); -+ } catch { ++}; ++var meetingOf = (meeting, status) => { ++ if (meeting === null || typeof meeting !== "object") return null; ++ const id = text(meeting.id); ++ if (id === "") return null; ++ return { ++ key: `meeting:${id}`, ++ id, ++ date: text(meeting.date), ++ question: text(meeting.question) || text(meeting.title), ++ owner: text(meeting.owner) || null, ++ status, ++ path: text(meeting.path), ++ blockingQuestion: text(meeting.blockingQuestion) || null, ++ // #1831: the level and recipients the host answered, and the refusal to advance a severe card. ++ // Nothing is inferred here: a record written before the slice carries an ordinary card and no ++ // recipients, exactly as the host reads it. ++ level: text(meeting.level) || "ordinary", ++ recipients: Array.isArray(meeting.recipients) ? meeting.recipients.map((item) => text(item)).filter((name) => name !== "") : [], ++ advance: meeting.advance !== null && typeof meeting.advance === "object" ? { allowed: meeting.advance.allowed === true, reason: text(meeting.advance.reason) || null } : { allowed: true, reason: null }, ++ // #1861: the host's participants with who each position speaks for, so the row can say a ++ // Project Chief or an agent held it for someone rather than for themselves. ++ participants: Array.isArray(meeting.participants) ? meeting.participants.map((participant) => ({ name: text(participant?.name), speaksFor: text(participant?.speaksFor) || null })).filter((participant) => participant.name !== "") : [] ++ }; ++}; ++var listOf = (value) => Array.isArray(value) ? value.map((item) => decisionOf(item)).filter((item) => item !== null) : []; ++function projectBoardRecord({ decisions, meetings } = {}) { ++ const dir = text(decisions?.dir) || null; ++ const files = listOf(decisions?.files); ++ const decided = Array.isArray(meetings?.decided) ? meetings.decided.map((item) => meetingOf(item, "decided")).filter((item) => item !== null) : []; ++ const pending = Array.isArray(meetings?.pending) ? meetings.pending.map((item) => meetingOf(item, item?.status ?? "deliberating")).filter((item) => item !== null) : []; ++ const totalOf = (value) => typeof value === "number" && Number.isFinite(value) ? value : null; ++ const hiddenOf = (total, rows) => total === null ? 0 : Math.max(0, total - rows); ++ const decisionsTotal = totalOf(decisions?.total); ++ const decidedTotal = totalOf(meetings?.decidedTotal); ++ const pendingTotal = totalOf(meetings?.pendingTotal); ++ return { ++ decisions: { ++ dir, ++ files, ++ count: files.length, ++ total: decisionsTotal, ++ hidden: hiddenOf(decisionsTotal, files.length), ++ empty: files.length === 0, ++ emptyText: text(decisions?.reason) || (dir === null ? "This Project names no working folder." : `No decision file in ${dir}/decisions.`) ++ }, ++ meetings: { ++ decided, ++ pending, ++ count: decided.length + pending.length, ++ decidedTotal, ++ pendingTotal, ++ decidedHidden: hiddenOf(decidedTotal, decided.length), ++ pendingHidden: hiddenOf(pendingTotal, pending.length), ++ empty: decided.length === 0 && pending.length === 0, ++ emptyText: text(meetings?.reason) || "No meeting of this Project is recorded." } -- const last = lastBot.current; -- const timer = setTimeout(() => actions.landOn(roster.mainBotId, last), last ? 0 : 400); -- return () => clearTimeout(timer); -- }, [mainView, roster]); --} --function FooterAccount(props) { -- const seated = props.useUi((value) => value.accountSeated === true); -- if (seated) return null; -- return (0, import_react8.createElement)("span", { className: "bt-foot-account" }, (0, import_react8.createElement)(AccountLauncher, { ...props, seat: false })); --} --function BotReturn({ wide, useSurface, actions }) { -- const mode = useSurface((value) => value.mode); -- if (mode !== "agent") return null; +- tops.current = { layout, map: next }; +- }, [layout, key]); ++ }; + } +-function TeamSidebar(props) { +- const { wide, useRoster, useUi, useSessions, actions } = props; +- const listRef = (0, import_react8.useRef)(null); +- const roster = useRoster((value) => value); +- const markedUnread = useUi((value) => value.unread); +- props.useRegistry((value) => value); +- const updated = useSessions((list) => { +- const stamps = {}; +- for (const id of list.ids) stamps[id] = list.byId[id]?.updatedAt ?? 0; +- return JSON.stringify(stamps); +- }); +- const order = (0, import_react8.useMemo)(() => { +- const stamps = JSON.parse(updated); +- const entries2 = [ +- ...roster.bots.filter((bot) => bot.id !== roster.mainBotId).map((bot) => ({ kind: "bot", item: bot, at: stamps[currentPart(bot)] ?? bot.createdAt })), +- ...roster.rooms.map((room2) => ({ kind: "room", item: room2, at: stamps[room2.id] ?? room2.createdAt })), +- // The Agent sorts by its most recent Session, falling back to when it was added. +- ...(roster.agents ?? []).map((agent) => ({ kind: "agent", item: agent, at: Math.max(0, ...agent.sessions.map((id) => stamps[id] ?? 0)) || agent.createdAt })) +- ]; +- return entries2.sort((a, b) => Number(Boolean(b.item.pinned)) - Number(Boolean(a.item.pinned)) || b.at - a.at); +- }, [roster, updated]); +- const main = roster.byId[roster.mainBotId]; +- useListGlide(listRef, wide, order.map((entry) => entry.item.id).join(",")); +- useLandOnMainBot(roster, useSessions, actions); +- const rowProps = { roster, actions, useSessions, useUi, useSessionStatus: props.useSessionStatus, useSessionRetainInfo: props.useSessionRetainInfo, useActivity: props.useActivity, useWork: props.useWork, wide }; +- const typeToSearch = (event) => { +- if (event.defaultPrevented || event.key.length !== 1 || event.key === " " || event.metaKey || event.ctrlKey || event.altKey || event.nativeEvent.isComposing) return; +- if (event.target instanceof Element && event.target.closest("input,textarea,[contenteditable]")) return; +- event.preventDefault(); +- actions.openPalette(event.key); ++function projectBoardView({ project, rows, done, canManage, tasks, decisions, meetings, queues, metrics } = {}) { ++ const isRow = (row) => row !== null && typeof row === "object" && text(row.id) !== ""; ++ const byId = new Map((Array.isArray(tasks) ? tasks : []).filter((task) => task !== null && typeof task === "object" && text(task.id) !== "").map((task) => [text(task.id), task])); ++ const list = []; ++ const archived = []; ++ const seen = /* @__PURE__ */ new Set(); ++ for (const row of [...Array.isArray(done) ? done : [], ...Array.isArray(rows) ? rows : []]) { ++ if (!isRow(row)) continue; ++ const id = text(row.id); ++ if (seen.has(id)) continue; ++ seen.add(id); ++ const column = text(row.column) || "not-started"; ++ (column === DONE_COLUMN ? archived : list).push(row); ++ } ++ const projectRow = (row) => { ++ const task = byId.get(text(row.id)); ++ return { ++ id: text(row.id), ++ key: `task:${text(row.id)}`, ++ column: text(row.column) || "not-started", ++ assignee: text(row.assignee) || null, ++ runId: text(row.runId) || null, ++ nodeId: text(row.nodeId) || null, ++ title: text(row.title) || text(task?.title) || text(task?.note) || null, ++ priority: text(row.priority) || text(task?.priority) || null, ++ audit: text(row.audit) || null, ++ // #1831: who is doing the card, and who is speaking for whom. Both are host truth; ++ // a card carrying neither renders exactly as before. ++ holder: text(row.holder) || null, ++ speaksFor: text(row.speaksFor) || null, ++ board: boardOfRow(row, task) ++ }; + }; - return (0, import_react8.createElement)( -- "button", -- { type: "button", className: "bt-return", "aria-label": t("DS Bot"), title: t("DS Bot"), onClick: () => actions.setSurface("bot") }, -- (0, import_react8.createElement)(BotMark, { look: MAIN_LOOK, size: 20 }), -- wide ? (0, import_react8.createElement)("span", { className: "bt-return-name" }, t("DS Bot")) : null ++ const groups = PROJECT_SOURCES.map((source) => ({ ++ source, ++ label: SOURCE_LABELS[source], ++ rows: list.filter((row) => row.source === source).map(projectRow) ++ })).filter((group) => group.rows.length > 0); ++ const countOf = (value) => value === void 0 || value === null ? null : value; ++ const queueItems = (items, annotate) => (Array.isArray(items) ? items : []).filter((item) => item !== null && typeof item === "object" && text(item.id) !== "").map((item) => { ++ const id = text(item.id); ++ const source = text([...list, ...archived].find((row) => text(row.id) === id)?.source); ++ const task = byId.get(id); ++ return { id, key: `task:${id}`, title: text(task?.title) || text(task?.note) || null, priority: text(task?.priority) || null, board: boardOfRow({ source }, task), ...annotate(item) }; ++ }); ++ const waiting = queueItems(queues?.waiting, (item) => ({ role: text(item.role) || null, waitedMs: Number.isFinite(item.waitedMs) ? item.waitedMs : null })); ++ const inflight = queueItems(queues?.inflight, (item) => ({ holder: text(item.holder) || null, state: item.stalled === true ? "stalled" : "running" })); ++ const blocked = queueItems(queues?.blocked, (item) => ({ reason: text(item.reason) || null, waitedMs: Number.isFinite(item.waitedMs) ? item.waitedMs : null })); ++ const queueGroups = [ ++ { group: "waiting", label: "Waiting", items: waiting }, ++ { group: "inflight", label: "In flight", items: inflight }, ++ { group: "blocked", label: "Blocked", items: blocked } ++ ].filter((entry) => entry.items.length > 0); ++ const rawCounts = queues?.counts ?? null; ++ return { ++ projectId: text(project?.id) || null, ++ title: text(project?.name) || "Project", ++ canManage: canManage === true, ++ groups, ++ // #1855: one group, closed until the reader opens it. Rows keep the row shape above, so a ++ // finished card is still a card — only its place on the board changed. ++ done: { group: "done", label: "Done cards", collapsed: true, rows: archived.map(projectRow) }, ++ queues: queueGroups, ++ counts: rawCounts === null ? null : { waiting: countOf(rawCounts.waiting), inflight: countOf(rawCounts.inflight), blocked: countOf(rawCounts.blocked), done: countOf(rawCounts.done) }, ++ count: list.length, ++ empty: list.length === 0 && queueGroups.length === 0 && archived.length === 0, ++ emptyText: "No cards yet.", ++ record: projectBoardRecord({ decisions, meetings }), ++ metrics: metricsView(metrics, { tasks }) + }; -+ if (state === null) return null; -+ return (0, import_react19.createElement)( -+ "div", -+ { className: "bt-project-overlay" }, -+ (0, import_react19.createElement)("div", { className: "bt-scrim", onClick: close }), -+ (0, import_react19.createElement)( ++} ++function projectRowNode(row, onOpen) { ++ const open = row.board === null ? void 0 : () => onOpen?.(row.board); ++ return (0, import_react20.createElement)( + "div", +- { className: wide ? "bt-side" : "bt-side bt-rail", onKeyDown: typeToSearch }, +- wide ? (0, import_react8.createElement)( +- "div", +- { className: "bt-side-top" }, +- (0, import_react8.createElement)("span", { className: "bt-brand" }, (0, import_react8.createElement)(BrandMark, { size: 22 }), (0, import_react8.createElement)(BrandName)), +- (0, import_react8.createElement)(UpdateButton, { useUpdate: props.useUpdate, actions }), +- (0, import_react8.createElement)("button", { type: "button", className: "bt-round", "aria-label": t("Search"), title: t("Search"), onClick: () => actions.openPalette() }, (0, import_react8.createElement)(SearchIcon)), +- (0, import_react8.createElement)("button", { type: "button", className: "bt-round", "aria-label": t("New…"), title: t("New…"), onClick: () => actions.openNewChat("new") }, (0, import_react8.createElement)(PlusIcon)) +- ) : (0, import_react8.createElement)( +- import_react8.Fragment, +- null, +- (0, import_react8.createElement)(UpdateButton, { useUpdate: props.useUpdate, actions }), +- (0, import_react8.createElement)("button", { type: "button", className: "bt-round", "aria-label": t("New…"), title: t("New…"), onClick: () => actions.openNewChat("new") }, (0, import_react8.createElement)(PlusIcon)) ++ { ++ key: row.key, ++ className: "bt-project-row", ++ "data-task-id": row.id, ++ "data-column": row.column, ++ "data-priority": row.priority ?? void 0, ++ role: open === void 0 ? void 0 : "button", ++ tabIndex: open === void 0 ? void 0 : 0, ++ onClick: open, ++ onKeyDown: open === void 0 ? void 0 : (event) => { ++ if (event.key === "Enter" || event.key === " ") open(); ++ } ++ }, ++ (0, import_react20.createElement)("span", { className: "bt-project-column" }, t(row.column)), ++ (0, import_react20.createElement)( ++ "span", ++ { className: "bt-project-title-wrap" }, ++ (0, import_react20.createElement)("span", { className: "bt-project-title" }, row.title ?? (row.assignee ? `@${row.assignee}` : "—")), ++ // The audit text the row already carries (#1730): shown inside the card detail, never a ++ // second read. A row without one renders exactly as before. ++ row.audit === null ? null : (0, import_react20.createElement)("span", { className: "bt-project-audit" }, row.audit) + ), +- wide && roster.readOnly ? (0, import_react8.createElement)("div", { className: "bt-hint bt-read-only", role: "status" }, hostText(roster.readOnly)) : null, +- (0, import_react8.createElement)( +- "div", +- { ref: listRef, className: "bt-list", role: "region", "aria-label": t("Bot list") }, +- main ? (0, import_react8.createElement)(BotRow, { ...rowProps, bot: main, marked: markedUnread[main.id] === true }) : roster.ready ? null : (0, import_react8.createElement)("div", { className: "bt-hint" }, t("Setting up your Main Bot…")), +- order.map((entry) => entry.kind === "bot" ? (0, import_react8.createElement)(BotRow, { key: entry.item.id, ...rowProps, bot: entry.item, marked: markedUnread[entry.item.id] === true }) : entry.kind === "room" ? (0, import_react8.createElement)(RoomRow, { key: entry.item.id, ...rowProps, room: entry.item, marked: markedUnread[entry.item.id] === true }) : (0, import_react8.createElement)(AgentRow, { key: entry.item.id, ...rowProps, agent: entry.item, marked: markedUnread[entry.item.id] === true })) +- ) ++ // "Who is doing" is the claim, never the assignee label; a card the Assistant speaks for ++ // says so, so the three identities (Bot, Assistant, user) never read as one. ++ row.holder === null ? null : (0, import_react20.createElement)("span", { className: "bt-project-holder", "data-holder": row.holder }, `@${row.holder}`), ++ row.speaksFor === null ? null : (0, import_react20.createElement)("span", { className: "bt-project-speaks-for", "data-speaks-for": row.speaksFor }, t("for {user}", { user: row.speaksFor })), ++ row.priority === null ? null : (0, import_react20.createElement)("span", { className: "bt-project-priority" }, row.priority), ++ row.runId === null ? null : (0, import_react20.createElement)("span", { className: "bt-project-ref" }, row.nodeId === null ? row.runId : `${row.runId} · ${row.nodeId}`) + ); + } +-function UpdateButton({ useUpdate, actions }) { +- const update = useUpdate((value) => value); +- const [open, setOpen] = (0, import_react8.useState)(false); +- const [box, setBox] = (0, import_react8.useState)(null); +- const [error, setError] = (0, import_react8.useState)(""); +- const [dshError, setDshError] = (0, import_react8.useState)(""); +- const button = (0, import_react8.useRef)(null); +- (0, import_react8.useEffect)(() => { +- if (!open) return void 0; +- const onDown = (event) => { +- if (event.target instanceof Element && (event.target.closest(".bt-update-pop") || button.current?.contains(event.target))) return; +- setOpen(false); +- }; +- const onKey = (event) => { +- if (event.key === "Escape") { +- event.stopPropagation(); +- setOpen(false); +- button.current?.focus(); +- } +- }; +- window.addEventListener("mousedown", onDown); +- window.addEventListener("keydown", onKey, true); +- return () => { +- window.removeEventListener("mousedown", onDown); +- window.removeEventListener("keydown", onKey, true); ++function ProjectBoardRows({ view, onOpen }) { ++ if (view.empty) return (0, import_react20.createElement)("div", { className: "bt-project-empty" }, t(view.emptyText)); ++ return view.groups.map((group) => (0, import_react20.createElement)( ++ "section", ++ { key: group.source, className: "bt-project-group", "data-source": group.source }, ++ (0, import_react20.createElement)("h4", null, t(group.label)), ++ group.rows.map((row) => projectRowNode(row, onOpen)) ++ )); ++} ++function ProjectDoneGroup({ view, onOpen }) { ++ if (view.done.rows.length === 0) return null; ++ return (0, import_react20.createElement)( ++ "details", ++ { className: "bt-project-group bt-project-done", "data-group": "done" }, ++ (0, import_react20.createElement)("summary", { className: "bt-project-done-summary" }, `${t(view.done.label)} · ${view.done.rows.length}`), ++ view.done.rows.map((row) => projectRowNode(row, onOpen)) ++ ); ++} ++function waitedLabel(ms) { ++ if (!Number.isFinite(ms) || ms < 0) return null; ++ if (ms < 6e4) return `${ms === 0 ? 0 : Math.max(1, Math.floor(ms / 1e3))}s`; ++ const minutes = Math.floor(ms / 6e4); ++ if (minutes < 60) return `${minutes}m`; ++ const hours = Math.floor(minutes / 60); ++ if (hours < 24) return `${hours}h`; ++ return `${Math.floor(hours / 24)}d`; ++} ++var METRIC_CARDS = [ ++ { key: "prsOpenedToday", label: "PRs opened today", caption: "Natural day in your time zone, by created_at." }, ++ { key: "prsMergedToday", label: "PRs merged today", caption: "Natural day in your time zone, by merged_at." }, ++ { key: "issuesOpenedToday", label: "Issues opened today", caption: "Natural day in your time zone, by created_at." }, ++ { key: "issuesClosedToday", label: "Issues closed today", caption: "Natural day in your time zone, by closed_at." }, ++ { key: "prMergeMedianMs", label: "PR merge time", caption: "Median of the last 7 days, opened to merged." }, ++ { key: "issueCloseMedianMs", label: "Issue close time", caption: "Median of the last 7 days, opened to closed." }, ++ { key: "taskDoneMedianMs", label: "Task completion", caption: "Median of the last 7 days, created to done." } ++]; ++var METRIC_DASH = "—"; ++var DURATION_KEYS = /* @__PURE__ */ new Set(["prMergeMedianMs", "issueCloseMedianMs", "taskDoneMedianMs"]); ++var RUNNING_OVERDUE_MS = 2 * 60 * 60 * 1e3; ++var metricText = (key, value) => { ++ if (!Number.isFinite(value)) return METRIC_DASH; ++ if (DURATION_KEYS.has(key)) return waitedLabel(value) ?? METRIC_DASH; ++ return String(value); ++}; ++function metricsView(metrics, { tasks } = {}) { ++ const source = metrics !== null && typeof metrics === "object" ? metrics : null; ++ const values = source?.metrics !== null && typeof source?.metrics === "object" ? source.metrics : {}; ++ const byId = new Map((Array.isArray(tasks) ? tasks : []).filter((task) => task !== null && typeof task === "object" && text(task.id) !== "").map((task) => [text(task.id), task])); ++ const running = (Array.isArray(source?.running) ? source.running : []).filter((row) => row !== null && typeof row === "object" && text(row.id) !== "").map((row) => { ++ const id = text(row.id); ++ const task = byId.get(id); ++ return { ++ id, ++ key: "task:" + id, ++ title: text(row.title) || text(task?.title) || text(task?.note) || id, ++ status: text(row.status), ++ who: text(row.who) || null, ++ sinceMs: Number.isFinite(row.sinceMs) ? row.sinceMs : null, ++ source: text(row.source) || null, ++ lastActivityAt: Number.isFinite(row.lastActivityAt) ? row.lastActivityAt : null, ++ overdue: Number.isFinite(row.sinceMs) && row.sinceMs > RUNNING_OVERDUE_MS, ++ board: boardOfRow({ source: text(row.source) }, task) + }; +- }, [open]); +- const phase = update?.phase ?? "idle"; +- const dsh = update?.dsh; +- const dshPhase = dsh?.phase ?? "idle"; +- const dshRow = showsDshRow(dsh, update?.app); +- if (!update?.available && !dshRow && phase === "idle" && dshPhase === "idle") return null; +- const cloudPhase = phase === "installing" || dshPhase === "installing" ? "installing" : phase !== "idle" ? phase : dshPhase; +- const toggle = () => { +- const rect = button.current?.getBoundingClientRect(); +- if (rect) setBox({ left: Math.max(8, Math.min(rect.left, window.innerWidth - 288)), top: rect.bottom + 6 }); +- setOpen((value) => !value); +- }; +- const start = () => { +- setError(""); +- void actions.installUpdate().catch((failure) => setError(hostText(failure?.message ?? String(failure)))); +- }; +- const startDsh = () => { +- setDshError(""); +- void actions.installDshUpdate().catch((failure) => setDshError(hostText(failure?.message ?? String(failure)))); ++ }); ++ return { ++ available: source?.ok === true, ++ reason: text(source?.reason) || null, ++ cards: METRIC_CARDS.map((card) => ({ ...card, value: metricText(card.key, values[card.key]) })), ++ running, ++ empty: source === null, ++ emptyText: "No metrics read yet." + }; +- const label = phase === "installing" ? t("Updating DS Bot…") : dshPhase === "installing" ? t("Updating DSH…") : phase === "installed" ? t("DS Bot {version} is installed. Restart DSH to use it.", { version: update.installed ?? update.latest }) : dshPhase === "installed" ? t("Restart DSH to start using DSH {version}.", { version: dsh.installed ?? dsh.verified }) : update?.available ? t("DS Bot {version} is available", { version: update.latest }) : t("DSH {version} is available", { version: dsh?.verified }); +- const message = error || (phase === "failed" ? update.error : ""); +- const dshMessage = dshError || (dshPhase === "failed" ? dsh?.error : ""); +- const dshText = dshPhase === "installed" ? t("Restart DSH to start using DSH {version}.", { version: dsh.installed ?? dsh.verified }) : dsh?.available === false && dsh?.blockedBy ? t("Update DS Bot {version} first.", { version: dsh.blockedBy }) : t("You have {version}.", { version: dsh?.current }); +- return (0, import_react8.createElement)( +- import_react8.Fragment, +- null, +- (0, import_react8.createElement)("button", { +- ref: button, +- type: "button", +- className: "bt-round bt-update", +- "data-phase": cloudPhase, +- "aria-label": label, +- title: label, +- "aria-haspopup": "dialog", +- "aria-expanded": open, +- onClick: toggle +- }, (0, import_react8.createElement)(CloudDownloadIcon, { done: phase === "installed" }), phase === "failed" || dshPhase === "failed" ? (0, import_react8.createElement)("span", { className: "bt-update-dot", "aria-hidden": true }) : null), +- open && box ? (0, import_react_dom3.createPortal)((0, import_react8.createElement)( ++} ++function ProjectMetrics({ view }) { ++ return (0, import_react20.createElement)( ++ "section", ++ { className: "bt-project-metrics", "data-available": view.available ? "true" : "false" }, ++ view.available || view.reason === null ? null : (0, import_react20.createElement)("p", { className: "bt-project-metrics-note" }, t("Metrics unavailable") + ": " + view.reason), ++ (0, import_react20.createElement)( + "div", +- { className: "bt-update-pop", role: "dialog", "aria-label": update?.available || phase !== "idle" ? t("DS Bot update") : t("DSH update"), style: box }, +- update?.available || phase !== "idle" ? (0, import_react8.createElement)( +- import_react8.Fragment, +- null, +- (0, import_react8.createElement)("div", { className: "bt-update-title" }, phase === "installed" ? t("Update installed") : t("DS Bot {version}", { version: update.latest })), +- (0, import_react8.createElement)("div", { className: "bt-update-text" }, phase === "installed" ? t("Restart DSH to start using DS Bot {version}.", { version: update.installed ?? update.latest }) : t("You have {version}.", { version: update.current })), +- message ? (0, import_react8.createElement)("div", { className: "bt-update-error", role: "alert" }, t("The update did not finish: {reason}", { reason: message })) : null, +- (0, import_react8.createElement)( +- "div", +- { className: "bt-update-actions" }, +- update.notesUrl ? (0, import_react8.createElement)("a", { className: "bt-update-notes", href: update.notesUrl, target: "_blank", rel: "noopener noreferrer" }, t("What's new")) : null, +- phase === "installed" ? null : (0, import_react8.createElement)("button", { +- type: "button", +- className: "bt-update-go", +- disabled: phase === "installing", +- onClick: start +- }, phase === "installing" ? t("Updating…") : phase === "failed" ? t("Try again") : t("Update")) +- ) +- ) : null, +- dshRow ? (0, import_react8.createElement)( ++ { className: "bt-project-metric-cards" }, ++ view.cards.map((card) => (0, import_react20.createElement)( + "div", +- { className: "bt-update-dsh" }, +- (0, import_react8.createElement)("div", { className: "bt-update-title" }, t("DSH {version}", { version: dsh.verified })), +- (0, import_react8.createElement)("div", { className: "bt-update-text" }, dshText), +- dshMessage ? (0, import_react8.createElement)("div", { className: "bt-update-error", role: "alert" }, t("The update did not finish: {reason}", { reason: dshMessage })) : null, +- (0, import_react8.createElement)( +- "div", +- { className: "bt-update-actions" }, +- dsh.kind === "app" ? dsh.available ? (0, import_react8.createElement)("button", { type: "button", className: "bt-update-go", onClick: () => void actions.openAppUpdates(dsh.verified).catch(() => { +- }) }, t("Open updates")) : null : !dsh.available && dshPhase === "idle" ? null : dshPhase === "installed" ? null : (0, import_react8.createElement)("button", { +- type: "button", +- className: "bt-update-go", +- disabled: dshPhase === "installing", +- onClick: startDsh +- }, dshPhase === "installing" ? t("Updating…") : dshPhase === "failed" ? t("Try again") : t("Update")) +- ) +- ) : null +- ), document.body) : null ++ { key: card.key, className: "bt-project-metric", "data-metric": card.key }, ++ (0, import_react20.createElement)("span", { className: "bt-project-metric-value" }, card.value), ++ (0, import_react20.createElement)("span", { className: "bt-project-metric-label" }, t(card.label)), ++ (0, import_react20.createElement)("span", { className: "bt-project-metric-caption" }, t(card.caption)) ++ )) ++ ) + ); + } +-function ConnectPlugins({ wide, actions }) { +- const tipId = (0, import_react8.useId)(); +- const [tip, setTip] = (0, import_react8.useState)(false); +- const timer = (0, import_react8.useRef)(0); +- const button = (0, import_react8.useRef)(null); +- const [box, setBox] = (0, import_react8.useState)(null); +- (0, import_react8.useEffect)(() => () => clearTimeout(timer.current), []); +- if (!wide) return null; +- const show = () => { +- clearTimeout(timer.current); +- timer.current = setTimeout(() => { +- const rect = button.current?.getBoundingClientRect(); +- if (!rect) return; +- const left = Math.max(8, Math.min(rect.left, window.innerWidth - 268)); +- setBox({ left, bottom: window.innerHeight - rect.top + 10 }); +- setTip(true); +- }, 250); ++function ProjectRunningTable({ view, onOpen }) { ++ if (view.running.length === 0) return (0, import_react20.createElement)("section", { className: "bt-project-running", "data-running": "0" }, (0, import_react20.createElement)("h4", null, t("Running tasks")), (0, import_react20.createElement)("div", { className: "bt-project-empty" }, t("Nothing is running."))); ++ return (0, import_react20.createElement)( ++ "section", ++ { className: "bt-project-running", "data-running": String(view.running.length) }, ++ (0, import_react20.createElement)("h4", null, t("Running tasks")), ++ (0, import_react20.createElement)( + "div", -+ { className: "bt-mem bt-project-dialog", role: "dialog", "aria-modal": true, "aria-label": t("Project board") }, -+ (0, import_react19.createElement)( ++ { className: "bt-project-running-head" }, ++ (0, import_react20.createElement)("span", null, t("Task")), ++ (0, import_react20.createElement)("span", null, t("Status")), ++ (0, import_react20.createElement)("span", null, t("Who")), ++ (0, import_react20.createElement)("span", null, t("Claimed for")), ++ (0, import_react20.createElement)("span", null, t("Source")), ++ (0, import_react20.createElement)("span", null, t("Last activity")) ++ ), ++ view.running.map((row) => { ++ const open = row.board === null ? void 0 : () => onOpen?.(row.board); ++ return (0, import_react20.createElement)( + "div", -+ { className: "bt-mem-head" }, -+ (0, import_react19.createElement)("h2", {}, t("Project board")), -+ (0, import_react19.createElement)("span", { className: "bt-mem-head-gap" }), -+ (0, import_react19.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm bt-project-close", autoFocus: true, onClick: close }, t("Close")) -+ ), -+ (0, import_react19.createElement)( ++ { ++ key: row.key, ++ className: "bt-project-running-row", ++ "data-task-id": row.id, ++ "data-overdue": row.overdue ? "true" : "false", ++ role: open === void 0 ? void 0 : "button", ++ tabIndex: open === void 0 ? void 0 : 0, ++ onClick: open ++ }, ++ (0, import_react20.createElement)("span", { className: "bt-project-running-title" }, row.title), ++ (0, import_react20.createElement)("span", { className: "bt-project-running-status" }, t(row.status)), ++ (0, import_react20.createElement)("span", null, row.who ?? METRIC_DASH), ++ (0, import_react20.createElement)("span", null, metricText("since", row.sinceMs)), ++ (0, import_react20.createElement)("span", null, row.source === null ? METRIC_DASH : t(SOURCE_LABELS[row.source] ?? row.source)), ++ (0, import_react20.createElement)("span", null, row.lastActivityAt === null ? METRIC_DASH : new Date(row.lastActivityAt).toLocaleString(dateLocale())) ++ ); ++ }) ++ ); ++} ++function ProjectQueueGroups({ view, onOpen }) { ++ return view.queues.map((entry) => (0, import_react20.createElement)( ++ "section", ++ { key: `queue:${entry.group}`, className: "bt-project-group", "data-queue": entry.group }, ++ (0, import_react20.createElement)("h4", null, t(entry.label)), ++ entry.items.map((row) => { ++ const open = row.board === null ? void 0 : () => onOpen?.(row.board); ++ const note = entry.group === "waiting" ? (0, import_react20.createElement)("span", { className: "bt-project-wait" }, [row.role === null ? null : (0, import_react20.createElement)("span", { key: "role", className: "bt-project-role" }, row.role), waitedLabel(row.waitedMs) === null ? null : ` · ${waitedLabel(row.waitedMs)}`]) : entry.group === "inflight" ? (0, import_react20.createElement)("span", { className: "bt-project-state" }, row.holder === null ? t(row.state) : `${row.holder} · ${t(row.state)}`) : (0, import_react20.createElement)("span", { className: "bt-project-reason" }, row.reason === null ? null : waitedLabel(row.waitedMs) === null ? row.reason : `${row.reason} · ${waitedLabel(row.waitedMs)}`); ++ return (0, import_react20.createElement)( + "div", -+ { className: "bt-project-body" }, -+ state.project === null ? (0, import_react19.createElement)( -+ "div", -+ { className: "bt-project-empty" }, -+ (0, import_react19.createElement)("p", null, t("No Project yet — create one.")), -+ (0, import_react19.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: () => { -+ close(); -+ actions?.openSettings?.("projects"); -+ } }, t("Open Projects in Settings")) -+ ) : panel === "dossier" ? (0, import_react19.createElement)(ProjectDossierPanel, { -+ row: dossierRowOf(state.team, dossier?.botId), -+ label: dossier?.label ?? "", -+ onClose: () => setPanel(null), -+ onOpenBot: (id) => { -+ close(); -+ actions?.openSession?.(id); -+ } -+ }) : panel === "settings" ? (0, import_react19.createElement)(ProjectSettingsPanel, { project: state.project, assistantMine: state.assistantMine, call: read, onClose: () => setPanel(null) }) : panel === "members" ? (0, import_react19.createElement)(ProjectMembersPanel, { project: state.project, rows: state.rows, onClose: () => setPanel(null) }) : (0, import_react19.createElement)(ProjectBoardPane, { -+ project: state.project, -+ rows: state.rows, -+ done: state.done, -+ canManage: state.canManage, -+ tasks: state.tasks, -+ decisions: state.decisions, -+ meetings: state.meetings, -+ queues: state.queues, -+ metrics: state.metrics, -+ team: state.team, -+ // #2041: a decision row opens through the same channel read the page polls with. -+ call: read, -+ onSettings: () => setPanel("settings"), -+ onMembers: () => setPanel("members"), -+ // #1864: a team row's Dossier control opens the read-only panel, fed by the same read. -+ onOpenDossier: (value) => { -+ void openDossier(value); -+ }, -+ // #1860: a team row opens that Bot's chat. The board is a modal over the conversation, -+ // so it closes first — otherwise the chat opens behind it. -+ onOpenBot: (id) => { -+ close(); -+ actions?.openSession?.(id); -+ }, -+ // The task's own board is where a row leads: the Tasks dialog reads `#task=` on mount and -+ // lands on that Bot or group. Nothing here re-decides which board that is. -+ onOpen: (board) => { -+ if (typeof window !== "undefined") window.location.hash = `#task=${board.kind}:${board.id}`; -+ actions?.openBoard?.(); -+ setState(null); ++ { ++ key: row.key, ++ className: "bt-project-row", ++ "data-task-id": row.id, ++ "data-state": row.state ?? void 0, ++ role: open === void 0 ? void 0 : "button", ++ tabIndex: open === void 0 ? void 0 : 0, ++ onClick: open, ++ onKeyDown: open === void 0 ? void 0 : (event) => { ++ if (event.key === "Enter" || event.key === " ") open(); + } -+ }) -+ ) -+ ) ++ }, ++ (0, import_react20.createElement)( ++ "span", ++ { className: "bt-project-title-wrap" }, ++ (0, import_react20.createElement)("span", { className: "bt-project-title" }, row.title ?? `#${row.id}`) ++ ), ++ note ++ ); ++ }) ++ )); ++} ++var countText = (value) => value === null ? t("unknown") : String(value); ++function ProjectBoardRecord({ record, projectId, call: call4 }) { ++ const { decisions, meetings } = record; ++ const [open, setOpen] = (0, import_react20.useState)(null); ++ const toggle = (path) => { ++ if (open !== null && open.path === path) { ++ setOpen(null); ++ return; ++ } ++ if (typeof call4 !== "function") return; ++ setOpen({ path, answer: null }); ++ const settle = (answer) => setOpen((current) => current !== null && current.path === path ? { path, answer } : current); ++ call4("project-decision", { id: projectId, path }).then(settle, (error) => settle({ ok: false, reason: String(error?.message ?? error) })); + }; +- const hide = () => { +- clearTimeout(timer.current); +- setTip(false); ++ const decisionBody = (file) => { ++ if (open === null || open.path !== file.path) return null; ++ const answer = open.answer; ++ if (answer === null) return (0, import_react20.createElement)("div", { className: "bt-project-decision-text", "data-open-decision": file.path }, t("Reading the decision…")); ++ if (answer.ok !== true) return (0, import_react20.createElement)("div", { className: "bt-project-decision-text bt-project-decision-refusal", "data-open-decision": file.path, "data-refused": "true" }, text(answer.reason) || t("not listed")); ++ return (0, import_react20.createElement)("pre", { className: "bt-project-decision-text", "data-open-decision": file.path }, typeof answer.text === "string" ? answer.text : ""); + }; +- return (0, import_react8.createElement)( +- "span", +- { className: "bt-connect-wrap" }, +- (0, import_react8.createElement)( ++ const decisionRows = decisions.files.map((file) => (0, import_react20.createElement)( ++ "div", ++ { key: file.key, className: "bt-project-decision-wrap" }, ++ (0, import_react20.createElement)( + "button", + { +- ref: button, + type: "button", +- className: "bt-connect bt-connect-foot", +- "aria-describedby": tip ? tipId : void 0, +- onMouseEnter: show, +- onMouseLeave: hide, +- onFocus: show, +- onBlur: hide, +- onClick: () => actions.selectPanel("plugins") ++ className: "bt-project-decision", ++ "data-path": file.path, ++ "data-sha256": file.sha256, ++ "data-open": open !== null && open.path === file.path ? "true" : void 0, ++ "aria-expanded": open !== null && open.path === file.path ? "true" : "false", ++ onClick: () => toggle(file.path) + }, +- (0, import_react8.createElement)(PlugIcon), +- t("Connect plugins"), +- (0, import_react8.createElement)("span", { className: "bt-connect-warn", "aria-hidden": true }, "!") ++ (0, import_react20.createElement)("span", { className: "bt-project-date" }, file.date), ++ (0, import_react20.createElement)("span", { className: "bt-project-title" }, file.title === "" ? file.name : file.title), ++ (0, import_react20.createElement)("span", { className: "bt-project-hash", title: file.sha256 }, file.shortHash) + ), +- tip && box ? (0, import_react_dom3.createPortal)((0, import_react8.createElement)( +- "span", +- { role: "tooltip", id: tipId, className: "bt-connect-tip", style: box }, +- t("Plugin compatibility is not fully verified.") +- ), document.body) : null +- ); +-} +-function YouAvatar() { +- return (0, import_react8.createElement)( +- "span", +- { className: "bt-you-wrap" }, +- (0, import_react8.createElement)("span", { className: "bt-you", "aria-hidden": true }, (0, import_react8.createElement)(BotMark, { look: MAIN_LOOK, size: 24 })), +- (0, import_react8.createElement)("span", { style: { position: "absolute", width: 1, height: 1, overflow: "hidden", clip: "rect(0 0 0 0)" } }, t("Settings")) ++ decisionBody(file) ++ )); ++ const recipientText = (list) => list.length === 0 ? null : list.map((name) => `@${name}`).join(", "); ++ const proxyText = (meeting) => meeting.participants.filter((participant) => participant.speaksFor !== null).map((participant) => `${participant.name} ${t("for {user}", { user: participant.speaksFor })}`).join(", "); ++ const proxyRow = (meeting) => proxyText(meeting) === "" ? null : (0, import_react20.createElement)("span", { className: "bt-project-speaks-for", "data-speaks-for": meeting.participants.filter((p) => p.speaksFor !== null).map((p) => p.speaksFor).join(", ") }, proxyText(meeting)); ++ const levelOf = (meeting) => (0, import_react20.createElement)("span", { className: "bt-project-level", "data-level": meeting.level }, t(meeting.level)); ++ const decidedRows = meetings.decided.map((meeting) => (0, import_react20.createElement)( ++ "div", ++ { key: meeting.key, className: "bt-project-meeting", "data-status": "decided" }, ++ (0, import_react20.createElement)("span", { className: "bt-project-date" }, meeting.date), ++ (0, import_react20.createElement)("span", { className: "bt-project-title" }, meeting.question), ++ meeting.owner === null ? null : (0, import_react20.createElement)("span", { className: "bt-project-owner" }, `@${meeting.owner}`), ++ proxyRow(meeting), ++ // A decided meeting whose file is gone still reads as decided; the missing path says so ++ // instead of the row disappearing with the file. ++ meeting.path === "" ? (0, import_react20.createElement)("span", { className: "bt-project-missing" }, "decision file gone") : (0, import_react20.createElement)("span", { className: "bt-project-path" }, meeting.path) ++ )); ++ const pendingRows = meetings.pending.map((meeting) => (0, import_react20.createElement)( ++ "div", ++ { key: meeting.key, className: "bt-project-meeting", "data-status": meeting.status, "data-level": meeting.level }, ++ (0, import_react20.createElement)("span", { className: "bt-project-date" }, meeting.date), ++ (0, import_react20.createElement)("span", { className: "bt-project-title" }, meeting.blockingQuestion === null ? meeting.question : meeting.blockingQuestion), ++ levelOf(meeting), ++ recipientText(meeting.recipients) === null ? null : (0, import_react20.createElement)("span", { className: "bt-project-recipients" }, recipientText(meeting.recipients)), ++ meeting.advance.allowed ? null : (0, import_react20.createElement)("span", { className: "bt-project-paused" }, meeting.advance.reason), ++ proxyRow(meeting), ++ (0, import_react20.createElement)("span", { className: "bt-project-status" }, meeting.status) ++ )); ++ const moreLine = (hidden, key) => hidden > 0 ? (0, import_react20.createElement)("div", { key, className: "bt-project-more" }, t("{count} more not shown", { count: hidden })) : null; ++ return (0, import_react20.createElement)( ++ "section", ++ { ++ className: "bt-project-record", ++ // `data-decisions` stays "rows drawn"; the total rides beside it so the view can be asserted. ++ "data-decisions": String(decisions.count), ++ ...decisions.total === null ? {} : { "data-decisions-total": String(decisions.total) }, ++ "data-meetings": String(meetings.count) ++ }, ++ (0, import_react20.createElement)("h4", null, t("Decisions")), ++ decisions.empty ? (0, import_react20.createElement)("div", { className: "bt-project-empty bt-project-record-empty" }, t(decisions.emptyText)) : decisionRows, ++ moreLine(decisions.hidden, "decisions-more"), ++ (0, import_react20.createElement)("h4", null, t("Meetings")), ++ meetings.empty ? (0, import_react20.createElement)("div", { className: "bt-project-empty bt-project-record-empty" }, t(meetings.emptyText)) : decidedRows, ++ moreLine(meetings.decidedHidden, "decided-more"), ++ meetings.pending.length === 0 ? null : (0, import_react20.createElement)("h5", null, t("Not decided")), ++ meetings.pending.length === 0 ? null : pendingRows, ++ moreLine(meetings.pendingHidden, "pending-more") ); } --var RETURN_CSS = ` --.bt-return{display:inline-flex;align-items:center;justify-content:center;gap:8px;min-height:36px;padding:0 14px;border:0;border-radius:999px;background:none;color:inherit;font:inherit;font-size:14px;line-height:20px;cursor:pointer;transition:background-color .12s ease} --.bt-return:hover{background:rgba(127,127,127,.14)} --.bt-return:active{transform:scale(.96)} --.bt-return .bt-mark{display:inline-flex;flex:none;line-height:0} --.bt-return .bt-mark svg{width:100%;height:100%;overflow:visible} --/* Shell styles carry --bt-ink-deepseek and Agent mode withdraws them; pin it here. */ --.bt-return .bt-mark{--bt-ink-deepseek:#4D6BFE} --.bt-return-name{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} --`; --function BrandMark({ size = 24 }) { -- return (0, import_react8.createElement)("span", { className: "bt-brand-whale" }, (0, import_react8.createElement)(BotMark, { look: MAIN_LOOK, size })); +-var openShellSettings = null; +-var openShellOnboarding = null; +-var shellShortcuts = null; +-function holdShellShortcuts(shortcuts) { +- shellShortcuts = shortcuts; +- return () => { +- if (shellShortcuts === shortcuts) shellShortcuts = null; ++var botIdOf = (entry) => entry !== null && typeof entry === "object" ? text(entry.botId) : ""; ++var idList = (value) => Array.isArray(value) ? value.map((id) => text(id)).filter((id) => id !== "") : []; ++function projectTeamView(project) { ++ const stored = project?.roles !== null && typeof project?.roles === "object" ? project.roles : {}; ++ const owner = []; ++ const addOwner = (id) => { ++ const clean = text(id); ++ if (clean !== "" && !owner.includes(clean)) owner.push(clean); ++ }; ++ addOwner(project?.ownerUserId); ++ for (const id of idList(project?.ownerUserIds)) addOwner(id); ++ const maintainers = idList(stored.maintainers).filter((id) => !owner.includes(id)); ++ const members = idList(stored.members).filter((id) => !owner.includes(id) && !maintainers.includes(id)); ++ return { ++ chief: botIdOf(project?.chief), ++ agent: botIdOf(project?.assistant), ++ roles: { owner, maintainers, members }, ++ empty: owner.length === 0 && maintainers.length === 0 && members.length === 0, ++ // Both come from the `projects` listing (#1828), which the overlay keeps on the record: the ++ // `project-read` record answers no rank, and the panel never re-derives the host's policy. ++ role: text(project?.role), ++ choices: idList(project?.choices) + }; + } +-function openHarnessSettings() { +- requestAnimationFrame(() => { +- if (openShellSettings) { +- openShellSettings(); +- return; ++var teamRow = (role, label, botId, onOpenBot, onOpenDossier) => (0, import_react20.createElement)( ++ "div", ++ { ++ key: role, ++ className: "bt-project-team-row", ++ "data-team-role": role, ++ // A row with no Bot is the fallback the slice specifies: still drawn, with nothing to open. ++ ...botId === "" ? { "data-team-state": "not-set" } : { ++ "data-open-bot": botId, ++ role: "button", ++ tabIndex: 0, ++ "aria-label": t("Open {name}'s chat", { name: botId }), ++ onClick: () => onOpenBot?.(botId), ++ onKeyDown: (event) => { ++ if (event.key === "Enter" || event.key === " ") { ++ event.preventDefault(); ++ onOpenBot?.(botId); ++ } ++ } + } +- const desktop = document.querySelector('button[aria-haspopup="menu"][data-signed-out]'); +- if (desktop) openThroughMenu(desktop); +- else pressSettingsShortcut(); +- }); -} --function BrandName() { -- return (0, import_react8.createElement)("span", { style: { fontWeight: 600, fontSize: 14 } }, "DS Bot"); +-function openHarnessOnboarding(id) { +- requestAnimationFrame(() => { +- if (openShellOnboarding) openShellOnboarding(id); +- else openHarnessSettings(); +- }); -} --var SIDEBAR_CSS = ` --.bt-side-top .bt-brand{cursor:default;padding-left:6px} --.bt-side-top .bt-brand:hover{background:none} --.bt-brand .bt-mark{flex:none} --.bt-pin{flex:none;display:inline-flex;color:var(--bt-ink-3)} --.bt-rail .bt-row{position:relative} --.bt-rail .bt-pin{position:absolute;top:3px;left:calc(50% + 12px)} --.bt-side .bt-read-only{margin:4px 8px 6px;padding:8px 10px;border-radius:10px;background:var(--bt-hover);font-size:12px;line-height:1.45;text-align:left} --[class*="_footArea"]:has(.bt-foot-account){display:flex!important;flex-direction:row!important;align-items:center;gap:6px} --[class*="_footArea"]:has(.bt-foot-account)>[class*="_footerActions"],[class*="_footArea"]:has(.bt-foot-account)>[class*="_footerActions"]>div{display:contents!important} --.bt-foot-account{order:-1;flex:none;display:inline-flex} --[class*="_footArea"]:has(.bt-foot-account) .bt-connect-foot{order:0;flex:1 1 auto;min-width:0;width:auto} --[class*="_footArea"]:has(.bt-foot-account)>[class*="_settingsArea"]{order:1;flex:none} --[class*="_footArea"]:has(.bt-foot-account)>[class*="_settingsArea"] button[class*="_trigger"] [class*="_label"]{display:none!important} -- --/* The update cloud springs in when a newer DS Bot shows up; its arrow drops in a -- loop while the install runs. */ --.bt-round.bt-update{position:relative;color:var(--bt-accent,#4D6BFE);animation:bt-update-in ${SPRING_SHAPE.duration}ms ${SPRING_SHAPE.easing}} --.bt-round.bt-update svg{width:17px;height:17px} --.bt-round.bt-update[data-phase=installed]{color:var(--bt-ink-2)} --@keyframes bt-update-in{from{opacity:0;transform:scale(.6);filter:blur(4px)}} --.bt-update[data-phase=installing] .bt-cloud-arrow{animation:bt-cloud-drop 1.1s cubic-bezier(.45,0,.55,1) infinite} --@keyframes bt-cloud-drop{0%{transform:translateY(-3px);opacity:0}35%{opacity:1}70%{transform:translateY(1px);opacity:1}100%{transform:translateY(2px);opacity:0}} --.bt-update-dot{position:absolute;top:6px;right:6px;width:7px;height:7px;border-radius:50%;background:#E5484D;box-shadow:0 0 0 1.5px var(--bt-sidebar)} --.bt-update-pop{position:fixed;z-index:60;width:280px;padding:14px 14px 12px;border-radius:14px;background:var(--bt-main);border:.5px solid var(--bt-line-2);box-shadow:0 10px 20px -3px rgba(0,0,0,.1),0 4px 6px -4px rgba(0,0,0,.1);color:var(--bt-ink);box-sizing:border-box;transform-origin:top left;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",system-ui,sans-serif;animation:bt-update-pop ${SPRING_SHAPE.duration}ms ${SPRING_SHAPE.easing}} --@keyframes bt-update-pop{from{opacity:0;transform:scale(.96);filter:blur(4px)}} --.bt-update-title{font-size:14px;line-height:20px;font-weight:600} --.bt-update-text{margin-top:2px;font-size:12.5px;line-height:18px;color:var(--bt-ink-2)} --.bt-update-error{margin-top:8px;font-size:12px;line-height:17px;color:#c21d2e;overflow-wrap:anywhere;max-height:90px;overflow:auto} --.bt-update-actions{display:flex;align-items:center;justify-content:flex-end;gap:10px;margin-top:12px} --.bt-update-notes{margin-right:auto;font-size:12.5px;color:var(--bt-ink-2);text-decoration:none} --.bt-update-notes:hover{color:var(--bt-ink);text-decoration:underline} --.bt-update-go{height:30px;padding:0 14px;border:0;border-radius:999px;background:var(--bt-accent,#4D6BFE);color:var(--bt-accent-ink,#fff);font:inherit;font-size:13px;font-weight:500;cursor:pointer;transition:opacity .12s ease,transform .12s ease} --.bt-update-go:hover:not(:disabled){opacity:.9} --.bt-update-go:active:not(:disabled){transform:scale(.96)} --.bt-update-go:disabled{opacity:.6;cursor:default} --.bt-update-dsh:not(:first-child){margin-top:12px;padding-top:12px;border-top:.5px solid var(--bt-line-2)} --@media (prefers-reduced-motion:reduce){.bt-round.bt-update,.bt-update-pop,.bt-update .bt-cloud-arrow{animation:none}} -- --/* The DSH Agent row and its expanding Session list. The black whale is the -- harness's own mark, so the row reads as "plain dsh" next to the Bot marks. */ --.bt-agent-ava{flex:none;width:36px;height:36px;border-radius:50%;display:inline-flex;align-items:center;justify-content:center;background:var(--bt-hover);color:var(--bt-ink)} --.bt-agent-ava.bt-run{animation:bt-agent-pulse 1.4s ease-in-out infinite} --@keyframes bt-agent-pulse{0%,100%{opacity:1}50%{opacity:.55}} --.bt-agent-sub{display:grid;grid-template-rows:0fr;transition:grid-template-rows ${SPRING_SHAPE.duration}ms ${SPRING_SHAPE.easing}} --.bt-agent-sub[data-open]{grid-template-rows:1fr} --.bt-agent-sub-in{overflow:hidden;min-height:0;margin-left:44px} --.bt-agent-sub-row{display:flex;align-items:center;gap:8px;width:100%;padding:5px 10px 5px 8px;border:0;border-radius:8px;background:none;color:var(--bt-ink-2);font:inherit;font-size:13px;line-height:20px;text-align:left;cursor:pointer} --.bt-agent-sub-row:hover{background:var(--bt-hover);color:var(--bt-ink)} --.bt-agent-sub-row[aria-current=page]{background:var(--bt-hover);color:var(--bt-ink);font-weight:500} --.bt-agent-sub-title{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} --.bt-agent-sub-time{flex:none;font-size:11px;color:var(--bt-ink-3)} --.bt-agent-new{color:var(--bt-ink-3)} --.bt-agent-new svg{width:14px;height:14px;flex:none} --.bt-agent-edit{padding:3px 10px 3px 4px} --.bt-agent-edit input{width:100%;border:0;border-radius:6px;background:var(--bt-hover);color:var(--bt-ink);font:inherit;font-size:13px;line-height:20px;padding:2px 8px;outline:none;box-shadow:inset 0 0 0 .5px var(--bt-line-2)} --.bt-agent-sub[data-open] .bt-agent-sub-row{animation:bt-agent-row-in .24s ease both;animation-delay:calc(var(--i, 0)*30ms)} --@keyframes bt-agent-row-in{from{opacity:0;filter:blur(4px)}to{opacity:1;filter:blur(0)}} --@media (prefers-reduced-motion:reduce){.bt-agent-sub{transition:none}.bt-agent-sub .bt-agent-sub-row{animation:none}} --`; - - // src/client/header.js --var import_react9 = require("react"); --var import_react_dom4 = require("react-dom"); --var GoArrow = () => (0, import_react9.createElement)("svg", { width: 14, height: 14, viewBox: "-12 -12 24 24", fill: "none", stroke: "currentColor", strokeWidth: 2.2, strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": true }, (0, import_react9.createElement)("path", { d: "M-7 0H7M2 -5L7 0L2 5" })); -+var import_react20 = require("react"); -+var import_react_dom6 = require("react-dom"); -+var GoArrow = () => (0, import_react20.createElement)("svg", { width: 14, height: 14, viewBox: "-12 -12 24 24", fill: "none", stroke: "currentColor", strokeWidth: 2.2, strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": true }, (0, import_react20.createElement)("path", { d: "M-7 0H7M2 -5L7 0L2 5" })); - var MEMORY_NOTICE_MS = 3200; - var noNews = (select) => select({}); - function useMemoryNotice(ids, useMemoryNews) { - const seen = useMemoryNews((map) => Math.max(0, ...ids.map((id) => map?.[id]?.seen ?? 0))); -- const [shown, setShown] = (0, import_react9.useState)(false); -- (0, import_react9.useEffect)(() => { -+ const [shown, setShown] = (0, import_react20.useState)(false); -+ (0, import_react20.useEffect)(() => { - const left = seen + MEMORY_NOTICE_MS - Date.now(); - setShown(seen > 0 && left > 0); - if (seen === 0 || left <= 0) return void 0; -@@ -5512,19 +5512,19 @@ function useMemoryNotice(ids, useMemoryNews) { - return shown; +-function openThroughMenu(trigger) { +- const before = new Set(document.querySelectorAll('[role="menu"]')); +- trigger.click(); +- let frames = 0; +- const look = () => { +- const menu = [...document.querySelectorAll('[role="menu"]')].find((node) => !before.has(node)); +- const item = menu?.querySelector('button[role="menuitem"]'); +- if (item) item.click(); +- else if (++frames < 30) requestAnimationFrame(look); +- else pressSettingsShortcut(); +- }; +- requestAnimationFrame(look); ++ }, ++ (0, import_react20.createElement)("span", { key: "label", className: "bt-project-team-label" }, label), ++ botId === "" ? (0, import_react20.createElement)("span", { key: "value", className: "bt-project-missing" }, t("Not set")) : (0, import_react20.createElement)("span", { key: "value", className: "bt-project-team-bot" }, `@${botId}`), ++ // #1864: the dossier control sits inside the row, so the row still opens the chat (#1860) and a ++ // reader can look a Bot up first. A row with no Bot keeps the control: its panel reads "not set". ++ (0, import_react20.createElement)("button", { ++ key: "dossier", ++ type: "button", ++ className: "bt-project-team-dossier", ++ "data-dossier-role": role, ++ ...botId === "" ? {} : { "data-dossier-bot": botId }, ++ "aria-label": t("Open {name}'s dossier", { name: botId === "" ? label : botId }), ++ onClick: (event) => { ++ event?.stopPropagation(); ++ onOpenDossier?.({ role, label, botId }); ++ }, ++ onKeyDown: (event) => { ++ if (event.key === "Enter" || event.key === " ") { ++ event?.stopPropagation(); ++ onOpenDossier?.({ role, label, botId }); ++ } ++ } ++ }, t("Dossier")) ++); ++var POOL_RANKS = [["owner", "owner"], ["maintainer", "maintainers"], ["member", "members"]]; ++function ProjectTeam({ project, onOpenBot, onOpenDossier }) { ++ const view = projectTeamView(project); ++ const poolId = (id) => (0, import_react20.createElement)("span", { key: id }, `@${id}`); ++ const ranks = view.empty ? (0, import_react20.createElement)("span", { className: "bt-project-missing" }, t("No one in this pool yet.")) : (0, import_react20.createElement)( ++ "span", ++ { className: "bt-project-team-roles" }, ++ POOL_RANKS.map(([role, key]) => view.roles[key].length === 0 ? null : (0, import_react20.createElement)( ++ "span", ++ { key: role, className: "bt-project-team-role", "data-pool-role": role }, ++ `${roleText(role)}: `, ++ view.roles[key].map((id) => poolId(id)) ++ )) ++ ); ++ return (0, import_react20.createElement)( ++ "section", ++ { className: "bt-project-team", "data-team": "chief,agent,pool" }, ++ (0, import_react20.createElement)("h4", null, t("Project team")), ++ teamRow("chief", t("Project Chief"), view.chief, onOpenBot, onOpenDossier), ++ teamRow("agent", t("My agent"), view.agent, onOpenBot, onOpenDossier), ++ (0, import_react20.createElement)( ++ "div", ++ { key: "pool", className: "bt-project-team-row", "data-team-role": "pool" }, ++ (0, import_react20.createElement)("span", { key: "label", className: "bt-project-team-label" }, t("Role pool")), ++ ranks, ++ // The caller's own rank and what the host said they may switch to: the same answer the ++ // sidebar menu reads, printed where the pool is. ++ view.role === "" ? null : (0, import_react20.createElement)( ++ "span", ++ { key: "mine", className: "bt-project-team-myrole", "data-team-my-role": view.role }, ++ [ ++ t("My role"), ++ ": ", ++ roleText(view.role), ++ view.choices.length === 0 ? null : ` · ${t("Can switch to {roles}", { roles: view.choices.map(roleText).join(", ") })}` ++ ] ++ ) ++ ) ++ ); } - function MemoryNotice({ shown }) { -- const inner = (0, import_react9.useRef)(null); -- const [width, setWidth] = (0, import_react9.useState)(0); -+ const inner = (0, import_react20.useRef)(null); -+ const [width, setWidth] = (0, import_react20.useState)(0); - const label = t("Memory updated"); -- (0, import_react9.useLayoutEffect)(() => { -+ (0, import_react20.useLayoutEffect)(() => { - if (shown) setWidth(inner.current?.scrollWidth ?? 0); - }, [label, shown]); -- return (0, import_react9.createElement)( +-function pressSettingsShortcut() { +- const binding = shellShortcuts?.catalog.getSnapshot().find((row) => row.id === "settings.open")?.binding; +- if (!binding) return; +- const held = new Set(binding.modifiers); +- window.dispatchEvent(new KeyboardEvent("keydown", { +- code: binding.code, +- key: binding.code === "Comma" ? "," : "", +- bubbles: true, +- cancelable: true, +- ctrlKey: held.has("control"), +- altKey: held.has("alt"), +- shiftKey: held.has("shift"), +- metaKey: held.has("meta") +- })); ++function dossierRowOf(team, botId) { ++ const id = text(botId); ++ if (id === "") return null; ++ const rows = Array.isArray(team?.rows) ? team.rows : []; ++ return rows.find((row) => row !== null && typeof row === "object" && text(row.botId) === id) ?? null; ++} ++var dossierSection = (title, children) => (0, import_react20.createElement)( ++ "div", ++ { key: title, className: "bt-project-dossier-section" }, ++ (0, import_react20.createElement)("h4", null, title), ++ children ++); ++function ProjectDossierPanel({ row, label, onClose, onOpenBot } = {}) { ++ const known = row !== null && typeof row === "object"; ++ const botId = known ? text(row.botId) : ""; ++ const topics = known && Array.isArray(row.memory?.topics) ? row.memory.topics.map((name) => text(name)).filter((name) => name !== "") : []; ++ const skills = known && Array.isArray(row.skills) ? row.skills.map((name) => text(name)).filter((name) => name !== "") : []; ++ const meta = known ? [text(row.role), text(row.model)].filter((value) => value !== "") : []; + return (0, import_react20.createElement)( - "span", - { className: "bt-pill-mem", "aria-hidden": true, style: { "--bt-mem-w": `${width}px` } }, -- (0, import_react9.createElement)("span", { className: "bt-pill-mem-in", ref: inner }, (0, import_react9.createElement)("span", { className: "bt-pill-mem-ico" }, (0, import_react9.createElement)(MemoryIcon)), label) -+ (0, import_react20.createElement)("span", { className: "bt-pill-mem-in", ref: inner }, (0, import_react20.createElement)("span", { className: "bt-pill-mem-ico" }, (0, import_react20.createElement)(MemoryIcon)), label) - ); ++ "div", ++ { className: "bt-project-panel bt-project-dossier", "data-panel": "project-dossier", "data-dossier-bot": botId === "" ? void 0 : botId }, ++ (0, import_react20.createElement)("h3", null, label === "" || label === void 0 ? t("Dossier") : `${label} · ${t("Dossier")}`), ++ known ? [ ++ dossierSection(t("Persona"), [ ++ meta.length === 0 ? null : (0, import_react20.createElement)("div", { key: "meta", className: "bt-project-dossier-meta" }, meta.map((value) => (0, import_react20.createElement)("span", { key: value }, value))), ++ (0, import_react20.createElement)("p", { key: "persona", className: "bt-project-dossier-text" }, text(row.persona) || t("Not set")) ++ ]), ++ dossierSection(t("Memory"), [ ++ (0, import_react20.createElement)("p", { key: "head", className: "bt-project-dossier-text" }, text(row.memory?.head) || t("No memory yet.")), ++ topics.length === 0 ? null : (0, import_react20.createElement)("div", { key: "topics", className: "bt-project-dossier-topics" }, topics.map((topic) => (0, import_react20.createElement)("span", { key: topic, className: "bt-project-dossier-topic" }, topic))) ++ ]), ++ dossierSection(t("Skills"), (0, import_react20.createElement)("p", { key: "skills", className: "bt-project-dossier-text" }, skills.length === 0 ? t("No skills attached.") : skills.join(", "))) ++ ] : (0, import_react20.createElement)("p", { className: "bt-project-missing" }, t("Not set")), ++ botId === "" || !known ? null : (0, import_react20.createElement)( ++ "div", ++ { key: "actions", className: "bt-project-row-actions" }, ++ (0, import_react20.createElement)("button", { ++ type: "button", ++ className: "bt-soft bt-soft-sm", ++ "data-open-bot": botId, ++ onClick: () => onOpenBot?.(botId) ++ }, t("Open chat")) ++ ), ++ (0, import_react20.createElement)( ++ "div", ++ { className: "bt-project-row-actions" }, ++ (0, import_react20.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: () => onClose?.() }, t("Back to board")) ++ ) ++ ); } --function HeaderPill({ sessionId, useRoster, useSessions, useSessionStatus, useSession, useActivity, useMemoryNews, useTurnClock, useUi, useLive, actions }) { -+function HeaderPill({ sessionId, useRoster, useSessions, useSessionStatus, useSession, useActivity, useBusyAcks, useMemoryNews, useTurnClock, useUi, useLive, actions }) { - const roster = useRoster((value) => value); - const title = useSessions((list) => list.byId[sessionId]?.displayTitle ?? list.byId[sessionId]?.title ?? ""); - const firstTurns = useSessions((list) => { -@@ -5537,7 +5537,7 @@ function HeaderPill({ sessionId, useRoster, useSessions, useSessionStatus, useSe - const first = openingTurn(state[sessionId], firstTurns, hiddenTurns); - return first && !unseenSince(visit, first.start) ? clockLabel(first.start) : ""; - }); -- (0, import_react9.useEffect)(() => { -+ (0, import_react20.useEffect)(() => { - if (opening) document.body.style.setProperty("--bt-first-sep", JSON.stringify(opening)); - else document.body.style.removeProperty("--bt-first-sep"); - return () => document.body.style.removeProperty("--bt-first-sep"); -@@ -5546,187 +5546,926 @@ function HeaderPill({ sessionId, useRoster, useSessions, useSessionStatus, useSe - const owner = roster.byId[sessionId]?.id ?? sessionId; - const doing = useActivity((map) => map[owner]); - const bot = roster.byId[sessionId]; -+ const projectAgent = projectAgentFor(bot, roster.self?.memberId); - const room2 = roster.roomsById[sessionId]; - const detailsOpen = useUi((state) => state.details === sessionId); -- const name = bot?.name ?? room2?.name ?? ""; -+ const full2 = bot?.remote ? friendBotTitle(bot) : roomTitle(room2) || bot?.name || ""; -+ const face = room2?.origin === "face-to-face"; -+ const name = face ? faceRowTitle(full2) : full2; - const useNews = useMemoryNews ?? noNews; - const memoryIds = bot ? [bot.id] : room2?.members ?? []; - const notice = useMemoryNotice(memoryIds, useNews); -- const changed = useNews((map) => latestMemoryChange(map, memoryIds)); -- (0, import_react9.useEffect)(() => { -- document.body.style.setProperty("--bt-placeholder", JSON.stringify(name ? t("Message {name}", { name }) : t("Message"))); -- }, [name, t("Message")]); -- (0, import_react9.useEffect)(() => { -- actions.beginVisit(sessionId); -- return () => actions.endVisit(sessionId); -- }, [sessionId]); -- (0, import_react9.useEffect)(() => { -- if (room2) document.body.dataset.btRoom = ""; -- else delete document.body.dataset.btRoom; +-function AccountLauncher({ seat = true, settingsOpen, openSettings, openOnboarding, actions }) { +- const [open, setOpen] = (0, import_react8.useState)(false); +- (0, import_react8.useLayoutEffect)(() => { +- if (!seat) return void 0; +- actions.seatAccount(true); +- return () => actions.seatAccount(false); +- }, [seat]); +- const [box, setBox] = (0, import_react8.useState)(null); +- const button = (0, import_react8.useRef)(null); +- (0, import_react8.useEffect)(() => { +- if (settingsOpen) setOpen(false); +- }, [settingsOpen]); +- (0, import_react8.useEffect)(() => { +- if (!openSettings) return void 0; +- openShellSettings = openSettings; - return () => { -- delete document.body.dataset.btRoom; +- if (openShellSettings === openSettings) openShellSettings = null; - }; -- }, [Boolean(room2)]); -- const memoryNotice = notice && changed !== null && roster.memory !== false; -- return (0, import_react9.createElement)( -+ const changed2 = useNews((map) => latestMemoryChange(map, memoryIds)); -+ const memoryNotice = notice && changed2 !== null && roster.memory !== false; +- }, [openSettings]); +- (0, import_react8.useEffect)(() => { +- if (!openOnboarding) return void 0; +- openShellOnboarding = openOnboarding; +- return () => { +- if (openShellOnboarding === openOnboarding) openShellOnboarding = null; +- }; +- }, [openOnboarding]); +- (0, import_react8.useEffect)(() => { +- if (!open) return void 0; +- const onDown = (event) => { +- if (event.target instanceof Element && (event.target.closest(".bt-account-menu") || button.current?.contains(event.target))) return; +- setOpen(false); +- }; +- const onKey = (event) => { +- if (event.key === "Escape") { +- event.stopPropagation(); +- setOpen(false); +- button.current?.focus(); ++function projectAgentFor(bot, selfId) { ++ if (bot === null || typeof bot !== "object" || bot.remote !== void 0) return null; ++ const projectId = text(bot.projectId); ++ if (projectId === "") return null; ++ const owner = text(bot.ownerId); ++ const self = text(selfId); ++ if (self !== "" && owner !== "" && owner !== self) return null; ++ return { projectId }; ++} ++function ProjectAgentEntry({ projectId, read, onOpen } = {}) { ++ const [agentId, setAgentId] = (0, import_react20.useState)(""); ++ const id = text(projectId); ++ const open = async () => { ++ let target = agentId; ++ if (target === "") { ++ try { ++ const record = await (read ?? call)("project-read", { id }); ++ target = botIdOf(record?.project?.assistant); ++ } catch { ++ target = ""; + } +- }; +- window.addEventListener("mousedown", onDown); +- window.addEventListener("keydown", onKey, true); +- return () => { +- window.removeEventListener("mousedown", onDown); +- window.removeEventListener("keydown", onKey, true); +- }; +- }, [open]); +- const toggle = () => { +- const rect = button.current?.getBoundingClientRect(); +- if (rect) setBox({ left: rect.left, bottom: window.innerHeight - rect.top + 5 }); +- setOpen((value) => !value); ++ if (target !== "") setAgentId(target); ++ } ++ if (target !== "") onOpen?.(target); + }; +- const groups = [ +- [ +- [t("Usage"), GaugeIcon, () => actions.openSettings("usage")], +- [t("Connectors"), ConnectorIcon, () => actions.openConnectors()], +- [t("Automation tasks"), ClockIcon, () => actions.selectPanel("schedules")], +- [t("Bot settings"), GearIcon, () => actions.openSettings("general")] +- ], +- [ +- [t("Switch to classic Agent mode"), SwitchIcon, () => actions.setSurface("agent")] +- ] +- ]; +- return (0, import_react8.createElement)( +- import_react8.Fragment, +- null, +- (0, import_react8.createElement)( +- "button", +- { ref: button, type: "button", className: "bt-account", "aria-label": t("Account"), "aria-haspopup": "menu", "aria-expanded": open, onClick: toggle }, +- (0, import_react8.createElement)("span", { className: "bt-you", "aria-hidden": true }, (0, import_react8.createElement)(BotMark, { look: MAIN_LOOK, size: 24 })) ++ if (id === "") return null; ++ return (0, import_react20.createElement)("button", { ++ type: "button", ++ className: "bt-project-agent-entry", ++ "data-project-agent": id, ++ "data-open-bot": agentId === "" ? void 0 : agentId, ++ title: t("Open my agent"), ++ "aria-label": t("Open my agent"), ++ onClick: open ++ }, t("My agent")); ++} ++function ProjectBoardPane({ project, rows, done, canManage, tasks, decisions, meetings, queues, metrics, onOpen, onSettings, onMembers, onOpenBot, onOpenDossier, call: call4 }) { ++ const view = projectBoardView({ project, rows, done, canManage, tasks, decisions, meetings, queues, metrics }); + return (0, import_react20.createElement)( - "div", - { className: "bt-head" }, -- (0, import_react9.createElement)( ++ "div", ++ { className: "bt-project-board", "data-can-manage": view.canManage ? "true" : "false" }, ++ (0, import_react20.createElement)("h3", null, view.title), ++ (0, import_react20.createElement)(ProjectTeam, { project, onOpenBot, onOpenDossier }), ++ // Header counts (#1768): four numbers read from the host, an unreadable one shown as unknown. ++ view.counts === null ? null : (0, import_react20.createElement)( ++ "div", ++ { className: "bt-project-counts", "data-counts": "waiting,inflight,blocked,done" }, ++ (0, import_react20.createElement)("span", null, [t("Waiting"), ": ", (0, import_react20.createElement)("b", { key: "w" }, countText(view.counts.waiting))]), ++ (0, import_react20.createElement)("span", null, [t("In flight"), ": ", (0, import_react20.createElement)("b", { key: "f" }, countText(view.counts.inflight))]), ++ (0, import_react20.createElement)("span", null, [t("Blocked"), ": ", (0, import_react20.createElement)("b", { key: "b" }, countText(view.counts.blocked))]), ++ (0, import_react20.createElement)("span", null, [t("Done"), ": ", (0, import_react20.createElement)("b", { key: "d" }, countText(view.counts.done))]) + ), +- open && box ? (0, import_react_dom3.createPortal)((0, import_react8.createElement)( ++ // #1749: both buttons open a panel and neither is hidden from a non-owner. The host refuses ++ // every write a non-owner attempts, so the refusal is an answer the reader sees, not a ++ // control that silently disappeared. + (0, import_react20.createElement)( - "button", - { - type: "button", - className: "bt-pill", - "aria-expanded": detailsOpen, - "data-memory": memoryNotice ? "updated" : void 0, -- "aria-label": memoryNotice ? t("Memory updated. Open {name}'s memory", { name: roster.byId[changed]?.name ?? name }) : t("View conversation details"), -- onClick: () => memoryNotice ? actions.openMemory(changed) : actions.toggleDetails(sessionId) -+ "aria-label": memoryNotice ? t("Memory updated. Open {name}'s memory", { name: roster.byId[changed2]?.name ?? full2 }) : t("View conversation details"), -+ onClick: () => memoryNotice ? actions.openMemory(changed2) : actions.toggleDetails(sessionId) - }, -- bot ? (0, import_react9.createElement)(BotAvatar, { bot, size: 24, state: botState(running, doing, roster.questions?.[currentPart(bot)] !== void 0), badge: false }) : room2 ? (0, import_react9.createElement)(RoomAvatar, { room: room2, roster, size: 24 }) : null, -- (0, import_react9.createElement)("span", { className: "bt-pill-name" }, bot?.name ?? room2?.name ?? (title || t("New chat"))), -- (0, import_react9.createElement)(MemoryNotice, { shown: memoryNotice }), -- (0, import_react9.createElement)("span", { className: "bt-pill-go", "aria-hidden": true }, (0, import_react9.createElement)(GoArrow)) -+ bot ? (0, import_react20.createElement)(BotAvatar, { bot, size: 24, state: botState(running, doing, roster.questions?.[currentPart(bot)] !== void 0), badge: false }) : room2 ? (0, import_react20.createElement)(RoomAvatar, { room: room2, roster, size: 24 }) : null, -+ (0, import_react20.createElement)("span", { className: face ? "bt-pill-name bt-pill-name-face" : "bt-pill-name", title: face ? full2 : void 0 }, name || title || t("New chat")), -+ (0, import_react20.createElement)(MemoryNotice, { shown: memoryNotice }), -+ (0, import_react20.createElement)("span", { className: "bt-pill-go", "aria-hidden": true }, (0, import_react20.createElement)(GoArrow)) + "div", +- { className: "bt-menu bt-menu-up bt-account-menu", role: "menu", "aria-label": t("Account"), style: box }, +- groups.map((group, index) => (0, import_react8.createElement)( +- import_react8.Fragment, +- { key: index }, +- index > 0 ? (0, import_react8.createElement)("div", { className: "bt-menu-sep", role: "separator" }) : null, +- group.map(([label, Icon, run, hint]) => (0, import_react8.createElement)("button", { +- key: label, ++ { className: "bt-project-actions" }, ++ (0, import_react20.createElement)("button", { type: "button", "data-action": "project-settings", onClick: () => onSettings?.() }, t("Project settings")), ++ (0, import_react20.createElement)("button", { type: "button", "data-action": "project-members", onClick: () => onMembers?.() }, t("Members")) + ), -+ (0, import_react20.createElement)("span", { className: "bt-sr", role: "status" }, memoryNotice ? t("Memory updated") : ""), -+ projectAgent === null ? null : (0, import_react20.createElement)(ProjectAgentEntry, { -+ projectId: projectAgent.projectId, -+ read: call, -+ onOpen: (id) => actions?.openSession?.(id) -+ }), -+ (0, import_react20.createElement)(NewMessagePills, { key: sessionId, sessionId, hiddenTurns, useSessions, useUi, useTurnClock, actions }), -+ (0, import_react20.createElement)(ActivityIndicator, { key: `activity:${sessionId}`, sessionId, useRoster, useSessionStatus, useSession, useLive, actions }), -+ (0, import_react20.createElement)(BusyAckBubble, { key: `busy-ack:${sessionId}`, sessionId, useRoster, useBusyAcks, useSessionStatus, useSession, useLive }) ++ (0, import_react20.createElement)(ProjectMetrics, { view: view.metrics }), ++ (0, import_react20.createElement)(ProjectRunningTable, { view: view.metrics, onOpen }), ++ (0, import_react20.createElement)(ProjectBoardRows, { view, onOpen }), ++ (0, import_react20.createElement)(ProjectQueueGroups, { view, onOpen }), ++ (0, import_react20.createElement)(ProjectDoneGroup, { view, onOpen }), ++ (0, import_react20.createElement)(ProjectBoardRecord, { record: view.record, projectId: view.projectId, call: call4 }) + ); +} -+function useTranscriptView() { -+ const [view, setView] = (0, import_react20.useState)(null); -+ (0, import_react20.useEffect)(() => { -+ let frame = 0; -+ let watched = null; -+ const observer = new MutationObserver(() => schedule()); -+ const measure = () => { -+ frame = 0; -+ const scroller = document.querySelector("[data-conversation-scroll]"); -+ if (scroller !== watched) { -+ observer.disconnect(); -+ watched = scroller; -+ if (scroller) observer.observe(scroller, { childList: true, subtree: true }); -+ } -+ if (!scroller) { -+ setView(null); -+ return; -+ } -+ const box = scroller.getBoundingClientRect(); -+ const headerBottom = document.querySelector('header[class*="_header"]')?.getBoundingClientRect().bottom ?? box.top + 50; -+ const sep = scroller.querySelector(".bt-new-sep"); -+ let divider = null; -+ if (sep) { -+ const at = sep.getBoundingClientRect(); -+ divider = at.bottom <= headerBottom ? "above" : at.top >= box.bottom ? "below" : "in-view"; -+ } -+ const composer = parseFloat(getComputedStyle(scroller).getPropertyValue("--dsh-composer-height")) || 152; -+ const next = { -+ unpinned: scroller.querySelector('[class*="_toBottomSlot"]>button') !== null, -+ divider, -+ center: Math.round(box.left + box.width / 2), -+ top: Math.round(headerBottom + 8), -+ bottom: Math.round(window.innerHeight - box.bottom + composer + 8) -+ }; -+ setView((prev) => prev && Object.keys(next).every((key) => prev[key] === next[key]) ? prev : next); -+ }; -+ const schedule = () => { -+ if (!frame) frame = requestAnimationFrame(measure); -+ }; -+ document.addEventListener("scroll", schedule, true); -+ window.addEventListener("resize", schedule); -+ schedule(); -+ return () => { -+ cancelAnimationFrame(frame); -+ observer.disconnect(); -+ document.removeEventListener("scroll", schedule, true); -+ window.removeEventListener("resize", schedule); -+ }; -+ }, []); -+ return view; -+} -+function NewMessagePills({ sessionId, hiddenTurns, useSessions, useUi, useTurnClock, actions }) { -+ const replies = useSessions((list) => { -+ const outline = list.projectionsBySession?.[sessionId]?.values?.turnOutline ?? list.byId[sessionId]?.projectionValues?.turnOutline; -+ return Array.isArray(outline) ? outline.reduce((count, entry) => count + (entry.response ? 1 : 0), 0) : 0; -+ }); -+ const visit = useUi((state) => state.visits?.[sessionId]); -+ const unread = useTurnClock((state) => { -+ if (typeof visit?.seen !== "number") return 0; -+ let count = 0; -+ for (const entry of Object.values(state[sessionId] ?? {})) { -+ if (entry.first !== void 0 && entry.first > visit.seen && entry.first <= visit.entered) count += 1; ++function ProjectSettingsPanel({ project, assistantMine, call: request, onClose, onSaved } = {}) { ++ const save = request ?? call; ++ const [kind, setKind] = (0, import_react20.useState)("bot"); ++ const [memberId, setMemberId] = (0, import_react20.useState)(""); ++ const [note, setNote] = (0, import_react20.useState)(""); ++ const noteLength = memoryLength(note); ++ const noteOver = noteLength > MEMORY_TEXT_MAX; ++ const noteSaveDisabled = text(note) === "" || noteOver; ++ const [assistantOff, setAssistantOff] = (0, import_react20.useState)(assistantMine === void 0 ? project?.assistant?.off === true : assistantMine?.off === true); ++ const [status, setStatus] = (0, import_react20.useState)(""); ++ const id = text(project?.id); ++ const run = async (endpoint, payload, done) => { ++ try { ++ const result = await save(endpoint, payload); ++ setStatus(done); ++ onSaved?.(result); ++ return result; ++ } catch (error) { ++ setStatus(hostText(error?.message ?? String(error))); ++ return null; + } -+ return count; -+ }); -+ const view = useTranscriptView(); -+ const unpinned = view?.unpinned === true; -+ const [release, setRelease] = (0, import_react20.useState)({ base: null, dismissed: false }); -+ const [above, setAbove] = (0, import_react20.useState)({ seen: false, dismissed: false }); -+ (0, import_react20.useEffect)(() => { -+ setRelease({ base: unpinned ? replies : null, dismissed: false }); -+ }, [unpinned]); -+ (0, import_react20.useEffect)(() => { -+ if (view?.divider === "in-view") setAbove((state) => state.seen ? state : { ...state, seen: true }); -+ }, [view?.divider]); -+ const movedOn = useTurnClock((state) => movedOnSince(visit, state[sessionId], hiddenTurns)); -+ (0, import_react20.useEffect)(() => { -+ if (above.seen && movedOn) actions?.settleVisit?.(sessionId); -+ }, [above.seen, movedOn, sessionId]); -+ const fresh = release.base === null ? 0 : replies - release.base; -+ const showDown = unpinned && fresh > 0 && !release.dismissed; -+ const showUp = view?.divider === "above" && !above.seen && !above.dismissed; -+ (0, import_react20.useEffect)(() => { -+ const scroller = document.querySelector("[data-conversation-scroll]"); -+ if (!scroller) return void 0; -+ scroller.toggleAttribute("data-bt-news", showDown); -+ return () => scroller.removeAttribute("data-bt-news"); -+ }, [showDown]); -+ if (!view || !showDown && !showUp) return null; -+ const label = (count) => count === 1 ? t("1 new message") : count > 0 ? t("{count} new messages", { count }) : t("New messages"); -+ const pill = (direction, count, style, onJump, onDismiss) => (0, import_react20.createElement)( ++ }; ++ return (0, import_react20.createElement)( + "div", -+ { className: "bt-news", "data-direction": direction, style }, ++ { className: "bt-project-panel", "data-panel": "project-settings" }, ++ (0, import_react20.createElement)("h3", null, t("Project settings")), + (0, import_react20.createElement)( -+ "button", -+ { type: "button", className: "bt-news-jump", onClick: onJump }, -+ (0, import_react20.createElement)("span", { className: "bt-news-ico" }, (0, import_react20.createElement)(direction === "up" ? ArrowUpIcon : ArrowDownIcon)), -+ label(count) ++ "div", ++ { className: "bt-project-field" }, ++ (0, import_react20.createElement)("label", { htmlFor: "bt-project-member-id" }, t("Bind a member to this Project")), ++ (0, import_react20.createElement)( ++ "div", ++ { className: "bt-project-row-actions" }, ++ (0, import_react20.createElement)( ++ "select", ++ { "aria-label": t("Member kind"), value: kind, onChange: (event) => setKind(event.target.value) }, ++ (0, import_react20.createElement)("option", { value: "bot" }, t("Bot")), ++ (0, import_react20.createElement)("option", { value: "group" }, t("Group")) ++ ), ++ (0, import_react20.createElement)("input", { id: "bt-project-member-id", value: memberId, placeholder: t("Member id"), onChange: (event) => setMemberId(event.target.value) }), ++ (0, import_react20.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", "data-action": "project-bind", onClick: () => run("project-bind", { kind, id: text(memberId), projectId: id }, t("Member bound.")) }, t("Bind")), ++ (0, import_react20.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", "data-action": "project-unbind", onClick: () => run("project-bind", { kind, id: text(memberId), projectId: null }, t("Member unbound.")) }, t("Unbind")) ++ ) ++ ), ++ (0, import_react20.createElement)( ++ "div", ++ { className: "bt-project-field" }, ++ (0, import_react20.createElement)("label", null, t("Project Assistant")), ++ (0, import_react20.createElement)( ++ "div", ++ { className: "bt-project-row-actions" }, ++ (0, import_react20.createElement)("label", null, (0, import_react20.createElement)("input", { type: "checkbox", "data-action": "assistant-off", checked: assistantOff, onChange: (event) => setAssistantOff(event.target.checked) }), " ", t("Pause the daily digest")), ++ (0, import_react20.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", "data-action": "project-assistant-save", onClick: () => run("project-assistant-owner", { id, settings: { off: assistantOff === true } }, t("Assistant settings saved.")) }, t("Save")) ++ ) + ), -+ (0, import_react20.createElement)("button", { -+ type: "button", -+ className: "bt-news-x", -+ "aria-label": t("Dismiss new messages"), -+ onClick: (event) => { -+ event.stopPropagation(); -+ onDismiss(); -+ } -+ }, (0, import_react20.createElement)(CloseIcon)) -+ ); -+ const toLatest = () => { -+ const scroller = document.querySelector("[data-conversation-scroll]"); -+ const back = scroller?.querySelector('[class*="_toBottomSlot"]>button'); -+ if (back) back.click(); -+ else scroller?.scrollTo({ top: scroller.scrollHeight, behavior: "smooth" }); -+ }; -+ const toDivider = () => document.querySelector("[data-conversation-scroll] .bt-new-sep")?.scrollIntoView({ block: "center", behavior: "smooth" }); -+ return (0, import_react_dom6.createPortal)( + (0, import_react20.createElement)( -+ import_react20.Fragment, -+ null, -+ showUp ? pill("up", unread, { left: view.center, top: view.top }, toDivider, () => setAbove((state) => ({ ...state, dismissed: true }))) : null, -+ showDown ? pill("down", fresh, { left: view.center, bottom: view.bottom }, toLatest, () => setRelease((state) => ({ ...state, dismissed: true }))) : null ++ "div", ++ { className: "bt-project-field" }, ++ // The host knows two memory kinds; the free text the panel takes is recorded as a decision, ++ // never as a third kind the host would refuse (#1820). ++ (0, import_react20.createElement)("label", { htmlFor: "bt-project-note" }, t("Add to the assistant memory")), ++ (0, import_react20.createElement)("textarea", { id: "bt-project-note", value: note, onChange: (event) => setNote(event.target.value) }), ++ (0, import_react20.createElement)("div", { className: "bt-project-count", "data-role": "project-memory-count", "data-over": noteOver ? "true" : "false" }, `${noteLength}/${MEMORY_TEXT_MAX}`), ++ (0, import_react20.createElement)( ++ "div", ++ { className: "bt-project-row-actions" }, ++ (0, import_react20.createElement)("button", { + type: "button", +- role: "menuitem", ++ className: "bt-soft bt-soft-sm", ++ "data-action": "project-memory-save", ++ disabled: noteSaveDisabled, ++ // A disabled button draws no click, but the guard is what a keyboard or a stale tree ++ // could still reach: neither state sends a request or writes anything. + onClick: () => { +- setOpen(false); +- void Promise.resolve(run()).catch((error) => actions.reportError(error, "menu")); ++ if (noteSaveDisabled) return; ++ run("project-memory-write", { id, kind: "decision", text: text(note) }, t("Memory saved.")); + } +- }, (0, import_react8.createElement)(Icon), (0, import_react8.createElement)("span", { className: "bt-menu-label" }, label), hint ? (0, import_react8.createElement)("span", { className: "bt-menu-hint" }, hint) : null)) ++ }, t("Save memory")) ++ ) + ), -+ document.body ++ (0, import_react20.createElement)("div", { className: "bt-project-status-line", role: "status" }, status), ++ (0, import_react20.createElement)( ++ "div", ++ { className: "bt-project-row-actions" }, ++ (0, import_react20.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: () => onClose?.() }, t("Back to board")) ++ ) + ); +} -+ -+// src/client/composer-label.js -+var ATTRIBUTES = ["aria-label", "data-placeholder"]; -+function holdComposerLabel(doc, label) { -+ doc.body.style.setProperty("--bt-placeholder", JSON.stringify(label)); -+ const shell = /* @__PURE__ */ new Map(); -+ const apply2 = () => { -+ for (const el of doc.querySelectorAll("[data-composer-input]")) { -+ for (const name of ATTRIBUTES) { -+ const current = el.getAttribute(name); -+ if (current === label) continue; -+ if (!shell.has(el)) shell.set(el, {}); -+ shell.get(el)[name] = current; -+ el.setAttribute(name, label); -+ } -+ } -+ }; -+ apply2(); -+ const Observer = doc.defaultView?.MutationObserver ?? globalThis.MutationObserver; -+ const watcher = typeof Observer === "function" ? new Observer(apply2) : null; -+ watcher?.observe(doc.body, { subtree: true, childList: true, attributes: true, attributeFilter: ATTRIBUTES }); -+ return () => { -+ watcher?.disconnect(); -+ for (const [el, values] of shell) { -+ if (!el.isConnected) continue; -+ for (const [name, value] of Object.entries(values)) { -+ if (value == null) el.removeAttribute(name); -+ else el.setAttribute(name, value); -+ } -+ } -+ }; -+} -+ -+// src/client/header-effects.js -+function installHeaderEffects({ sessions, roster, actions, doc = globalThis.document }) { -+ if (!doc) return () => { -+ }; -+ const slots = /* @__PURE__ */ new Map(); -+ const run = (id, dep, setup) => { -+ const held = slots.get(id); -+ if (held !== void 0 && held.dep === dep) return; -+ held?.off(); -+ slots.set(id, { dep, off: setup() ?? (() => { -+ }) }); -+ }; -+ let running = false; -+ let again = false; -+ const pass = () => { -+ const list = sessions.getSnapshot(); -+ const state = roster.getSnapshot(); -+ const row = Object.values(list?.byId ?? {}).find((entry) => (entry?.retainedBy?.mainView ?? 0) > 0); -+ const sessionId = row?.id; -+ const bot = sessionId === void 0 ? void 0 : state?.byId?.[sessionId]; -+ const room2 = sessionId === void 0 ? void 0 : state?.roomsById?.[sessionId]; -+ const name = bot?.remote ? friendBotTitle(bot) : roomTitle(room2) || bot?.name || ""; -+ run("friend-chat", `${sessionId}|${bot?.remote === true}`, () => { -+ if (!bot?.remote) return void 0; -+ doc.body.dataset.btFriendChat = ""; -+ return () => { -+ delete doc.body.dataset.btFriendChat; -+ }; -+ }); -+ run("remote-empty", `${sessionId}|${bot?.remote === true}|${row?.blank === true}`, () => { -+ if (!bot?.remote || row?.blank !== true) return void 0; -+ doc.body.dataset.btRemoteChat = "empty"; -+ doc.body.style.setProperty("--bt-remote-empty", JSON.stringify(t("Send a message. Your friend approves unless Auto-send is on."))); -+ return () => { -+ delete doc.body.dataset.btRemoteChat; -+ doc.body.style.removeProperty("--bt-remote-empty"); -+ }; -+ }); -+ run("composer-label", `${sessionId}|${name}`, () => holdComposerLabel(doc, name ? t("Message {name}", { name }) : t("Message"))); -+ run("visit", sessionId, () => { -+ if (sessionId === void 0) return void 0; -+ actions.beginVisit(sessionId); -+ return () => actions.endVisit(sessionId); -+ }); -+ run("face-at-menu", `${sessionId}|${room2?.origin === "face-to-face"}`, () => { -+ if (room2?.origin !== "face-to-face") return void 0; -+ const stop = actions.filterFaceAtMenu?.(sessionId); -+ return typeof stop === "function" ? stop : void 0; -+ }); -+ run("room-flag", `${sessionId}|${room2 !== void 0}`, () => { -+ if (room2 === void 0) return void 0; -+ doc.body.dataset.btRoom = ""; -+ return () => { -+ delete doc.body.dataset.btRoom; -+ }; -+ }); -+ }; -+ const apply2 = () => { -+ if (running) { -+ again = true; -+ return; -+ } -+ running = true; -+ try { -+ pass(); -+ } finally { -+ running = false; -+ } -+ if (again) { -+ again = false; -+ apply2(); -+ } ++function ProjectMembersPanel({ project, rows, onClose } = {}) { ++ const owners = []; ++ const add = (id) => { ++ const clean = text(id); ++ if (clean !== "" && !owners.includes(clean)) owners.push(clean); + }; -+ apply2(); -+ const offSessions = sessions.subscribe(apply2); -+ const offRoster = roster.subscribe(apply2); -+ return () => { -+ offSessions?.(); -+ offRoster?.(); -+ for (const held of slots.values()) held.off(); -+ slots.clear(); ++ add(project?.ownerUserId); ++ for (const id of Array.isArray(project?.ownerUserIds) ? project.ownerUserIds : []) add(id); ++ const boardMembers = []; ++ for (const row of Array.isArray(rows) ? rows : []) { ++ const name = text(row?.assignee); ++ if (name !== "" && !boardMembers.includes(name)) boardMembers.push(name); ++ } ++ const chief = text(project?.chief?.botId); ++ return (0, import_react20.createElement)( ++ "div", ++ { className: "bt-project-panel", "data-panel": "project-members" }, ++ (0, import_react20.createElement)("h3", null, t("Members")), ++ (0, import_react20.createElement)( ++ "ul", ++ { className: "bt-project-members" }, ++ owners.map((owner) => (0, import_react20.createElement)( ++ "li", ++ { key: `owner:${owner}`, className: "bt-project-member", "data-member-role": "owner", "data-member-id": owner }, ++ (0, import_react20.createElement)("span", null, `@${owner}`), ++ (0, import_react20.createElement)("span", { className: "bt-project-member-kind" }, t("Owner")) ++ )), ++ chief === "" ? null : (0, import_react20.createElement)( ++ "li", ++ { key: `chief:${chief}`, className: "bt-project-member", "data-member-role": "chief", "data-member-id": chief }, ++ (0, import_react20.createElement)("span", null, t("Project Chief")), ++ (0, import_react20.createElement)("span", { className: "bt-project-member-kind" }, t("Chief of this Project")) ++ ), ++ boardMembers.map((name) => (0, import_react20.createElement)( ++ "li", ++ { key: `board:${name}`, className: "bt-project-member", "data-member-role": "board", "data-member-id": name }, ++ (0, import_react20.createElement)("span", null, `@${name}`), ++ (0, import_react20.createElement)("span", { className: "bt-project-member-kind" }, t("On the board")) + )) +- ), document.body) : null ++ ), ++ owners.length === 0 && boardMembers.length === 0 && chief === "" ? (0, import_react20.createElement)("div", { className: "bt-project-empty" }, t("No member is named yet.")) : null, ++ (0, import_react20.createElement)("p", { className: "bt-project-note" }, t("Member management is not part of this slice; owners and the members their cards name are listed here.")), ++ (0, import_react20.createElement)( ++ "div", ++ { className: "bt-project-row-actions" }, ++ (0, import_react20.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: () => onClose?.() }, t("Back to board")) ++ ) + ); + } +-function useLandOnMainBot(roster, useSessions, actions) { +- const mainView = useSessions((list) => { +- const row = Object.values(list.byId).find((session) => (session?.retainedBy?.mainView ?? 0) > 0); +- return row ? `${row.id}|${row.blank ? 1 : 0}` : ""; +- }); +- const lastBot = (0, import_react8.useRef)(null); +- (0, import_react8.useEffect)(() => { +- if (!roster.ready || !roster.mainBotId) return void 0; +- const [id, blank3] = mainView.split("|"); +- const bot = id === "" ? void 0 : roster.byId[id]; +- if (bot !== void 0) { +- lastBot.current = { botId: bot.id, sessionId: id }; +- if (currentPart(bot) !== id) actions.landOn(bot.id); ++async function readProjectBoard(read, wanted, knownTeam) { ++ const listing = await read("projects"); ++ const projects = Array.isArray(listing?.projects) ? listing.projects : []; ++ const project = wanted === "" ? projects[0] : projects.find((item) => item.id === wanted); ++ if (project === void 0) return { project: null, rows: [], done: [], canManage: false, tasks: [], team: { rows: [] }, teamFresh: true }; ++ const zone = typeof Intl === "undefined" || typeof Intl.DateTimeFormat !== "function" ? void 0 : Intl.DateTimeFormat().resolvedOptions().timeZone; ++ const [record, board, taskList, dossier] = await Promise.all([ ++ read("project-read", { id: project.id }), ++ read("project-board", { id: project.id, timeZone: zone }), ++ read("tasks", {}), ++ // #1910: #1902 turned this failure into an ordinary empty team, which the poll then served for ++ // the rest of the open. Keep the two answers apart: a refusal is not fresh, so nothing is cached ++ // and the next tick asks again, while a real `{ rows: [] }` answer is fresh and frozen as usual. ++ knownTeam !== void 0 ? { answer: knownTeam, fresh: true } : read("project-team", { id: project.id }).then( ++ (answer) => ({ answer, fresh: answer !== null && typeof answer === "object" }), ++ () => ({ answer: null, fresh: false }) ++ ) ++ ]); ++ const tasks = Array.isArray(taskList) ? taskList : taskList?.tasks ?? []; ++ const queues = { waiting: board?.waiting, inflight: board?.inflight, blocked: board?.blocked, counts: board?.counts ?? null }; ++ const base = record?.project ?? project; ++ const withRole = { ...base, role: project.role ?? null, choices: Array.isArray(project.choices) ? project.choices : [] }; ++ return { ++ project: withRole, ++ assistantMine: record?.assistantMine ?? null, ++ rows: board?.tasks ?? [], ++ done: board?.done ?? [], ++ canManage: record?.canManage === true, ++ tasks, ++ decisions: board?.decisions, ++ meetings: board?.meetings, ++ queues, ++ metrics: board?.metrics ?? null, ++ team: dossier.answer !== null && typeof dossier.answer === "object" ? dossier.answer : { rows: [] }, ++ teamFresh: dossier.fresh + }; +} -+ -+// src/client/schedules.js -+var import_react21 = require("react"); -+var POLL_MS4 = 3e4; -+var shared = null; -+var listeners = /* @__PURE__ */ new Set(); -+function publish(view) { -+ shared = view; -+ for (const listener of listeners) listener(view); -+} -+function publishFailure(error) { -+ publish({ available: false, tasks: [], readFailure: hostText(error?.message ?? String(error)) }); -+} -+function useSchedules(actions) { -+ const [view, setView] = (0, import_react21.useState)(shared); -+ (0, import_react21.useEffect)(() => { -+ listeners.add(setView); -+ let stopped = false; -+ let timer; -+ const load = () => actions.schedules?.().then((next) => { -+ if (!stopped) publish(next); -+ }).catch((error) => { -+ if (!stopped) publishFailure(error); -+ }).finally(() => { -+ if (!stopped) timer = setTimeout(load, POLL_MS4); -+ }); -+ load(); -+ return () => { -+ stopped = true; -+ clearTimeout(timer); -+ listeners.delete(setView); ++var PROJECT_BOARD_POLL_MS = 5e3; ++var boardPollPaused = () => typeof document !== "undefined" && document.visibilityState === "hidden" || typeof window !== "undefined" && !window.location.hash.startsWith("#project"); ++function ProjectBoardOverlay({ actions, call: request } = {}) { ++ const read = request ?? call; ++ const [state, setState] = (0, import_react20.useState)(null); ++ const [panel, setPanel] = (0, import_react20.useState)(null); ++ const [dossier, setDossier] = (0, import_react20.useState)(null); ++ const team = (0, import_react20.useRef)(void 0); ++ const teamGen = (0, import_react20.useRef)(0); ++ const [hash, setHash] = (0, import_react20.useState)(() => typeof window === "undefined" ? "" : window.location.hash); ++ const close = (0, import_react20.useCallback)(() => { ++ if (typeof window !== "undefined") window.location.hash = ""; ++ setPanel(null); ++ setState(null); ++ }, []); ++ (0, import_react20.useEffect)(() => { ++ if (typeof window === "undefined") return void 0; ++ const onKey = (event) => { ++ if (event.key !== "Escape") return; ++ if (panel !== null) setPanel(null); ++ else close(); + }; ++ window.addEventListener("keydown", onKey); ++ return () => window.removeEventListener("keydown", onKey); ++ }, [panel, close]); ++ (0, import_react20.useEffect)(() => { ++ if (typeof window === "undefined") return void 0; ++ const sync = () => setHash(window.location.hash); ++ window.addEventListener("hashchange", sync); ++ sync(); ++ return () => window.removeEventListener("hashchange", sync); + }, []); -+ return view; -+} -+var schedulesReadFailure = (failure) => typeof failure === "string" && failure !== "" ? t("The scheduled tasks could not be read: {reason}", { reason: failure }) : null; -+var localZone = () => Intl.DateTimeFormat().resolvedOptions().timeZone; -+var clock2 = (time) => String(time ?? "").slice(0, 5); -+var when2 = (ms, options) => new Intl.DateTimeFormat(dateLocale(), options).format(ms); -+function dayAndTime(ms) { -+ const sameYear = new Date(ms).getFullYear() === (/* @__PURE__ */ new Date()).getFullYear(); -+ return when2(ms, { ...sameYear ? {} : { year: "numeric" }, month: "short", day: "numeric", hour: "2-digit", minute: "2-digit" }); -+} -+function instant(value) { -+ const at = Date.parse(value); -+ return Number.isFinite(at) ? dayAndTime(at) : ""; -+} -+function weekdayNames(days) { -+ const names = days.map((day) => when2(Date.UTC(2024, 0, day), { weekday: "short", timeZone: "UTC" })); -+ return names.join(dateLocale().startsWith("zh") ? "、" : ", "); -+} -+function everyText(seconds) { -+ if (seconds % 86400 === 0) return seconds === 86400 ? t("Every day") : t("Every {count} days", { count: seconds / 86400 }); -+ if (seconds % 3600 === 0) return seconds === 3600 ? t("Every hour") : t("Every {count} hours", { count: seconds / 3600 }); -+ if (seconds % 60 === 0) return t("Every {count} minutes", { count: seconds / 60 }); -+ return t("Every {count} seconds", { count: seconds }); -+} -+function timingText(task) { -+ const zone = task.timeZone && task.timeZone !== localZone() ? ` (${task.timeZone})` : ""; -+ switch (task.kind) { -+ case "after": -+ case "at": { -+ const time = instant(task.scheduledAt); -+ return time === "" ? "" : t("Once, {time}", { time }); -+ } -+ case "every": -+ return everyText(task.everySeconds); -+ case "daily": -+ return `${t("Every day at {time}", { time: clock2(task.time) })}${zone}`; -+ case "weekly": { -+ const days = (task.weekdays ?? []).join(); -+ const text2 = days === "1,2,3,4,5" ? t("Every weekday at {time}", { time: clock2(task.time) }) : days === "6,7" ? t("Every weekend at {time}", { time: clock2(task.time) }) : days === "1,2,3,4,5,6,7" ? t("Every day at {time}", { time: clock2(task.time) }) : t("Every {days} at {time}", { days: weekdayNames(task.weekdays ?? []), time: clock2(task.time) }); -+ return `${text2}${zone}`; -+ } -+ case "cron": -+ return `${t("On the schedule {expression}", { expression: task.expression })}${zone}`; -+ default: -+ return ""; -+ } -+} -+var nextText = (task) => { -+ if (task.status === "paused") return t("Paused"); -+ if (task.status !== "active") return t("Finished"); -+ const at = instant(task.scheduledAt); -+ return at === "" ? timingText(task) : t("Next: {time}", { time: at }); -+}; -+function lastRunText(run) { -+ if (run === void 0 || !Number.isFinite(run.at)) return ""; -+ const time = dayAndTime(run.at); -+ switch (run.result) { -+ case "running": -+ return t("Running since {time}", { time }); -+ case "stopped": -+ return t("Last run {time} · Stopped", { time }); -+ case "failed": -+ return t("Last run {time} · Failed: {reason}", { time, reason: hostText(run.reason || "Unknown error") }); -+ default: -+ return t("Last run {time}", { time }); -+ } ++ (0, import_react20.useEffect)(() => { ++ if (typeof window === "undefined") return void 0; ++ if (!hash.startsWith("#project")) { ++ setState(null); + return void 0; + } +- if (id !== "" && (roster.roomsById[id] || (actions.isAgentSession?.(id) ?? roster.agentOf[id] !== void 0) || blank3 !== "1")) { +- lastBot.current = null; +- return void 0; ++ const wanted = hash.slice("#project".length).replace(/^=/, ""); ++ let gone = false; ++ team.current = void 0; ++ teamGen.current = 0; ++ const loadBoard = () => readProjectBoard(read, wanted, team.current); ++ const refresh = async (first) => { ++ const gen = teamGen.current; ++ try { ++ const next = await loadBoard(); ++ if (!gone) { ++ const newer = teamGen.current !== gen; ++ if (next.teamFresh !== false && !newer) team.current = next.team; ++ setState(newer && team.current !== void 0 ? { ...next, team: team.current } : next); ++ } ++ } catch { ++ if (!gone && first) setState({ project: null, rows: [], canManage: false, tasks: [] }); ++ } ++ }; ++ void refresh(true); ++ const timer = setInterval(() => { ++ if (!boardPollPaused()) void refresh(false); ++ }, PROJECT_BOARD_POLL_MS); ++ return () => { ++ gone = true; ++ clearInterval(timer); ++ }; ++ }, [hash]); ++ const openDossier = async (value) => { ++ setDossier(value); ++ setPanel("dossier"); ++ const id = state?.project?.id; ++ if (typeof id !== "string" || id === "") return; ++ const gen = teamGen.current + 1; ++ teamGen.current = gen; ++ try { ++ const answer = await read("project-team", { id }); ++ if (teamGen.current !== gen || answer === null || typeof answer !== "object") return; ++ team.current = answer; ++ setState((previous) => previous === null ? previous : { ...previous, team: answer }); ++ } catch { + } +- const last = lastBot.current; +- const timer = setTimeout(() => actions.landOn(roster.mainBotId, last), last ? 0 : 400); +- return () => clearTimeout(timer); +- }, [mainView, roster]); +-} +-function FooterAccount(props) { +- const seated = props.useUi((value) => value.accountSeated === true); +- if (seated) return null; +- return (0, import_react8.createElement)("span", { className: "bt-foot-account" }, (0, import_react8.createElement)(AccountLauncher, { ...props, seat: false })); +-} +-function BotReturn({ wide, useSurface, actions }) { +- const mode = useSurface((value) => value.mode); +- if (mode !== "agent") return null; +- return (0, import_react8.createElement)( +- "button", +- { type: "button", className: "bt-return", "aria-label": t("DS Bot"), title: t("DS Bot"), onClick: () => actions.setSurface("bot") }, +- (0, import_react8.createElement)(BotMark, { look: MAIN_LOOK, size: 20 }), +- wide ? (0, import_react8.createElement)("span", { className: "bt-return-name" }, t("DS Bot")) : null +- ); +-} +-var RETURN_CSS = ` +-.bt-return{display:inline-flex;align-items:center;justify-content:center;gap:8px;min-height:36px;padding:0 14px;border:0;border-radius:999px;background:none;color:inherit;font:inherit;font-size:14px;line-height:20px;cursor:pointer;transition:background-color .12s ease} +-.bt-return:hover{background:rgba(127,127,127,.14)} +-.bt-return:active{transform:scale(.96)} +-.bt-return .bt-mark{display:inline-flex;flex:none;line-height:0} +-.bt-return .bt-mark svg{width:100%;height:100%;overflow:visible} +-/* Shell styles carry --bt-ink-deepseek and Agent mode withdraws them; pin it here. */ +-.bt-return .bt-mark{--bt-ink-deepseek:#4D6BFE} +-.bt-return-name{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} +-`; +-function BrandMark({ size = 24 }) { +- return (0, import_react8.createElement)("span", { className: "bt-brand-whale" }, (0, import_react8.createElement)(BotMark, { look: MAIN_LOOK, size })); +-} +-function BrandName() { +- return (0, import_react8.createElement)("span", { style: { fontWeight: 600, fontSize: 14 } }, "DS Bot"); +-} +-var SIDEBAR_CSS = ` +-.bt-side-top .bt-brand{cursor:default;padding-left:6px} +-.bt-side-top .bt-brand:hover{background:none} +-.bt-brand .bt-mark{flex:none} +-.bt-pin{flex:none;display:inline-flex;color:var(--bt-ink-3)} +-.bt-rail .bt-row{position:relative} +-.bt-rail .bt-pin{position:absolute;top:3px;left:calc(50% + 12px)} +-.bt-side .bt-read-only{margin:4px 8px 6px;padding:8px 10px;border-radius:10px;background:var(--bt-hover);font-size:12px;line-height:1.45;text-align:left} +-[class*="_footArea"]:has(.bt-foot-account){display:flex!important;flex-direction:row!important;align-items:center;gap:6px} +-[class*="_footArea"]:has(.bt-foot-account)>[class*="_footerActions"],[class*="_footArea"]:has(.bt-foot-account)>[class*="_footerActions"]>div{display:contents!important} +-.bt-foot-account{order:-1;flex:none;display:inline-flex} +-[class*="_footArea"]:has(.bt-foot-account) .bt-connect-foot{order:0;flex:1 1 auto;min-width:0;width:auto} +-[class*="_footArea"]:has(.bt-foot-account)>[class*="_settingsArea"]{order:1;flex:none} +-[class*="_footArea"]:has(.bt-foot-account)>[class*="_settingsArea"] button[class*="_trigger"] [class*="_label"]{display:none!important} +- +-/* The update cloud springs in when a newer DS Bot shows up; its arrow drops in a +- loop while the install runs. */ +-.bt-round.bt-update{position:relative;color:var(--bt-accent,#4D6BFE);animation:bt-update-in ${SPRING_SHAPE.duration}ms ${SPRING_SHAPE.easing}} +-.bt-round.bt-update svg{width:17px;height:17px} +-.bt-round.bt-update[data-phase=installed]{color:var(--bt-ink-2)} +-@keyframes bt-update-in{from{opacity:0;transform:scale(.6);filter:blur(4px)}} +-.bt-update[data-phase=installing] .bt-cloud-arrow{animation:bt-cloud-drop 1.1s cubic-bezier(.45,0,.55,1) infinite} +-@keyframes bt-cloud-drop{0%{transform:translateY(-3px);opacity:0}35%{opacity:1}70%{transform:translateY(1px);opacity:1}100%{transform:translateY(2px);opacity:0}} +-.bt-update-dot{position:absolute;top:6px;right:6px;width:7px;height:7px;border-radius:50%;background:#E5484D;box-shadow:0 0 0 1.5px var(--bt-sidebar)} +-.bt-update-pop{position:fixed;z-index:60;width:280px;padding:14px 14px 12px;border-radius:14px;background:var(--bt-main);border:.5px solid var(--bt-line-2);box-shadow:0 10px 20px -3px rgba(0,0,0,.1),0 4px 6px -4px rgba(0,0,0,.1);color:var(--bt-ink);box-sizing:border-box;transform-origin:top left;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",system-ui,sans-serif;animation:bt-update-pop ${SPRING_SHAPE.duration}ms ${SPRING_SHAPE.easing}} +-@keyframes bt-update-pop{from{opacity:0;transform:scale(.96);filter:blur(4px)}} +-.bt-update-title{font-size:14px;line-height:20px;font-weight:600} +-.bt-update-text{margin-top:2px;font-size:12.5px;line-height:18px;color:var(--bt-ink-2)} +-.bt-update-error{margin-top:8px;font-size:12px;line-height:17px;color:#c21d2e;overflow-wrap:anywhere;max-height:90px;overflow:auto} +-.bt-update-actions{display:flex;align-items:center;justify-content:flex-end;gap:10px;margin-top:12px} +-.bt-update-notes{margin-right:auto;font-size:12.5px;color:var(--bt-ink-2);text-decoration:none} +-.bt-update-notes:hover{color:var(--bt-ink);text-decoration:underline} +-.bt-update-go{height:30px;padding:0 14px;border:0;border-radius:999px;background:var(--bt-accent,#4D6BFE);color:var(--bt-accent-ink,#fff);font:inherit;font-size:13px;font-weight:500;cursor:pointer;transition:opacity .12s ease,transform .12s ease} +-.bt-update-go:hover:not(:disabled){opacity:.9} +-.bt-update-go:active:not(:disabled){transform:scale(.96)} +-.bt-update-go:disabled{opacity:.6;cursor:default} +-.bt-update-dsh:not(:first-child){margin-top:12px;padding-top:12px;border-top:.5px solid var(--bt-line-2)} +-@media (prefers-reduced-motion:reduce){.bt-round.bt-update,.bt-update-pop,.bt-update .bt-cloud-arrow{animation:none}} +- +-/* The DSH Agent row and its expanding Session list. The black whale is the +- harness's own mark, so the row reads as "plain dsh" next to the Bot marks. */ +-.bt-agent-ava{flex:none;width:36px;height:36px;border-radius:50%;display:inline-flex;align-items:center;justify-content:center;background:var(--bt-hover);color:var(--bt-ink)} +-.bt-agent-ava.bt-run{animation:bt-agent-pulse 1.4s ease-in-out infinite} +-@keyframes bt-agent-pulse{0%,100%{opacity:1}50%{opacity:.55}} +-.bt-agent-sub{display:grid;grid-template-rows:0fr;transition:grid-template-rows ${SPRING_SHAPE.duration}ms ${SPRING_SHAPE.easing}} +-.bt-agent-sub[data-open]{grid-template-rows:1fr} +-.bt-agent-sub-in{overflow:hidden;min-height:0;margin-left:44px} +-.bt-agent-sub-row{display:flex;align-items:center;gap:8px;width:100%;padding:5px 10px 5px 8px;border:0;border-radius:8px;background:none;color:var(--bt-ink-2);font:inherit;font-size:13px;line-height:20px;text-align:left;cursor:pointer} +-.bt-agent-sub-row:hover{background:var(--bt-hover);color:var(--bt-ink)} +-.bt-agent-sub-row[aria-current=page]{background:var(--bt-hover);color:var(--bt-ink);font-weight:500} +-.bt-agent-sub-title{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} +-.bt-agent-sub-time{flex:none;font-size:11px;color:var(--bt-ink-3)} +-.bt-agent-new{color:var(--bt-ink-3)} +-.bt-agent-new svg{width:14px;height:14px;flex:none} +-.bt-agent-edit{padding:3px 10px 3px 4px} +-.bt-agent-edit input{width:100%;border:0;border-radius:6px;background:var(--bt-hover);color:var(--bt-ink);font:inherit;font-size:13px;line-height:20px;padding:2px 8px;outline:none;box-shadow:inset 0 0 0 .5px var(--bt-line-2)} +-.bt-agent-sub[data-open] .bt-agent-sub-row{animation:bt-agent-row-in .24s ease both;animation-delay:calc(var(--i, 0)*30ms)} +-@keyframes bt-agent-row-in{from{opacity:0;filter:blur(4px)}to{opacity:1;filter:blur(0)}} +-@media (prefers-reduced-motion:reduce){.bt-agent-sub{transition:none}.bt-agent-sub .bt-agent-sub-row{animation:none}} +-`; ++ }; ++ if (state === null) return null; ++ return (0, import_react20.createElement)( ++ "div", ++ { className: "bt-project-overlay" }, ++ (0, import_react20.createElement)("div", { className: "bt-scrim", onClick: close }), ++ (0, import_react20.createElement)( ++ "div", ++ { className: "bt-mem bt-project-dialog", role: "dialog", "aria-modal": true, "aria-label": t("Project board") }, ++ (0, import_react20.createElement)( ++ "div", ++ { className: "bt-mem-head" }, ++ (0, import_react20.createElement)("h2", {}, t("Project board")), ++ (0, import_react20.createElement)("span", { className: "bt-mem-head-gap" }), ++ (0, import_react20.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm bt-project-close", autoFocus: true, onClick: close }, t("Close")) ++ ), ++ (0, import_react20.createElement)( ++ "div", ++ { className: "bt-project-body" }, ++ state.project === null ? (0, import_react20.createElement)( ++ "div", ++ { className: "bt-project-empty" }, ++ (0, import_react20.createElement)("p", null, t("No Project yet — create one.")), ++ (0, import_react20.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: () => { ++ close(); ++ actions?.openSettings?.("projects"); ++ } }, t("Open Projects in Settings")) ++ ) : panel === "dossier" ? (0, import_react20.createElement)(ProjectDossierPanel, { ++ row: dossierRowOf(state.team, dossier?.botId), ++ label: dossier?.label ?? "", ++ onClose: () => setPanel(null), ++ onOpenBot: (id) => { ++ close(); ++ actions?.openSession?.(id); ++ } ++ }) : panel === "settings" ? (0, import_react20.createElement)(ProjectSettingsPanel, { project: state.project, assistantMine: state.assistantMine, call: read, onClose: () => setPanel(null) }) : panel === "members" ? (0, import_react20.createElement)(ProjectMembersPanel, { project: state.project, rows: state.rows, onClose: () => setPanel(null) }) : (0, import_react20.createElement)(ProjectBoardPane, { ++ project: state.project, ++ rows: state.rows, ++ done: state.done, ++ canManage: state.canManage, ++ tasks: state.tasks, ++ decisions: state.decisions, ++ meetings: state.meetings, ++ queues: state.queues, ++ metrics: state.metrics, ++ team: state.team, ++ // #2041: a decision row opens through the same channel read the page polls with. ++ call: read, ++ onSettings: () => setPanel("settings"), ++ onMembers: () => setPanel("members"), ++ // #1864: a team row's Dossier control opens the read-only panel, fed by the same read. ++ onOpenDossier: (value) => { ++ void openDossier(value); ++ }, ++ // #1860: a team row opens that Bot's chat. The board is a modal over the conversation, ++ // so it closes first — otherwise the chat opens behind it. ++ onOpenBot: (id) => { ++ close(); ++ actions?.openSession?.(id); ++ }, ++ // The task's own board is where a row leads: the Tasks dialog reads `#task=` on mount and ++ // lands on that Bot or group. Nothing here re-decides which board that is. ++ onOpen: (board) => { ++ if (typeof window !== "undefined") window.location.hash = `#task=${board.kind}:${board.id}`; ++ actions?.openBoard?.(); ++ setState(null); ++ } ++ }) ++ ) ++ ) ++ ); +} -+var lockedWindow = (roster) => typeof roster?.readOnly === "string" && roster.readOnly.trim() !== ""; -+function TaskRow({ task, actions, onView, onEdit, onCopy, showBot, roster, locked }) { -+ const [confirming, setConfirming] = (0, import_react21.useState)(false); -+ const [busy, setBusy] = (0, import_react21.useState)(false); -+ const [rowError, setRowError] = (0, import_react21.useState)(""); -+ const refuse = (error) => setRowError(hostText(error?.message ?? String(error))); + + // src/client/header.js +-var import_react9 = require("react"); +-var import_react_dom4 = require("react-dom"); +-var GoArrow = () => (0, import_react9.createElement)("svg", { width: 14, height: 14, viewBox: "-12 -12 24 24", fill: "none", stroke: "currentColor", strokeWidth: 2.2, strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": true }, (0, import_react9.createElement)("path", { d: "M-7 0H7M2 -5L7 0L2 5" })); ++var import_react21 = require("react"); ++var import_react_dom6 = require("react-dom"); ++var GoArrow = () => (0, import_react21.createElement)("svg", { width: 14, height: 14, viewBox: "-12 -12 24 24", fill: "none", stroke: "currentColor", strokeWidth: 2.2, strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": true }, (0, import_react21.createElement)("path", { d: "M-7 0H7M2 -5L7 0L2 5" })); + var MEMORY_NOTICE_MS = 3200; + var noNews = (select) => select({}); + function useMemoryNotice(ids, useMemoryNews) { + const seen = useMemoryNews((map) => Math.max(0, ...ids.map((id) => map?.[id]?.seen ?? 0))); +- const [shown, setShown] = (0, import_react9.useState)(false); +- (0, import_react9.useEffect)(() => { ++ const [shown, setShown] = (0, import_react21.useState)(false); + (0, import_react21.useEffect)(() => { -+ if (!confirming) return void 0; -+ const timer = setTimeout(() => setConfirming(false), 3e3); -+ return () => clearTimeout(timer); -+ }, [confirming]); -+ const remove = () => { -+ if (!confirming) { -+ setConfirming(true); -+ return; -+ } -+ setBusy(true); -+ setRowError(""); -+ void actions.scheduleDelete(task.sessionId, task.id).then((next) => { -+ setBusy(false); -+ setRowError(""); -+ onView(next); -+ }, (error) => { -+ setBusy(false); -+ refuse(error); -+ }); -+ }; -+ const toggle = () => { -+ setBusy(true); -+ setRowError(""); -+ const step = task.status === "paused" ? actions.scheduleResume : actions.schedulePause; -+ void step(task.sessionId, task.id).then((next) => { -+ setBusy(false); -+ setRowError(""); -+ onView(next); -+ }, (error) => { -+ setBusy(false); -+ refuse(error); -+ }); -+ }; -+ const retry = () => { -+ setBusy(true); -+ setRowError(""); -+ void actions.scheduleRetry(task.sessionId, task.id).then((next) => { -+ setBusy(false); -+ onView(next); -+ }, (error) => { -+ setBusy(false); -+ refuse(error); -+ }); -+ }; -+ const room2 = task.roomId ? roster.roomsById[task.roomId] : void 0; -+ const stoppable = task.status === "active" || task.status === "paused"; -+ const failed = task.lastRun?.result === "failed"; -+ const last = lastRunText(task.lastRun); + const left = seen + MEMORY_NOTICE_MS - Date.now(); + setShown(seen > 0 && left > 0); + if (seen === 0 || left <= 0) return void 0; +@@ -5512,19 +5512,19 @@ function useMemoryNotice(ids, useMemoryNews) { + return shown; + } + function MemoryNotice({ shown }) { +- const inner = (0, import_react9.useRef)(null); +- const [width, setWidth] = (0, import_react9.useState)(0); ++ const inner = (0, import_react21.useRef)(null); ++ const [width, setWidth] = (0, import_react21.useState)(0); + const label = t("Memory updated"); +- (0, import_react9.useLayoutEffect)(() => { ++ (0, import_react21.useLayoutEffect)(() => { + if (shown) setWidth(inner.current?.scrollWidth ?? 0); + }, [label, shown]); +- return (0, import_react9.createElement)( + return (0, import_react21.createElement)( -+ "div", -+ { className: "bt-set-row bt-task", "data-done": stoppable ? void 0 : "", "data-paused": task.status === "paused" || void 0, "data-busy": busy || void 0 }, -+ showBot ? (0, import_react21.createElement)(BotAvatar, { bot: roster.byId[task.botId], size: 28, badge: false, live: false }) : null, -+ (0, import_react21.createElement)( -+ "span", -+ { className: "bt-set-copy" }, -+ (0, import_react21.createElement)("span", { className: "bt-task-title" }, task.title), -+ (0, import_react21.createElement)("span", { className: "bt-set-hint" }, [timingText(task), room2 ? t("in {name}", { name: room2.name }) : ""].filter(Boolean).join(" · ")), -+ task.prompt && task.prompt !== task.title ? (0, import_react21.createElement)("span", { className: "bt-task-prompt" }, task.prompt) : null, -+ last ? (0, import_react21.createElement)("span", { className: "bt-task-last", "data-failed": failed || void 0 }, last) : null, -+ rowError ? (0, import_react21.createElement)("span", { className: "bt-task-last", "data-failed": "", role: "alert" }, rowError) : null - ), -- (0, import_react9.createElement)("span", { className: "bt-sr", role: "status" }, memoryNotice ? t("Memory updated") : ""), -- (0, import_react9.createElement)(NewMessagePills, { key: sessionId, sessionId, useSessions, useUi, useTurnClock }), -- (0, import_react9.createElement)(ActivityIndicator, { key: `activity:${sessionId}`, sessionId, useRoster, useSessionStatus, useSession, useLive, actions }) -+ (0, import_react21.createElement)("span", { className: "bt-task-next" }, nextText(task)), -+ // A locked window draws none of the writes the host would refuse (#2232). Hiding, not -+ // disabling: a disabled button still says this window can do it. -+ !locked && failed && task.status !== "paused" && actions.scheduleRetry ? (0, import_react21.createElement)("button", { type: "button", className: "bt-mini bt-task-pause", disabled: busy, title: t("Run the task once now"), onClick: retry }, t("Retry")) : null, -+ !locked && onEdit ? (0, import_react21.createElement)("button", { type: "button", className: "bt-mini bt-task-tool", disabled: busy, "aria-label": t("Edit"), title: t("Edit"), onClick: () => onEdit(task) }, (0, import_react21.createElement)(PencilIcon)) : null, -+ !locked && onCopy ? (0, import_react21.createElement)("button", { type: "button", className: "bt-mini bt-task-tool", disabled: busy, "aria-label": t("Copy to another Bot or group chat"), title: t("Copy to…"), onClick: () => onCopy(task) }, (0, import_react21.createElement)(DuplicateIcon)) : null, -+ !locked && stoppable ? (0, import_react21.createElement)("button", { type: "button", className: "bt-mini bt-task-pause", disabled: busy, onClick: toggle }, task.status === "paused" ? t("Resume") : t("Pause")) : null, -+ locked ? null : (0, import_react21.createElement)("button", { -+ type: "button", -+ className: "bt-mini bt-task-remove", -+ "data-confirm": confirming || void 0, -+ disabled: busy, -+ "aria-label": confirming ? t("Click again to delete") : t("Delete task"), -+ title: confirming ? void 0 : t("Delete task"), -+ onClick: remove -+ }, confirming ? t("Delete") : (0, import_react21.createElement)(TrashIcon)) + "span", + { className: "bt-pill-mem", "aria-hidden": true, style: { "--bt-mem-w": `${width}px` } }, +- (0, import_react9.createElement)("span", { className: "bt-pill-mem-in", ref: inner }, (0, import_react9.createElement)("span", { className: "bt-pill-mem-ico" }, (0, import_react9.createElement)(MemoryIcon)), label) ++ (0, import_react21.createElement)("span", { className: "bt-pill-mem-in", ref: inner }, (0, import_react21.createElement)("span", { className: "bt-pill-mem-ico" }, (0, import_react21.createElement)(MemoryIcon)), label) ); } --function useTranscriptView() { -- const [view, setView] = (0, import_react9.useState)(null); +-function HeaderPill({ sessionId, useRoster, useSessions, useSessionStatus, useSession, useActivity, useMemoryNews, useTurnClock, useUi, useLive, actions }) { ++function HeaderPill({ sessionId, useRoster, useSessions, useSessionStatus, useSession, useActivity, useBusyAcks, useMemoryNews, useTurnClock, useUi, useLive, actions }) { + const roster = useRoster((value) => value); + const title = useSessions((list) => list.byId[sessionId]?.displayTitle ?? list.byId[sessionId]?.title ?? ""); + const firstTurns = useSessions((list) => { +@@ -5537,7 +5537,7 @@ function HeaderPill({ sessionId, useRoster, useSessions, useSessionStatus, useSe + const first = openingTurn(state[sessionId], firstTurns, hiddenTurns); + return first && !unseenSince(visit, first.start) ? clockLabel(first.start) : ""; + }); - (0, import_react9.useEffect)(() => { -- let frame = 0; -- let watched = null; -- const observer = new MutationObserver(() => schedule()); -- const measure = () => { -- frame = 0; -- const scroller = document.querySelector("[data-conversation-scroll]"); -- if (scroller !== watched) { -- observer.disconnect(); -- watched = scroller; -- if (scroller) observer.observe(scroller, { childList: true, subtree: true }); -- } -- if (!scroller) { -- setView(null); -- return; -- } -- const box = scroller.getBoundingClientRect(); -- const headerBottom = document.querySelector('header[class*="_header"]')?.getBoundingClientRect().bottom ?? box.top + 50; -- const sep = scroller.querySelector(".bt-new-sep"); -- let divider = null; -- if (sep) { -- const at = sep.getBoundingClientRect(); -- divider = at.bottom <= headerBottom ? "above" : at.top >= box.bottom ? "below" : "in-view"; -- } -- const composer = parseFloat(getComputedStyle(scroller).getPropertyValue("--dsh-composer-height")) || 152; -- const next = { -- unpinned: scroller.querySelector('[class*="_toBottomSlot"]>button') !== null, -- divider, -- center: Math.round(box.left + box.width / 2), -- top: Math.round(headerBottom + 8), -- bottom: Math.round(window.innerHeight - box.bottom + composer + 8) -- }; -- setView((prev) => prev && Object.keys(next).every((key) => prev[key] === next[key]) ? prev : next); -- }; -- const schedule = () => { -- if (!frame) frame = requestAnimationFrame(measure); -- }; -- document.addEventListener("scroll", schedule, true); -- window.addEventListener("resize", schedule); -- schedule(); ++ (0, import_react21.useEffect)(() => { + if (opening) document.body.style.setProperty("--bt-first-sep", JSON.stringify(opening)); + else document.body.style.removeProperty("--bt-first-sep"); + return () => document.body.style.removeProperty("--bt-first-sep"); +@@ -5546,54 +5546,49 @@ function HeaderPill({ sessionId, useRoster, useSessions, useSessionStatus, useSe + const owner = roster.byId[sessionId]?.id ?? sessionId; + const doing = useActivity((map) => map[owner]); + const bot = roster.byId[sessionId]; ++ const projectAgent = projectAgentFor(bot, roster.self?.memberId); + const room2 = roster.roomsById[sessionId]; + const detailsOpen = useUi((state) => state.details === sessionId); +- const name = bot?.name ?? room2?.name ?? ""; ++ const full2 = bot?.remote ? friendBotTitle(bot) : roomTitle(room2) || bot?.name || ""; ++ const face = room2?.origin === "face-to-face"; ++ const name = face ? faceRowTitle(full2) : full2; + const useNews = useMemoryNews ?? noNews; + const memoryIds = bot ? [bot.id] : room2?.members ?? []; + const notice = useMemoryNotice(memoryIds, useNews); +- const changed = useNews((map) => latestMemoryChange(map, memoryIds)); +- (0, import_react9.useEffect)(() => { +- document.body.style.setProperty("--bt-placeholder", JSON.stringify(name ? t("Message {name}", { name }) : t("Message"))); +- }, [name, t("Message")]); +- (0, import_react9.useEffect)(() => { +- actions.beginVisit(sessionId); +- return () => actions.endVisit(sessionId); +- }, [sessionId]); +- (0, import_react9.useEffect)(() => { +- if (room2) document.body.dataset.btRoom = ""; +- else delete document.body.dataset.btRoom; - return () => { -- cancelAnimationFrame(frame); -- observer.disconnect(); -- document.removeEventListener("scroll", schedule, true); -- window.removeEventListener("resize", schedule); +- delete document.body.dataset.btRoom; - }; -- }, []); -- return view; -+var pad = (number) => String(number).padStart(2, "0"); -+function inAnHour() { -+ const date = new Date(Date.now() + 36e5); -+ date.setMinutes(0, 0, 0); -+ return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:00`; - } --function NewMessagePills({ sessionId, useSessions, useUi, useTurnClock }) { -- const replies = useSessions((list) => { -- const outline = list.projectionsBySession?.[sessionId]?.values?.turnOutline ?? list.byId[sessionId]?.projectionValues?.turnOutline; -- return Array.isArray(outline) ? outline.reduce((count, entry) => count + (entry.response ? 1 : 0), 0) : 0; -- }); -- const visit = useUi((state) => state.visits?.[sessionId]); -- const unread = useTurnClock((state) => { -- if (typeof visit?.seen !== "number") return 0; -- let count = 0; -- for (const entry of Object.values(state[sessionId] ?? {})) { -- if (entry.first !== void 0 && entry.first > visit.seen && entry.first <= visit.entered) count += 1; -+var KINDS = [["at", "Once"], ["daily", "Every day"], ["weekly", "Every week"], ["every", "Repeat"]]; -+var UNITS = [["seconds", 1, "seconds"], ["minutes", 60, "minutes"], ["hours", 3600, "hours"], ["days", 86400, "days"]]; -+function localInput(ms) { -+ const date = new Date(ms); -+ return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}`; -+} -+function formStart(task) { -+ const seconds = task?.kind === "every" ? Number(task.everySeconds) : 3600; -+ const [unit, size] = seconds % 86400 === 0 ? ["days", 86400] : seconds % 3600 === 0 ? ["hours", 3600] : seconds % 60 === 0 ? ["minutes", 60] : ["seconds", 1]; -+ const once = task?.kind === "at" || task?.kind === "after"; -+ return { -+ title: task?.title ?? "", -+ prompt: task?.prompt ?? "", -+ kind: once ? "at" : task?.kind ?? "daily", -+ at: once && !Number.isNaN(Date.parse(task.scheduledAt)) ? localInput(Date.parse(task.scheduledAt)) : inAnHour(), -+ time: task?.time ? clock2(task.time) : "09:00", -+ weekdays: task?.kind === "weekly" && Array.isArray(task.weekdays) ? task.weekdays : [1, 2, 3, 4, 5], -+ count: Math.max(1, Math.round(seconds / size)), -+ unit, -+ timeZone: task?.timeZone || localZone() -+ }; -+} -+function TaskForm({ roster, actions, start, task, onDone, onCancel }) { -+ const first = formStart(task); -+ const [target, setTarget] = (0, import_react21.useState)(task ? task.botId ?? task.roomId : start ?? ""); -+ const [title, setTitle] = (0, import_react21.useState)(first.title); -+ const [prompt, setPrompt] = (0, import_react21.useState)(first.prompt); -+ const [kind, setKind] = (0, import_react21.useState)(first.kind); -+ const [at, setAt] = (0, import_react21.useState)(first.at); -+ const [time, setTime] = (0, import_react21.useState)(first.time); -+ const [weekdays, setWeekdays] = (0, import_react21.useState)(first.weekdays); -+ const [count, setCount] = (0, import_react21.useState)(first.count); -+ const [unit, setUnit] = (0, import_react21.useState)(first.unit); -+ const [busy, setBusy] = (0, import_react21.useState)(false); -+ const [error, setError] = (0, import_react21.useState)(""); -+ const timing = () => { -+ if (kind === "cron") return { kind: "keep" }; -+ if (kind === "at") return { kind, at: new Date(at).toISOString(), timeZone: first.timeZone }; -+ if (kind === "every") return { kind, seconds: Math.round(Number(count) * UNITS.find(([id]) => id === unit)[1]) }; -+ return { kind, time, timeZone: first.timeZone, ...kind === "weekly" ? { weekdays } : {} }; -+ }; -+ const submit = (event) => { -+ event.preventDefault(); -+ if (busy || target === "") return; -+ setBusy(true); -+ setError(""); -+ let when3; -+ try { -+ when3 = timing(); -+ } catch { -+ setError(t("Pick a date and time")); -+ setBusy(false); -+ return; - } -- return count; -- }); -- const view = useTranscriptView(); -- const unpinned = view?.unpinned === true; +- }, [Boolean(room2)]); +- const memoryNotice = notice && changed !== null && roster.memory !== false; +- return (0, import_react9.createElement)( ++ const changed2 = useNews((map) => latestMemoryChange(map, memoryIds)); ++ const memoryNotice = notice && changed2 !== null && roster.memory !== false; ++ return (0, import_react21.createElement)( + "div", + { className: "bt-head" }, +- (0, import_react9.createElement)( ++ (0, import_react21.createElement)( + "button", + { + type: "button", + className: "bt-pill", + "aria-expanded": detailsOpen, + "data-memory": memoryNotice ? "updated" : void 0, +- "aria-label": memoryNotice ? t("Memory updated. Open {name}'s memory", { name: roster.byId[changed]?.name ?? name }) : t("View conversation details"), +- onClick: () => memoryNotice ? actions.openMemory(changed) : actions.toggleDetails(sessionId) ++ "aria-label": memoryNotice ? t("Memory updated. Open {name}'s memory", { name: roster.byId[changed2]?.name ?? full2 }) : t("View conversation details"), ++ onClick: () => memoryNotice ? actions.openMemory(changed2) : actions.toggleDetails(sessionId) + }, +- bot ? (0, import_react9.createElement)(BotAvatar, { bot, size: 24, state: botState(running, doing, roster.questions?.[currentPart(bot)] !== void 0), badge: false }) : room2 ? (0, import_react9.createElement)(RoomAvatar, { room: room2, roster, size: 24 }) : null, +- (0, import_react9.createElement)("span", { className: "bt-pill-name" }, bot?.name ?? room2?.name ?? (title || t("New chat"))), +- (0, import_react9.createElement)(MemoryNotice, { shown: memoryNotice }), +- (0, import_react9.createElement)("span", { className: "bt-pill-go", "aria-hidden": true }, (0, import_react9.createElement)(GoArrow)) ++ bot ? (0, import_react21.createElement)(BotAvatar, { bot, size: 24, state: botState(running, doing, roster.questions?.[currentPart(bot)] !== void 0), badge: false }) : room2 ? (0, import_react21.createElement)(RoomAvatar, { room: room2, roster, size: 24 }) : null, ++ (0, import_react21.createElement)("span", { className: face ? "bt-pill-name bt-pill-name-face" : "bt-pill-name", title: face ? full2 : void 0 }, name || title || t("New chat")), ++ (0, import_react21.createElement)(MemoryNotice, { shown: memoryNotice }), ++ (0, import_react21.createElement)("span", { className: "bt-pill-go", "aria-hidden": true }, (0, import_react21.createElement)(GoArrow)) + ), +- (0, import_react9.createElement)("span", { className: "bt-sr", role: "status" }, memoryNotice ? t("Memory updated") : ""), +- (0, import_react9.createElement)(NewMessagePills, { key: sessionId, sessionId, useSessions, useUi, useTurnClock }), +- (0, import_react9.createElement)(ActivityIndicator, { key: `activity:${sessionId}`, sessionId, useRoster, useSessionStatus, useSession, useLive, actions }) ++ (0, import_react21.createElement)("span", { className: "bt-sr", role: "status" }, memoryNotice ? t("Memory updated") : ""), ++ projectAgent === null ? null : (0, import_react21.createElement)(ProjectAgentEntry, { ++ projectId: projectAgent.projectId, ++ read: call, ++ onOpen: (id) => actions?.openSession?.(id) ++ }), ++ (0, import_react21.createElement)(NewMessagePills, { key: sessionId, sessionId, hiddenTurns, useSessions, useUi, useTurnClock, actions }), ++ (0, import_react21.createElement)(ActivityIndicator, { key: `activity:${sessionId}`, sessionId, useRoster, useSessionStatus, useSession, useLive, actions }), ++ (0, import_react21.createElement)(BusyAckBubble, { key: `busy-ack:${sessionId}`, sessionId, useRoster, useBusyAcks, useSessionStatus, useSession, useLive }) + ); + } + function useTranscriptView() { +- const [view, setView] = (0, import_react9.useState)(null); +- (0, import_react9.useEffect)(() => { ++ const [view, setView] = (0, import_react21.useState)(null); ++ (0, import_react21.useEffect)(() => { + let frame = 0; + let watched = null; + const observer = new MutationObserver(() => schedule()); +@@ -5642,7 +5642,7 @@ function useTranscriptView() { + }, []); + return view; + } +-function NewMessagePills({ sessionId, useSessions, useUi, useTurnClock }) { ++function NewMessagePills({ sessionId, hiddenTurns, useSessions, useUi, useTurnClock, actions }) { + const replies = useSessions((list) => { + const outline = list.projectionsBySession?.[sessionId]?.values?.turnOutline ?? list.byId[sessionId]?.projectionValues?.turnOutline; + return Array.isArray(outline) ? outline.reduce((count, entry) => count + (entry.response ? 1 : 0), 0) : 0; +@@ -5658,18 +5658,22 @@ function NewMessagePills({ sessionId, useSessions, useUi, useTurnClock }) { + }); + const view = useTranscriptView(); + const unpinned = view?.unpinned === true; - const [release, setRelease] = (0, import_react9.useState)({ base: null, dismissed: false }); - const [above, setAbove] = (0, import_react9.useState)({ seen: false, dismissed: false }); - (0, import_react9.useEffect)(() => { -- setRelease({ base: unpinned ? replies : null, dismissed: false }); -- }, [unpinned]); ++ const [release, setRelease] = (0, import_react21.useState)({ base: null, dismissed: false }); ++ const [above, setAbove] = (0, import_react21.useState)({ seen: false, dismissed: false }); ++ (0, import_react21.useEffect)(() => { + setRelease({ base: unpinned ? replies : null, dismissed: false }); + }, [unpinned]); - (0, import_react9.useEffect)(() => { -- if (view?.divider === "in-view") setAbove((state) => state.seen ? state : { ...state, seen: true }); -- }, [view?.divider]); -- const fresh = release.base === null ? 0 : replies - release.base; -- const showDown = unpinned && fresh > 0 && !release.dismissed; -- const showUp = view?.divider === "above" && !above.seen && !above.dismissed; ++ (0, import_react21.useEffect)(() => { + if (view?.divider === "in-view") setAbove((state) => state.seen ? state : { ...state, seen: true }); + }, [view?.divider]); ++ const movedOn = useTurnClock((state) => movedOnSince(visit, state[sessionId], hiddenTurns)); ++ (0, import_react21.useEffect)(() => { ++ if (above.seen && movedOn) actions?.settleVisit?.(sessionId); ++ }, [above.seen, movedOn, sessionId]); + const fresh = release.base === null ? 0 : replies - release.base; + const showDown = unpinned && fresh > 0 && !release.dismissed; + const showUp = view?.divider === "above" && !above.seen && !above.dismissed; - (0, import_react9.useEffect)(() => { -- const scroller = document.querySelector("[data-conversation-scroll]"); -- if (!scroller) return void 0; -- scroller.toggleAttribute("data-bt-news", showDown); -- return () => scroller.removeAttribute("data-bt-news"); -- }, [showDown]); -- if (!view || !showDown && !showUp) return null; -- const label = (count) => count === 1 ? t("1 new message") : count > 0 ? t("{count} new messages", { count }) : t("New messages"); ++ (0, import_react21.useEffect)(() => { + const scroller = document.querySelector("[data-conversation-scroll]"); + if (!scroller) return void 0; + scroller.toggleAttribute("data-bt-news", showDown); +@@ -5677,16 +5677,16 @@ function NewMessagePills({ sessionId, useSessions, useUi, useTurnClock }) { + }, [showDown]); + if (!view || !showDown && !showUp) return null; + const label = (count) => count === 1 ? t("1 new message") : count > 0 ? t("{count} new messages", { count }) : t("New messages"); - const pill = (direction, count, style, onJump, onDismiss) => (0, import_react9.createElement)( -- "div", -- { className: "bt-news", "data-direction": direction, style }, ++ const pill = (direction, count, style, onJump, onDismiss) => (0, import_react21.createElement)( + "div", + { className: "bt-news", "data-direction": direction, style }, - (0, import_react9.createElement)( -- "button", -- { type: "button", className: "bt-news-jump", onClick: onJump }, -- (0, import_react9.createElement)("span", { className: "bt-news-ico" }, (0, import_react9.createElement)(direction === "up" ? ArrowUpIcon : ArrowDownIcon)), -- label(count) -+ const saved = task ? actions.scheduleUpdate({ sessionId: task.sessionId, id: task.id, target, title, prompt, when: when3 }) : actions.scheduleCreate({ target, title, prompt, when: when3 }); -+ void saved.then(onDone, (failure) => { -+ setError(hostText(failure?.message ?? String(failure))); -+ setBusy(false); -+ }); -+ }; -+ const toggleDay = (day) => setWeekdays((days) => days.includes(day) ? days.filter((item) => item !== day) : [...days, day].sort()); -+ return (0, import_react21.createElement)( -+ "form", -+ { className: "bt-set-card bt-task-form", onSubmit: submit }, + (0, import_react21.createElement)( -+ "label", -+ { className: "bt-set-row bt-bs-row" }, -+ (0, import_react21.createElement)("span", { className: "bt-set-copy" }, (0, import_react21.createElement)("span", { className: "bt-bs-label" }, t("Send to"))), -+ (0, import_react21.createElement)( -+ "select", -+ { className: "bt-select bt-task-bot", value: target, onChange: (event) => setTarget(event.target.value) }, -+ (0, import_react21.createElement)("option", { value: "" }, t("Pick a Bot or group chat")), -+ (0, import_react21.createElement)("optgroup", { label: t("Bots") }, roster.bots.map((bot) => (0, import_react21.createElement)("option", { key: bot.id, value: bot.id }, bot.name))), -+ roster.rooms.length > 0 ? (0, import_react21.createElement)("optgroup", { label: t("Group chats") }, roster.rooms.map((room2) => (0, import_react21.createElement)("option", { key: room2.id, value: room2.id }, room2.name))) : null -+ ) + "button", + { type: "button", className: "bt-news-jump", onClick: onJump }, +- (0, import_react9.createElement)("span", { className: "bt-news-ico" }, (0, import_react9.createElement)(direction === "up" ? ArrowUpIcon : ArrowDownIcon)), ++ (0, import_react21.createElement)("span", { className: "bt-news-ico" }, (0, import_react21.createElement)(direction === "up" ? ArrowUpIcon : ArrowDownIcon)), + label(count) ), - (0, import_react9.createElement)("button", { -- type: "button", -- className: "bt-news-x", -- "aria-label": t("Dismiss new messages"), -- onClick: (event) => { -- event.stopPropagation(); -- onDismiss(); -- } ++ (0, import_react21.createElement)("button", { + type: "button", + className: "bt-news-x", + "aria-label": t("Dismiss new messages"), +@@ -5694,7 +5694,7 @@ function NewMessagePills({ sessionId, useSessions, useUi, useTurnClock }) { + event.stopPropagation(); + onDismiss(); + } - }, (0, import_react9.createElement)(CloseIcon)) -+ (0, import_react21.createElement)( -+ "label", -+ { className: "bt-set-row bt-bs-row" }, -+ (0, import_react21.createElement)("span", { className: "bt-set-copy" }, (0, import_react21.createElement)("span", { className: "bt-bs-label" }, t("Name"))), -+ (0, import_react21.createElement)("input", { className: "bt-bs-input", value: title, maxLength: 120, placeholder: t("Morning briefing"), autoFocus: true, onChange: (event) => setTitle(event.target.value) }) -+ ), -+ (0, import_react21.createElement)( -+ "label", -+ { className: "bt-set-row bt-bs-row bt-bs-stack" }, -+ (0, import_react21.createElement)("span", { className: "bt-set-copy" }, (0, import_react21.createElement)("span", { className: "bt-bs-label" }, t("What to do"))), -+ (0, import_react21.createElement)("textarea", { className: "bt-bs-input bt-bs-area", value: prompt, rows: 3, placeholder: t("Sum up what changed since yesterday and what needs me today."), onChange: (event) => setPrompt(event.target.value) }) -+ ), -+ (0, import_react21.createElement)( -+ "div", -+ { className: "bt-set-row bt-bs-row bt-bs-stack" }, -+ (0, import_react21.createElement)("span", { className: "bt-set-copy" }, (0, import_react21.createElement)("span", { className: "bt-bs-label" }, t("When"))), -+ (0, import_react21.createElement)(Segmented, { label: t("When"), value: kind }, KINDS.map(([id, label]) => (0, import_react21.createElement)("button", { key: id, type: "button", "aria-pressed": kind === id, onClick: () => setKind(id) }, t(label)))), -+ (0, import_react21.createElement)( -+ "div", -+ { key: kind, className: "bt-task-when bt-veil" }, -+ kind === "cron" ? (0, import_react21.createElement)("span", { className: "bt-task-every" }, timingText(task)) : null, -+ kind === "at" ? (0, import_react21.createElement)("input", { className: "bt-input", type: "datetime-local", value: at, "aria-label": t("Date and time"), onChange: (event) => setAt(event.target.value) }) : null, -+ kind === "weekly" ? (0, import_react21.createElement)("div", { className: "bt-task-days", role: "group", "aria-label": t("Days") }, [1, 2, 3, 4, 5, 6, 7].map((day) => (0, import_react21.createElement)("button", { -+ key: day, -+ type: "button", -+ className: "bt-task-day", -+ "aria-pressed": weekdays.includes(day), -+ onClick: () => toggleDay(day) -+ }, when2(Date.UTC(2024, 0, day), { weekday: "narrow", timeZone: "UTC" })))) : null, -+ kind === "daily" || kind === "weekly" ? (0, import_react21.createElement)("input", { className: "bt-input bt-task-time", type: "time", value: time, "aria-label": t("Time"), onChange: (event) => setTime(event.target.value) }) : null, -+ kind === "every" ? (0, import_react21.createElement)( -+ import_react21.Fragment, -+ null, -+ (0, import_react21.createElement)("span", { className: "bt-task-every" }, t("Every")), -+ (0, import_react21.createElement)("input", { className: "bt-input bt-task-count", type: "number", min: 1, value: count, "aria-label": t("How many"), onChange: (event) => setCount(event.target.value) }), -+ (0, import_react21.createElement)( -+ "select", -+ { className: "bt-select bt-task-unit", value: unit, "aria-label": t("Unit"), onChange: (event) => setUnit(event.target.value) }, -+ UNITS.map(([id, , label]) => (0, import_react21.createElement)("option", { key: id, value: id }, t(label))) -+ ) -+ ) : null -+ ) -+ ), -+ error ? (0, import_react21.createElement)("div", { className: "bt-set-row" }, (0, import_react21.createElement)("span", { className: "bt-danger bt-set-hint", role: "alert" }, error)) : null, -+ (0, import_react21.createElement)( -+ "div", -+ { className: "bt-set-row bt-task-foot" }, -+ (0, import_react21.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: onCancel }, t("Cancel")), -+ (0, import_react21.createElement)( -+ "button", -+ { type: "submit", className: "bt-send bt-soft-sm", disabled: busy || !target || title.trim() === "" || prompt.trim() === "" }, -+ task ? busy ? t("Saving…") : t("Save changes") : busy ? t("Creating…") : t("Create task") -+ ) -+ ) ++ }, (0, import_react21.createElement)(CloseIcon)) ); -- const toLatest = () => { -- const scroller = document.querySelector("[data-conversation-scroll]"); -- const back = scroller?.querySelector('[class*="_toBottomSlot"]>button'); -- if (back) back.click(); -- else scroller?.scrollTo({ top: scroller.scrollHeight, behavior: "smooth" }); -+} -+function openPlugins(actions) { -+ actions.closeSettings(); -+ actions.selectPanel("plugins"); -+} -+function AutomationOff({ actions }) { -+ return (0, import_react21.createElement)( -+ "div", -+ { className: "bt-set-card bt-task-off" }, -+ (0, import_react21.createElement)("span", { className: "bt-task-off-mark" }, (0, import_react21.createElement)(ClockIcon)), -+ (0, import_react21.createElement)("span", { className: "bt-task-off-title" }, t("Turn on Automation tasks in DSH")), -+ (0, import_react21.createElement)("span", { className: "bt-set-hint" }, t("Switch on Automation tasks on the Plugins page.")), -+ (0, import_react21.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: () => openPlugins(actions) }, t("Open Plugins")) -+ ); -+} -+function QuietHoursCard({ view, actions, locked }) { -+ const quiet = view.quietHours ?? { on: false }; -+ const [busy, setBusy] = (0, import_react21.useState)(false); -+ const [error, setError] = (0, import_react21.useState)(""); -+ const [from, setFrom] = (0, import_react21.useState)(quiet.from ?? "22:00"); -+ const [to, setTo] = (0, import_react21.useState)(quiet.to ?? "07:00"); -+ const unchecked = view.quietUnchecked === true; -+ const refresh = () => { -+ void Promise.resolve(actions.schedules?.()).then((next) => { -+ if (next) publish(next); -+ }).catch(() => { -+ }); + const toLatest = () => { + const scroller = document.querySelector("[data-conversation-scroll]"); +@@ -5703,9 +5703,9 @@ function NewMessagePills({ sessionId, useSessions, useUi, useTurnClock }) { + else scroller?.scrollTo({ top: scroller.scrollHeight, behavior: "smooth" }); }; -- const toDivider = () => document.querySelector("[data-conversation-scroll] .bt-new-sep")?.scrollIntoView({ block: "center", behavior: "smooth" }); + const toDivider = () => document.querySelector("[data-conversation-scroll] .bt-new-sep")?.scrollIntoView({ block: "center", behavior: "smooth" }); - return (0, import_react_dom4.createPortal)( - (0, import_react9.createElement)( -- import_react9.Fragment, -- null, -- showUp ? pill("up", unread, { left: view.center, top: view.top }, toDivider, () => setAbove((state) => ({ ...state, dismissed: true }))) : null, -- showDown ? pill("down", fresh, { left: view.center, bottom: view.bottom }, toLatest, () => setRelease((state) => ({ ...state, dismissed: true }))) : null -+ const save = (next) => { -+ if (busy) return; -+ setBusy(true); -+ setError(""); -+ const shown = next.on ? { on: true, from: next.from ?? from, to: next.to ?? to } : { on: false }; -+ void Promise.resolve(actions.setPrefs({ quietHours: next })).then(() => { -+ publish({ ...view, quietHours: shown }); -+ refresh(); -+ }, (failure) => setError(hostText(failure?.message ?? String(failure)))).finally(() => setBusy(false)); -+ }; -+ return (0, import_react21.createElement)( -+ "section", -+ { className: "bt-set-section" }, -+ (0, import_react21.createElement)("h3", null, t("Quiet hours")), -+ (0, import_react21.createElement)( -+ "div", -+ { className: "bt-set-card" }, -+ (0, import_react21.createElement)( -+ "div", -+ { className: "bt-set-row bt-bs-row" }, -+ (0, import_react21.createElement)( -+ "span", -+ { className: "bt-set-copy" }, -+ (0, import_react21.createElement)("span", { className: "bt-bs-label" }, quiet.on ? t("On, {from}–{to}", { from: quiet.from, to: quiet.to }) : t("Off")), -+ (0, import_react21.createElement)("span", { className: "bt-set-hint" }, t("A new or edited task cannot be due inside the window. It never mutes a task that already exists.")) -+ ), -+ locked ? null : (0, import_react21.createElement)("button", { -+ type: "button", -+ className: "bt-soft bt-soft-sm", -+ disabled: busy, -+ "aria-pressed": quiet.on, -+ onClick: () => save(quiet.on ? { on: false } : { on: true, from, to }) -+ }, quiet.on ? t("Turn off") : t("Turn on")) -+ ), -+ quiet.on && !locked ? (0, import_react21.createElement)( -+ "div", -+ { className: "bt-set-row bt-bs-row" }, -+ (0, import_react21.createElement)("span", { className: "bt-set-copy" }, (0, import_react21.createElement)("span", { className: "bt-bs-label" }, t("Window"))), -+ (0, import_react21.createElement)( -+ "div", -+ { className: "bt-task-when" }, -+ (0, import_react21.createElement)("input", { className: "bt-input bt-task-time", type: "time", value: from, "aria-label": t("Quiet hours start"), disabled: busy, onChange: (event) => setFrom(event.target.value) }), -+ (0, import_react21.createElement)("span", { className: "bt-task-every" }, "–"), -+ (0, import_react21.createElement)("input", { className: "bt-input bt-task-time", type: "time", value: to, "aria-label": t("Quiet hours end"), disabled: busy, onChange: (event) => setTo(event.target.value) }), -+ (0, import_react21.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", disabled: busy, onClick: () => save({ on: true, from, to }) }, t("Save window")) -+ ) -+ ) : null, -+ error ? (0, import_react21.createElement)("div", { className: "bt-set-row" }, (0, import_react21.createElement)("span", { className: "bt-danger bt-set-hint", role: "alert" }, error)) : null, -+ quiet.on && unchecked ? (0, import_react21.createElement)("div", { className: "bt-set-row" }, (0, import_react21.createElement)("span", { className: "bt-set-hint" }, t("A repeat or cron task can still run inside quiet hours, because DSH decides its next run. FlyBot says so here rather than claiming a silence it cannot keep."))) : null -+ ) -+ ); -+} -+function SchedulesPage({ roster, actions, start }) { -+ const view = useSchedules(actions); -+ const [adding, setAdding] = (0, import_react21.useState)(Boolean(start)); -+ const [editing, setEditing] = (0, import_react21.useState)(null); -+ const locked = lockedWindow(roster); -+ const refused = schedulesReadFailure(view?.readFailure); -+ if (refused !== null) return (0, import_react21.createElement)("div", { className: "bt-set-hint bt-danger", role: "status" }, refused); -+ if (view === null) return (0, import_react21.createElement)("div", { className: "bt-set-hint" }, t("Loading…")); -+ if (!view.available) return (0, import_react21.createElement)(AutomationOff, { actions }); -+ const shown = (next) => { -+ publish(next); -+ setAdding(false); -+ setEditing(null); -+ }; -+ const edit = (task) => { -+ setAdding(false); -+ setEditing(task); -+ }; -+ const byChat = [ -+ ...roster.bots.map((bot) => [bot.id, (0, import_react21.createElement)(BotAvatar, { bot, size: 18, badge: false, live: false }), bot.name, view.tasks.filter((task) => task.botId === bot.id)]), -+ ...roster.rooms.map((room2) => [room2.id, (0, import_react21.createElement)(RoomAvatar, { room: room2, roster, size: 18 }), room2.name, view.tasks.filter((task) => task.botId === void 0 && task.roomId === room2.id)]) -+ ].filter(([, , , tasks]) => tasks.length > 0); -+ return [ -+ (0, import_react21.createElement)(QuietHoursCard, { key: "quiet", view, actions, locked }), -+ // A locked window draws no create control and opens no form (#2232): the header already says -+ // why, and a button the host refuses is the defect this page had. -+ locked ? null : adding ? (0, import_react21.createElement)(TaskForm, { key: "form", roster, actions, start, onDone: shown, onCancel: () => setAdding(false) }) : (0, import_react21.createElement)("div", { key: "add", className: "bt-actions" }, (0, import_react21.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm bt-with-icon", onClick: () => { -+ setEditing(null); -+ setAdding(true); -+ } }, (0, import_react21.createElement)(PlusIcon), t("New task"))), -+ byChat.length === 0 && !adding ? (0, import_react21.createElement)("div", { key: "none", className: "bt-set-card" }, (0, import_react21.createElement)("div", { className: "bt-set-row" }, (0, import_react21.createElement)("span", { className: "bt-set-hint" }, view.someHidden ? t("No scheduled tasks you can see. Another member's tasks stay hidden.") : t("No scheduled tasks yet.")))) : byChat.map(([id, avatar, name, tasks]) => (0, import_react21.createElement)( -+ "section", -+ { key: id, className: "bt-set-section" }, -+ (0, import_react21.createElement)("h3", { className: "bt-task-head" }, avatar, name), -+ (0, import_react21.createElement)(TaskList, { tasks, editing, roster, actions, locked, onEdit: edit, onDone: shown, onCancel: () => setEditing(null) }) -+ )) -+ ]; -+} -+function TaskList({ tasks, editing, roster, actions, locked, onEdit, onDone, onCancel, extra = [] }) { -+ const open = !locked && editing ? tasks.find((task) => task.id === editing.id && task.sessionId === editing.sessionId) : void 0; -+ const [copying, setCopying] = (0, import_react21.useState)(null); -+ const source = copying ? tasks.find((task) => task.id === copying.id && task.sessionId === copying.sessionId) : void 0; -+ const rows = tasks.filter((task) => task !== open && task !== source).map((task) => (0, import_react21.createElement)(TaskRow, { key: task.id, task, actions, roster, locked, onView: publish, onEdit, onCopy: actions.scheduleCreate ? setCopying : void 0 })); -+ return (0, import_react21.createElement)( -+ import_react21.Fragment, -+ null, -+ open ? (0, import_react21.createElement)(TaskForm, { key: `edit-${open.id}`, roster, actions, task: open, onDone, onCancel }) : null, -+ source ? (0, import_react21.createElement)(CopyRow, { key: `copy-${source.id}`, task: source, roster, actions, onView: (next) => { -+ setCopying(null); -+ onDone(next); -+ }, onCancel: () => setCopying(null) }) : null, -+ rows.length + extra.length > 0 ? (0, import_react21.createElement)("div", { className: "bt-set-card" }, ...rows, ...extra) : null -+ ); -+} -+function CopyRow({ task, roster, actions, onView, onCancel }) { -+ const others = [ -+ ...roster.bots.filter((bot) => bot.id !== task.botId).map((bot) => [bot.id, bot.name]), -+ ...roster.rooms.filter((room2) => room2.id !== task.roomId).map((room2) => [room2.id, room2.name]) -+ ]; -+ const [target, setTarget] = (0, import_react21.useState)(""); -+ const [busy, setBusy] = (0, import_react21.useState)(false); -+ const [error, setError] = (0, import_react21.useState)(""); -+ const submit = (event) => { -+ event.preventDefault(); -+ if (busy || target === "") return; -+ setBusy(true); -+ setError(""); -+ void actions.scheduleCreate({ target, copyFrom: { sessionId: task.sessionId, id: task.id } }).then(onView, (failure) => { -+ setBusy(false); -+ setError(hostText(failure?.message ?? String(failure))); -+ }); -+ }; -+ return (0, import_react21.createElement)( -+ "form", -+ { className: "bt-set-row bt-task-copy", onSubmit: submit }, -+ (0, import_react21.createElement)("span", { className: "bt-set-copy" }, (0, import_react21.createElement)("span", { className: "bt-bs-label" }, t("Copy “{name}” to", { name: task.title }))), -+ (0, import_react21.createElement)( -+ "select", -+ { className: "bt-select bt-task-bot", value: target, "aria-label": t("Copy to"), onChange: (event) => setTarget(event.target.value) }, -+ (0, import_react21.createElement)("option", { value: "" }, t("Pick a Bot or group chat")), -+ others.map(([id, name]) => (0, import_react21.createElement)("option", { key: id, value: id }, name)) - ), -- document.body -+ (0, import_react21.createElement)("button", { type: "submit", className: "bt-soft bt-soft-sm", disabled: busy || target === "" }, busy ? t("Copying…") : t("Copy")), -+ (0, import_react21.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: onCancel }, t("Cancel")), -+ error ? (0, import_react21.createElement)("span", { className: "bt-danger bt-set-hint", role: "alert" }, error) : null -+ ); -+} -+function BotSchedules({ bot, roster, actions }) { -+ const view = useSchedules(actions); -+ const [editing, setEditing] = (0, import_react21.useState)(null); -+ const locked = lockedWindow(roster); -+ const refused = schedulesReadFailure(view?.readFailure); -+ if (refused !== null) { -+ return (0, import_react21.createElement)( -+ "section", -+ { className: "bt-set-section" }, -+ (0, import_react21.createElement)("h3", null, t("Scheduled tasks")), -+ (0, import_react21.createElement)("div", { className: "bt-set-card" }, (0, import_react21.createElement)( -+ "div", -+ { className: "bt-set-row bt-bs-row" }, -+ (0, import_react21.createElement)("span", { className: "bt-set-copy" }, (0, import_react21.createElement)("span", { className: "bt-set-hint bt-danger", role: "status" }, refused)) -+ )) -+ ); -+ } -+ if (view === null) return null; -+ const tasks = view.available ? view.tasks.filter((task) => task.botId === bot.id) : []; -+ return (0, import_react21.createElement)( -+ "section", -+ { className: "bt-set-section" }, -+ (0, import_react21.createElement)("h3", null, t("Scheduled tasks")), -+ !view.available ? (0, import_react21.createElement)( -+ "div", -+ { className: "bt-set-card" }, -+ (0, import_react21.createElement)( -+ "div", -+ { className: "bt-set-row bt-bs-row" }, -+ (0, import_react21.createElement)("span", { className: "bt-set-copy" }, (0, import_react21.createElement)("span", { className: "bt-set-hint" }, t("Switch on Automation tasks on the Plugins page."))), -+ (0, import_react21.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: () => openPlugins(actions) }, t("Open Plugins")) -+ ) -+ ) : (0, import_react21.createElement)(TaskList, { -+ tasks, -+ editing, -+ roster, -+ actions, -+ locked, -+ onEdit: locked ? void 0 : setEditing, -+ onDone: (next) => { -+ publish(next); -+ setEditing(null); -+ }, -+ onCancel: () => setEditing(null), -+ extra: locked ? [] : [(0, import_react21.createElement)("button", { key: "new", type: "button", className: "bt-set-row bt-bs-row bt-gs-add", onClick: () => actions.openSettings("schedules", bot.id) }, (0, import_react21.createElement)(PlusIcon), t("New task"))] -+ }) -+ ); -+} -+function SummaryLines({ tasks, actions }) { -+ const shown = tasks.slice(0, 3); -+ const hidden = tasks.length - shown.length; -+ return (0, import_react21.createElement)( -+ import_react21.Fragment, -+ null, -+ (0, import_react21.createElement)("div", { className: "bt-section-title" }, hidden > 0 ? t("Scheduled tasks · {count}", { count: tasks.length }) : t("Scheduled tasks")), +- import_react9.Fragment, ++ return (0, import_react_dom6.createPortal)( + (0, import_react21.createElement)( -+ "div", -+ { className: "bt-task-list" }, -+ shown.map((task) => (0, import_react21.createElement)( -+ "button", -+ { key: task.id, type: "button", className: "bt-task-line", onClick: () => actions.openSettings("schedules") }, -+ (0, import_react21.createElement)(ClockIcon), -+ (0, import_react21.createElement)("span", { className: "bt-task-line-title" }, task.title), -+ (0, import_react21.createElement)("span", { className: "bt-task-line-when" }, nextText(task)) -+ )), -+ hidden > 0 ? (0, import_react21.createElement)("button", { key: "more", type: "button", className: "bt-more", onClick: () => actions.openSettings("schedules") }, t("{count} more not shown", { count: hidden })) : null -+ ) -+ ); -+} -+function ScheduleSummary({ bot, roster, actions }) { -+ const view = useSchedules(actions); -+ if (view === null || view.readFailure !== void 0) return null; -+ const tasks = view.available ? view.tasks.filter((task) => task.botId === bot.id) : []; -+ if (lockedWindow(roster) && tasks.length === 0) return null; -+ return (0, import_react21.createElement)( -+ "div", -+ null, -+ tasks.length === 0 ? (0, import_react21.createElement)("div", { className: "bt-section-title" }, t("Scheduled tasks")) : (0, import_react21.createElement)(SummaryLines, { tasks, actions }), -+ tasks.length === 0 ? (0, import_react21.createElement)("button", { type: "button", className: "bt-more", onClick: () => actions.openSettings("schedules", bot.id) }, view.available === false ? t("Switch on Automation tasks on the Plugins page.") : view.someHidden ? t("No scheduled tasks you can see. Add one") : t("No scheduled tasks yet. Add one")) : null ++ import_react21.Fragment, + null, + showUp ? pill("up", unread, { left: view.center, top: view.top }, toDivider, () => setAbove((state) => ({ ...state, dismissed: true }))) : null, + showDown ? pill("down", fresh, { left: view.center, bottom: view.bottom }, toLatest, () => setRelease((state) => ({ ...state, dismissed: true }))) : null +@@ -5714,19 +5714,135 @@ function NewMessagePills({ sessionId, useSessions, useUi, useTurnClock }) { ); } -+function RoomSchedules({ room: room2, actions }) { -+ const view = useSchedules(actions); -+ if (view === null || view.readFailure !== void 0 || room2.origin === "face-to-face") return null; -+ const tasks = view.available ? view.tasks.filter((task) => task.botId === void 0 && task.roomId === room2.id) : []; -+ if (tasks.length === 0) return null; -+ return (0, import_react21.createElement)("div", null, (0, import_react21.createElement)(SummaryLines, { tasks, actions })); -+} -+var SCHEDULE_CSS = ` -+.bt-task{gap:10px} -+.bt-task-copy{flex-wrap:wrap;gap:8px} -+.bt-task-copy .bt-task-bot{flex:1;min-width:140px;max-width:100%} -+.bt-task[data-done]{opacity:.6} -+.bt-task[data-busy]{opacity:.4} -+.bt-task-title{font-size:13px;line-height:18px;font-weight:500;overflow-wrap:anywhere} -+.bt-task-prompt{font-size:12px;line-height:16px;color:var(--bt-ink-2);display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden} -+.bt-task-next{flex:none;font-size:12px;line-height:16px;color:var(--bt-ink-3);font-variant-numeric:tabular-nums;white-space:nowrap} -+.bt-task-remove{display:inline-flex;align-items:center;justify-content:center;min-width:26px;height:26px;color:var(--bt-ink-3)} -+.bt-task-remove svg{width:14px;height:14px} -+.bt-task-tool{display:inline-flex;align-items:center;justify-content:center;min-width:26px;height:26px;color:var(--bt-ink-3)} -+.bt-task-tool svg{width:14px;height:14px} -+.bt-task-pause{height:26px;color:var(--bt-ink-2)} -+.bt-task-last{font-size:12px;line-height:16px;color:var(--bt-ink-3);overflow-wrap:anywhere} -+.bt-task-last[data-failed]{color:#c21d2e} -+.bt-task[data-paused] .bt-task-title,.bt-task[data-paused] .bt-task-prompt{color:var(--bt-ink-3)} -+.bt-task-remove[data-confirm]{background:rgba(224,33,53,.1);color:#e02135} -+.bt-task-head{display:flex;align-items:center;gap:6px} -+.bt-task-form{animation:bt-q-in .4s cubic-bezier(.22,1,.36,1),bt-veil-in .2s ease-out} -+.bt-task-bot{width:auto;min-width:160px;max-width:58%} -+.bt-task-when{display:flex;align-items:center;flex-wrap:wrap;gap:8px} -+.bt-task-when .bt-input{width:auto;height:32px} -+.bt-task-time{min-width:110px} -+.bt-task-count{width:72px!important} -+.bt-task-unit{width:auto;height:32px} -+.bt-task-every{font-size:13px;color:var(--bt-ink-2)} -+.bt-task-days{display:flex;gap:4px} -+.bt-task-day{width:30px;height:30px;border-radius:50%;border:1px solid var(--bt-line);background:none;color:var(--bt-ink-2);font:inherit;font-size:12px;cursor:pointer;transition:background-color .12s ease,color .12s ease,border-color .12s ease} -+.bt-task-day[aria-pressed=true]{background:var(--bt-user);border-color:var(--bt-user);color:var(--bt-user-ink)} -+.bt-task-foot{justify-content:flex-end;gap:8px} -+.bt-task-foot .bt-send{height:28px;font-size:12px} -+.bt-task-off{align-items:center;gap:8px;padding:28px 20px;text-align:center} -+.bt-task-off>*+*{border-top:0} -+.bt-task-off-mark{width:44px;height:44px;border-radius:50%;background:var(--bt-hover);display:flex;align-items:center;justify-content:center;color:var(--bt-ink-2)} -+.bt-task-off-mark svg{width:20px;height:20px} -+.bt-task-off-title{font-size:14px;line-height:20px;font-weight:600} -+.bt-task-off .bt-set-hint{max-width:380px} -+.bt-task-list{display:flex;flex-direction:column;gap:2px} -+.bt-task-line{display:flex;align-items:center;gap:8px;width:100%;min-width:0;padding:6px 8px;border:0;border-radius:8px;background:none;color:var(--bt-ink);font:inherit;font-size:13px;text-align:left;cursor:pointer;transition:background-color .12s ease} -+.bt-task-line:hover{background:var(--bt-hover)} -+.bt-task-line svg{flex:none;color:var(--bt-ink-3)} -+.bt-task-line-title{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} -+.bt-task-line-when{flex:none;font-size:12px;color:var(--bt-ink-3)} -+@media (prefers-reduced-motion:reduce){.bt-task-form{animation:none}} -+`; ++// src/client/composer-label.js ++var ATTRIBUTES = ["aria-label", "data-placeholder"]; ++function holdComposerLabel(doc, label) { ++ doc.body.style.setProperty("--bt-placeholder", JSON.stringify(label)); ++ const shell = /* @__PURE__ */ new Map(); ++ const apply2 = () => { ++ for (const el of doc.querySelectorAll("[data-composer-input]")) { ++ for (const name of ATTRIBUTES) { ++ const current = el.getAttribute(name); ++ if (current === label) continue; ++ if (!shell.has(el)) shell.set(el, {}); ++ shell.get(el)[name] = current; ++ el.setAttribute(name, label); ++ } ++ } ++ }; ++ apply2(); ++ const Observer = doc.defaultView?.MutationObserver ?? globalThis.MutationObserver; ++ const watcher = typeof Observer === "function" ? new Observer(apply2) : null; ++ watcher?.observe(doc.body, { subtree: true, childList: true, attributes: true, attributeFilter: ATTRIBUTES }); ++ return () => { ++ watcher?.disconnect(); ++ for (const [el, values] of shell) { ++ if (!el.isConnected) continue; ++ for (const [name, value] of Object.entries(values)) { ++ if (value == null) el.removeAttribute(name); ++ else el.setAttribute(name, value); ++ } ++ } ++ }; ++} ++ ++// src/client/header-effects.js ++function installHeaderEffects({ sessions, roster, actions, doc = globalThis.document }) { ++ if (!doc) return () => { ++ }; ++ const slots = /* @__PURE__ */ new Map(); ++ const run = (id, dep, setup) => { ++ const held = slots.get(id); ++ if (held !== void 0 && held.dep === dep) return; ++ held?.off(); ++ slots.set(id, { dep, off: setup() ?? (() => { ++ }) }); ++ }; ++ let running = false; ++ let again = false; ++ const pass = () => { ++ const list = sessions.getSnapshot(); ++ const state = roster.getSnapshot(); ++ const row = Object.values(list?.byId ?? {}).find((entry) => (entry?.retainedBy?.mainView ?? 0) > 0); ++ const sessionId = row?.id; ++ const bot = sessionId === void 0 ? void 0 : state?.byId?.[sessionId]; ++ const room2 = sessionId === void 0 ? void 0 : state?.roomsById?.[sessionId]; ++ const name = bot?.remote ? friendBotTitle(bot) : roomTitle(room2) || bot?.name || ""; ++ run("friend-chat", `${sessionId}|${bot?.remote === true}`, () => { ++ if (!bot?.remote) return void 0; ++ doc.body.dataset.btFriendChat = ""; ++ return () => { ++ delete doc.body.dataset.btFriendChat; ++ }; ++ }); ++ run("remote-empty", `${sessionId}|${bot?.remote === true}|${row?.blank === true}`, () => { ++ if (!bot?.remote || row?.blank !== true) return void 0; ++ doc.body.dataset.btRemoteChat = "empty"; ++ doc.body.style.setProperty("--bt-remote-empty", JSON.stringify(t("Send a message. Your friend approves unless Auto-send is on."))); ++ return () => { ++ delete doc.body.dataset.btRemoteChat; ++ doc.body.style.removeProperty("--bt-remote-empty"); ++ }; ++ }); ++ run("composer-label", `${sessionId}|${name}`, () => holdComposerLabel(doc, name ? t("Message {name}", { name }) : t("Message"))); ++ run("visit", sessionId, () => { ++ if (sessionId === void 0) return void 0; ++ actions.beginVisit(sessionId); ++ return () => actions.endVisit(sessionId); ++ }); ++ run("face-at-menu", `${sessionId}|${room2?.origin === "face-to-face"}`, () => { ++ if (room2?.origin !== "face-to-face") return void 0; ++ const stop = actions.filterFaceAtMenu?.(sessionId); ++ return typeof stop === "function" ? stop : void 0; ++ }); ++ run("room-flag", `${sessionId}|${room2 !== void 0}`, () => { ++ if (room2 === void 0) return void 0; ++ doc.body.dataset.btRoom = ""; ++ return () => { ++ delete doc.body.dataset.btRoom; ++ }; ++ }); ++ }; ++ const apply2 = () => { ++ if (running) { ++ again = true; ++ return; ++ } ++ running = true; ++ try { ++ pass(); ++ } finally { ++ running = false; ++ } ++ if (again) { ++ again = false; ++ apply2(); ++ } ++ }; ++ apply2(); ++ const offSessions = sessions.subscribe(apply2); ++ const offRoster = roster.subscribe(apply2); ++ return () => { ++ offSessions?.(); ++ offRoster?.(); ++ for (const held of slots.values()) held.off(); ++ slots.clear(); ++ }; ++} ++ // src/client/composer.js -var import_react10 = require("react"); -var import_react_dom5 = require("react-dom"); @@ -16617,20 +16376,16 @@ diff --git a/client.js b/client.js - (0, import_react13.createElement)( + (0, import_react23.createElement)( "div", - { className: "bt-msg-line" }, +- { className: "bt-msg-line" }, - (0, import_react13.createElement)(BotMark, { bot, size: 22 }), - (0, import_react13.createElement)("div", { className: "bt-bubble" }, text) -+ (0, import_react23.createElement)(BotMark, { bot, size: 22 }), -+ (0, import_react23.createElement)("div", { className: "bt-bubble" }, text2) - ) - ) - ), +- ) +- ) +- ), - (0, import_react13.createElement)("div", { className: "bt-model-chips", role: "group", "aria-label": t("Choose a model") }, choices), - error ? (0, import_react13.createElement)("div", { className: "bt-secret-error bt-model-error", role: "alert" }, error) : null -+ (0, import_react23.createElement)("div", { className: "bt-model-chips", role: "group", "aria-label": t("Choose a model") }, choices), -+ error ? (0, import_react23.createElement)("div", { className: "bt-secret-error bt-model-error", role: "alert" }, error) : null - ); - } +- ); +-} -function setupChoices(actions) { - return [ - actions.canSignIn?.() ? [t("Sign in to DeepSeek"), () => actions.signIn()] : null, @@ -16786,9 +16541,7 @@ diff --git a/client.js b/client.js - const parent = listing?.crumbs?.length > 1 ? listing.crumbs.at(-2) : void 0; - const inside = listing?.entries?.filter((entry) => !entry.hidden) ?? []; - const browseView = listing ? (0, import_react14.createElement)( -+function RemoteGoneDock({ bot }) { -+ return (0, import_react23.createElement)( - "div", +- "div", - { key: `browse:${listing.path}`, className: "bt-folder-view" }, - (0, import_react14.createElement)("button", { type: "button", className: "bt-folder-back", onClick: () => setView("list") }, (0, import_react14.createElement)(ChevronLeftIcon), (0, import_react14.createElement)("span", { className: "bt-menu-label" }, t("Back"))), - (0, import_react14.createElement)("div", { className: "bt-folder-path bt-folder-here" }, listing.path), @@ -16829,27 +16582,20 @@ diff --git a/client.js b/client.js - value ? (0, import_react14.createElement)("span", { className: "bt-model-provider" }, value) : null - ), - (0, import_react14.createElement)(Caret) -- ), ++ { className: "bt-msg-line" }, ++ (0, import_react23.createElement)(BotMark, { bot, size: 22 }), ++ (0, import_react23.createElement)("div", { className: "bt-bubble" }, text2) ++ ) ++ ) + ), - shownPlace ? (0, import_react_dom7.createPortal)((0, import_react14.createElement)( -+ { className: "bt-model-dock", "data-flybot": "remote-gone" }, -+ (0, import_react23.createElement)( - "div", +- "div", - { ref: menu, className: `bt-menu bt-model-menu bt-folder-menu${shownPlace.bottom !== void 0 ? " bt-menu-up" : ""}`, role: "menu", "aria-label": t("Working folder"), style: shownPlace, "data-leaving": leaving || void 0 }, - shownView, - problem ? (0, import_react14.createElement)("div", { className: "bt-folder-error", role: "alert" }, problem) : null - ), document.body) : null -+ { className: "bt-astack", "data-lead": "" }, -+ (0, import_react23.createElement)( -+ "div", -+ { className: "bt-msg" }, -+ (0, import_react23.createElement)( -+ "div", -+ { className: "bt-msg-line" }, -+ (0, import_react23.createElement)(BotMark, { bot, size: 22 }), -+ (0, import_react23.createElement)("div", { className: "bt-bubble" }, t("{name} ({handle}) is no longer shared with you, so messages to it cannot be delivered. It runs on its owner's machine, so another model cannot stand in. Ask its owner for a new invite.", { name: bot.name, handle: bot.remote.handle })) -+ ) -+ ) -+ ) ++ (0, import_react23.createElement)("div", { className: "bt-model-chips", role: "group", "aria-label": t("Choose a model") }, choices), ++ error ? (0, import_react23.createElement)("div", { className: "bt-secret-error bt-model-error", role: "alert" }, error) : null ); } -var FOLDER_CSS = ` @@ -16896,7 +16642,26 @@ diff --git a/client.js b/client.js - }; - }, []); - return view; --} ++function RemoteGoneDock({ bot }) { ++ return (0, import_react23.createElement)( ++ "div", ++ { className: "bt-model-dock", "data-flybot": "remote-gone" }, ++ (0, import_react23.createElement)( ++ "div", ++ { className: "bt-astack", "data-lead": "" }, ++ (0, import_react23.createElement)( ++ "div", ++ { className: "bt-msg" }, ++ (0, import_react23.createElement)( ++ "div", ++ { className: "bt-msg-line" }, ++ (0, import_react23.createElement)(BotMark, { bot, size: 22 }), ++ (0, import_react23.createElement)("div", { className: "bt-bubble" }, t("{name} ({handle}) is no longer shared with you, so messages to it cannot be delivered. It runs on its owner's machine, so another model cannot stand in. Ask its owner for a new invite.", { name: bot.name, handle: bot.remote.handle })) ++ ) ++ ) ++ ) ++ ); + } -var localZone = () => Intl.DateTimeFormat().resolvedOptions().timeZone; -var clock2 = (time) => String(time ?? "").slice(0, 5); -var when2 = (ms, options) => new Intl.DateTimeFormat(dateLocale(), options).format(ms); @@ -17214,7 +16979,7 @@ diff --git a/client.js b/client.js + if (isOpen) { + close(); return; - } +- } - void actions.scheduleCreate({ target, title, prompt, when: when3 }).then(onDone, (failure) => { - setError(hostText(failure?.message ?? String(failure))); - setBusy(false); @@ -17276,6 +17041,7 @@ diff --git a/client.js b/client.js - ) - ) : null - ) ++ } + setView("list"); + setProblem(""); + void Promise.resolve(actions.folders()).then(setInfo).catch(() => setInfo((last) => last ?? { team: null, picker: null, folders: [] })); @@ -17957,7 +17723,7 @@ diff --git a/client.js b/client.js + meta ? (0, import_react28.createElement)("span", { className: "bt-mem-entry-meta" }, meta) : null ), - (0, import_react17.createElement)("button", { -+ (0, import_react28.createElement)("button", { ++ remove ? (0, import_react28.createElement)("button", { type: "button", className: "bt-mem-remove", "data-confirm": confirming || void 0, @@ -17966,7 +17732,7 @@ diff --git a/client.js b/client.js title: confirming ? void 0 : t("Remove from memory"), onClick: click - }, confirming ? t("Remove") : (0, import_react17.createElement)(TrashIcon)) -+ }, confirming ? t("Remove") : (0, import_react28.createElement)(TrashIcon)) ++ }, confirming ? t("Remove") : (0, import_react28.createElement)(TrashIcon)) : null ); } function Entries({ bot, view, remove }) { @@ -18091,7 +17857,7 @@ diff --git a/client.js b/client.js alive.current = true; void load({ summarize: true }); return () => { -@@ -7606,9 +7606,9 @@ function useMemoryView(botId, actions) { +@@ -7606,28 +7606,29 @@ function useMemoryView(botId, actions) { }, [botId]); return { view, error, load, show }; } @@ -18104,12 +17870,14 @@ diff --git a/client.js b/client.js const id = setInterval(() => setNow(Date.now()), every); return () => clearInterval(id); }, [every]); -@@ -7616,18 +7616,18 @@ function useNow(every) { + return now; } - function MemoryDialog({ bot, actions, leaving = false }) { +-function MemoryDialog({ bot, actions, leaving = false }) { ++function MemoryDialog({ bot, actions, roster, leaving = false }) { const { view, error, load, show } = useMemoryView(bot.id, actions); - const [page, setPage] = (0, import_react17.useState)("summary"); - const [menu, setMenu] = (0, import_react17.useState)(false); ++ const locked = typeof roster?.readOnly === "string" && roster.readOnly.trim() !== ""; + const [page, setPage] = (0, import_react28.useState)("summary"); + const [menu, setMenu] = (0, import_react28.useState)(false); const [menuShown, menuLeaving] = useLinger(menu, MENU_EXIT_MS2); @@ -18142,7 +17910,7 @@ diff --git a/client.js b/client.js if (asked === "" || answer?.pending) return; setText(""); setAnswer({ asked, pending: true }); -@@ -7689,65 +7689,65 @@ function MemoryDialog({ bot, actions, leaving = false }) { +@@ -7689,65 +7689,66 @@ 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; @@ -18200,8 +17968,9 @@ diff --git a/client.js b/client.js { 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 }) ++ locked ? (0, import_react28.createElement)("p", { className: "bt-hint bt-read-only", role: "status" }, hostText(roster.readOnly)) : null, + error ? (0, import_react28.createElement)("div", { className: "bt-error", role: "alert", style: { padding: 0 } }, hostText(error)) : null, -+ view === null ? error ? null : (0, import_react28.createElement)(Skeleton) : page === "entries" ? (0, import_react28.createElement)(Entries, { bot, view, remove }) : (0, import_react28.createElement)(Summary, { bot, view, regenerate }) ++ view === null ? error ? null : (0, import_react28.createElement)(Skeleton) : page === "entries" ? (0, import_react28.createElement)(Entries, { bot, view, remove: locked ? null : remove }) : (0, import_react28.createElement)(Summary, { bot, view, regenerate }) ), - (0, import_react17.createElement)( + (0, import_react28.createElement)( @@ -18210,7 +17979,7 @@ diff --git a/client.js b/client.js - answer ? (0, import_react17.createElement)(Reply, { answer, dismiss: () => setAnswer(null) }) : null, - (0, import_react17.createElement)( + answer ? (0, import_react28.createElement)(Reply, { answer, dismiss: () => setAnswer(null) }) : null, -+ (0, import_react28.createElement)( ++ locked ? null : (0, import_react28.createElement)( "form", { className: "bt-mem-ask", onSubmit: ask }, - (0, import_react17.createElement)("input", { @@ -24174,7 +23943,7 @@ diff --git a/client.js b/client.js { key: "set-body", className: "bt-drawer-body bt-set-body", "data-move": move }, errorLine, - bot ? (0, import_react31.createElement)(BotSettingsForm, { bot, roster, actions, run }) : (0, import_react31.createElement)(GroupSettingsForm, { room: room2, roster, actions, run, onDeleted: close }) -+ bot ? (0, import_react44.createElement)(BotSettingsForm, { bot, roster, actions, run }) : (0, import_react44.createElement)(GroupSettingsForm, { room: room2, roster, actions, run, onDeleted: close }) ++ bot && lockedWindow(roster) ? (0, import_react44.createElement)("div", { className: "bt-hint bt-read-only", role: "status" }, hostText(roster.readOnly)) : bot ? (0, import_react44.createElement)(BotSettingsForm, { bot, roster, actions, run }) : (0, import_react44.createElement)(GroupSettingsForm, { room: room2, roster, actions, run, onDeleted: close }) ) ]; } @@ -24849,7 +24618,7 @@ diff --git a/client.js b/client.js + menu ? (0, import_react_dom11.createPortal)((0, import_react47.createElement)(ContextMenu, { key: `${menu.kind ?? ""}:${menu.id}:${menu.x}:${menu.y}`, menu, roster, actions, leaving: menuLeaving }), document.body) : null, + settings ? (0, import_react_dom11.createPortal)((0, import_react47.createElement)(SettingsDialog, { page: settings.page, id: settings.id, roster, actions, useUpdate, leaving: settingsLeaving }), document.body) : null, + remoteBotSettings ? (0, import_react_dom11.createPortal)((0, import_react47.createElement)(RemoteBotSettingsSheet, { key: remoteBotSettings, bot: roster.byId[remoteBotSettings], roster, actions, leaving: remoteLeaving }), document.body) : null, -+ memory && roster.byId[memory] ? (0, import_react_dom11.createPortal)((0, import_react47.createElement)(MemoryDialog, { key: memory, bot: roster.byId[memory], actions, leaving: memoryLeaving }), document.body) : null, ++ memory && roster.byId[memory] ? (0, import_react_dom11.createPortal)((0, import_react47.createElement)(MemoryDialog, { key: memory, bot: roster.byId[memory], roster, actions, leaving: memoryLeaving }), document.body) : null, + inbox ? (0, import_react_dom11.createPortal)((0, import_react47.createElement)(InboxDialog, { roster, useWork, useSessions, actions, leaving: inboxLeaving }), document.body) : null, + board ? (0, import_react_dom11.createPortal)((0, import_react47.createElement)(KanbanDialog, { roster, actions, leaving: boardLeaving }), document.body) : null ); diff --git a/patches/ds-bot/0012-room-membership.patch b/patches/ds-bot/0012-room-membership.patch index 7d2ef2e9..db902b3e 100644 --- a/patches/ds-bot/0012-room-membership.patch +++ b/patches/ds-bot/0012-room-membership.patch @@ -122,10 +122,10 @@ diff --git a/src/client/details-drawer.js b/src/client/details-drawer.js import { FolderMenu } from './folder-menu.js' import { MemoryRow } from './memory-panel.js' -import { ScheduleSummary } from './schedules.js' -+import { RoomSchedules, ScheduleSummary } from './schedules.js' ++import { RoomSchedules, ScheduleSummary, lockedWindow } from './schedules.js' import { Segmented, useGlide, useMorph } from './motion.js' import { MemberWork, useMemberWork } from './member-status.js' - import { t } from './i18n.js' +-import { t } from './i18n.js' - -function transcriptOf(list, id, name, hiddenTurns = []) { - const outline = list.projectionsBySession?.[id]?.values?.turnOutline ?? list.byId[id]?.projectionValues?.turnOutline @@ -138,6 +138,7 @@ diff --git a/src/client/details-drawer.js b/src/client/details-drawer.js - } - return lines.join('\n\n') -} ++import { hostText, t } from './i18n.js' +import { remoteBotTitle, roomTitle, copiedToClipboard } from '../shared/remote-label.js' +import { FlyBotRoomOverview, FlyBotDetails } from './flybot-slots.js' +import { call } from './api.js' @@ -268,7 +269,7 @@ diff --git a/src/client/details-drawer.js b/src/client/details-drawer.js ] } -@@ -164,19 +164,27 @@ export function DetailsDrawer({ sessionId, startRenaming, startTab, hasStrip, ro +@@ -164,34 +164,56 @@ export function DetailsDrawer({ sessionId, startRenaming, startTab, hasStrip, ro const bot = owner const running = useSessionStatus(map => map.get(part)?.running === true) const doing = useActivity(map => map[bot?.id ?? sessionId]) @@ -299,7 +300,13 @@ diff --git a/src/client/details-drawer.js b/src/client/details-drawer.js return [ h('div', { key: 'head', className: 'bt-subhead', 'data-move': move }, h('button', { type: 'button', className: 'bt-icon-btn', 'aria-label': t('Back to details'), onClick: () => setView('overview') }, h(ChevronLeftIcon)), -@@ -187,11 +187,23 @@ export function DetailsDrawer({ sessionId, startRenaming, startTab, hasStrip, ro + h('span', { className: 'bt-subhead-title' }, bot ? t('Settings') : t('Group settings')), + hasStrip ? null : h('button', { type: 'button', className: 'bt-icon-btn', 'aria-label': t('Close details'), onClick: close }, h(CloseIcon))), + h('div', { key: 'set-body', className: 'bt-drawer-body bt-set-body', 'data-move': move }, errorLine, +- bot ? h(BotSettingsForm, { bot, roster, actions, run }) : h(GroupSettingsForm, { room, roster, actions, run, onDeleted: close })), ++ bot && lockedWindow(roster) ++ ? h('div', { className: 'bt-hint bt-read-only', role: 'status' }, hostText(roster.readOnly)) ++ : bot ? h(BotSettingsForm, { bot, roster, actions, run }) : h(GroupSettingsForm, { room, roster, actions, run, onDeleted: close })), ] } const top = h('div', { className: 'bt-drawer-top', 'data-move': side }, diff --git a/patches/ds-bot/files/test/details-bot-settings-readonly.test.js b/patches/ds-bot/files/test/details-bot-settings-readonly.test.js new file mode 100644 index 00000000..ae5d0a12 --- /dev/null +++ b/patches/ds-bot/files/test/details-bot-settings-readonly.test.js @@ -0,0 +1,169 @@ +// A read-only window still opens a Bot's Settings page from the details drawer. The drawer mounted +// `BotSettingsForm` with the roster that carries the host's own read-only sentence but the form +// never read it, so every write control was drawn and every press could only be refused. When the +// roster holds that sentence the drawer now draws the sentence in place of the form, and a +// writable window keeps today's form unchanged. +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { fileURLToPath } from 'node:url' +import { runInNewContext } from 'node:vm' +import { build } from 'esbuild' + +const { outputFiles } = await build({ + stdin: { + contents: "export { DetailsDrawer } from './src/client/details-drawer.js'", + resolveDir: fileURLToPath(new URL('..', import.meta.url)), + }, + bundle: true, format: 'cjs', platform: 'browser', write: false, logLevel: 'silent', + external: ['react', 'react-dom', '@deepseek-ai/dsh-client-ui-primitives'], +}) + +const h = (type, props, ...children) => ({ type, props: { ...props, children: children.flat(Infinity) } }) +// A hook runtime that survives a re-render, the same one the Settings dialog is drawn with: the +// read-only sentence has to come out of the drawer's own tree, not from a re-implementation of it. +let slots = [] +let index = 0 +let effects = [] +const react = { + createElement: h, Component: class {}, Fragment: Symbol('F'), + useMemo: (fn) => { const at = index++; if (!(at in slots)) slots[at] = fn(); return slots[at] }, + useState: (initial) => { + const at = index++ + if (!(at in slots)) slots[at] = typeof initial === 'function' ? initial() : initial + return [slots[at], (value) => { slots[at] = typeof value === 'function' ? value(slots[at]) : value }] + }, + useCallback: (fn) => { const at = index++; if (!(at in slots)) slots[at] = fn; return slots[at] }, + useRef: (value) => { const at = index++; if (!(at in slots)) slots[at] = { current: value }; return slots[at] }, + useEffect: (fn) => { const at = index++; if (!(at in slots)) { slots[at] = true; effects.push(fn) } }, + useLayoutEffect() {}, + useSyncExternalStore: (_subscribe, get) => get(), +} +const win = { location: { hash: '' }, addEventListener() {}, removeEventListener() {}, matchMedia: () => ({ matches: false, addEventListener() {}, removeEventListener() {} }) } +const module = { exports: {} } +runInNewContext(outputFiles[0].text, { + module, exports: module.exports, console, setTimeout, clearTimeout, requestAnimationFrame: () => 0, cancelAnimationFrame() {}, + document: { hidden: false, addEventListener() {}, removeEventListener() {} }, + navigator: { language: 'en' }, + localStorage: { getItem: () => null, setItem() {} }, + window: win, + require: (name) => { + if (name === 'react') return react + if (name === 'react-dom') return { createPortal: node => node } + if (name === '@deepseek-ai/dsh-client-ui-primitives') return {} + throw new Error(`Unexpected platform module ${name}`) + }, +}) +const { DetailsDrawer } = module.exports + +const bot = { id: 'a', name: 'Chief', pinned: false, hidden: false, model: '', instructions: '', workDir: '' } +const base = { bots: [bot], rooms: [], byId: { a: bot }, roomsById: {}, prefs: {}, mainBotIds: ['a'], connectors: [], memory: false, exchangeTurns: {}, questions: {} } +const rosterWith = (readOnly) => (readOnly === undefined ? { ...base } : { ...base, readOnly }) +const SENTENCE = 'The Bot team in /home/xuke/.flybot is open in another dsh (Web or Desktop, pid 41 on host). Close the other one and reload.' +const actions = { + openSettings() {}, closeSettings() {}, closeOverlay() {}, openSession() {}, openBrowser() {}, + notify() {}, reportError() {}, setFlags: () => Promise.resolve(), +} +// The drawer reads its stores through the hooks the shell hands it; these stand in for one render. +const render = (roster, tab = 'settings', acts = actions) => { + index = 0 + effects = [] + const map = () => new Map() + return DetailsDrawer({ + sessionId: 'a', startTab: tab, hasStrip: false, roster, actions: acts, + useSessions: selector => selector({ byId: {}, projectionsBySession: {} }), useSessionStatus: selector => selector(map()), + useActivity: selector => selector(map()), useWork: selector => selector(map()), + useBrowserTabs: selector => selector(map()), useBrowserPages: selector => selector(map()), + }) +} +// The drawer returns elements; a function component is drawn by React, so the tree is expanded +// here the same way: every function component runs with the hook slots above, in tree order. +const expand = (node) => { + if (Array.isArray(node)) return node.map(expand) + if (!node || typeof node !== 'object') return node + const props = node.props ?? {} + if (typeof node.type === 'function') return expand(node.type(props)) + return { ...node, props: { ...props, children: expand(props.children) } } +} +const all = (node, out = []) => { + if (Array.isArray(node)) { for (const item of node) all(item, out); return out } + if (node && typeof node === 'object') { out.push(node); all(node.props?.children, out) } + return out +} +const tree = (roster) => all(expand(render(roster))) +const ownText = (node) => { + const walk = (value) => { + if (Array.isArray(value)) return value.map(walk).join('') + if (typeof value === 'string' || typeof value === 'number') return String(value) + if (value && typeof value === 'object') return walk(value.props?.children) + return '' + } + return walk(node?.props?.children) +} +const bodyOf = (nodes) => nodes.find(node => node.props?.className === 'bt-drawer-body bt-set-body') +// A write entry is anything in the Bot settings body a person can change something with: a text +// field or a button. The drawer's own Back/Close buttons live in the head, outside this body. +const writes = (nodes) => nodes.filter(node => ['input', 'textarea', 'select', 'button'].includes(node.type)) + +test('a read-only window draws no Bot-settings write entry and says why in the host\'s words', () => { + const nodes = tree(rosterWith(SENTENCE)) + const body = bodyOf(nodes) + assert.ok(body, 'the drawer draws its settings body') + const inside = all(body) + assert.equal(writes(inside).length, 0, 'no write entry is drawn in a read-only window') + const note = inside.filter(node => node.props?.role === 'status' && ownText(node).trim() === SENTENCE) + assert.equal(note.length, 1, "the host's sentence is drawn once") + assert.equal(note[0].props?.role, 'status', 'the sentence is a status line') + assert.ok(String(note[0].props?.className ?? '').includes('bt-read-only'), 'the sentence is the read-only hint') +}) + +test('a writable window still draws the Bot settings form', () => { + for (const roster of [rosterWith(undefined), rosterWith(''), rosterWith(' ')]) { + const nodes = tree(roster) + const body = bodyOf(nodes) + assert.ok(body, 'the drawer draws its settings body') + const inside = all(body) + assert.ok(writes(inside).length > 0, 'a writable window keeps its write entries') + assert.equal(inside.some(node => typeof node === 'object' && ownText(node).trim() === SENTENCE), false, 'no read-only sentence is drawn') + } + // The writable form is the same component, mounted with the same roster: the counts are not zero + // on both sides, which is what makes the read-only zero above mean something. + const inside = all(bodyOf(tree(rosterWith(undefined)))) + assert.ok(inside.some(node => node.type === 'input'), 'the writable form draws a text field') +}) + +// P-457/#2232 has a second entry point: the details overview mounts `ScheduleSummary`, and that +// line decides on `roster.readOnly` whether its "No scheduled tasks yet. Add one" is drawn — the +// entry opens the create form a locked settings page cannot draw. `schedules-client.test.js` calls +// `ScheduleSummary` directly and hands it a roster, so a drawer that forgets to pass one keeps that +// suite green; this goes in through the overview's own tree instead. +const readView = { available: true, tasks: [] } +const overviewActions = { ...actions, schedules: () => Promise.resolve(readView) } +// The overview reads its tasks in an effect (one read, then a poll), so it is drawn twice: once to +// queue that read, then again once the view has been published into the hook slots. +const overview = async (roster) => { + // Fresh slots: the two tests above drew the settings page, and these hook slots are positional. + slots = [] + expand(render(roster, 'details', overviewActions)) + const stops = [] + for (const effect of effects) { + try { + const stop = effect() + if (typeof stop === 'function') stops.push(stop) + } catch { /* other cards' own first reads are not this test's subject */ } + } + await new Promise(resolve => setTimeout(resolve, 0)) + const drawn = all(expand(render(roster, 'details', overviewActions))) + // The line polls every 30 s; leaving that timer behind holds the whole file open. + for (const stop of stops) stop() + return drawn +} +const addOneEntries = (nodes) => nodes.filter(node => node.type === 'button' && ownText(node).includes('Add one')) + +test('a read-only details overview draws no Add one for scheduled tasks', async () => { + const locked = await overview(rosterWith(SENTENCE)) + assert.deepEqual(addOneEntries(locked).map(ownText), [], 'no create entry is drawn in a read-only window') + // The same overview in a writable window still offers it, so the empty list above is the lock and + // not a task view that never reached the line. + const writable = await overview(rosterWith(undefined)) + assert.deepEqual(addOneEntries(writable).map(ownText), ['No scheduled tasks yet. Add one'], 'a writable window keeps the Add one entry') +}) diff --git a/patches/ds-bot/files/test/memory-panel-readonly.test.js b/patches/ds-bot/files/test/memory-panel-readonly.test.js new file mode 100644 index 00000000..be03b7f0 --- /dev/null +++ b/patches/ds-bot/files/test/memory-panel-readonly.test.js @@ -0,0 +1,174 @@ +// A window that holds the team read-only can open a Bot's memory dialog, and the panel draws +// two writes — a Remove on every entry and the "Ask or update" form — that the host refuses in +// that window, while nothing inside the panel says why (P-475). The message is the host's own +// sentence, already drawn by the sidebar and the Settings header; this panel is the surface +// that was missing it. A writable window must keep both writes exactly as they are. +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import { fileURLToPath } from 'node:url' +import { runInNewContext } from 'node:vm' +import { build } from 'esbuild' + +const { outputFiles } = await build({ + stdin: { + contents: "export { MemoryDialog } from './src/client/memory-panel.js'\nexport { hostText } from './src/client/i18n.js'", + resolveDir: fileURLToPath(new URL('..', import.meta.url)), + }, + bundle: true, format: 'cjs', platform: 'browser', write: false, logLevel: 'silent', + external: ['react', 'react-dom', '@deepseek-ai/dsh-client-ui-primitives'], +}) +const h = (type, props, ...children) => ({ type, props: { ...props, children: children.flat(Infinity) } }) +// A hook runtime that survives a re-render: the memory view arrives from an async effect, so the +// dialog is drawn once as its skeleton and again with the view, and the test clicks the ⋯ menu +// between those draws. Intervals are stubbed out (the clock never matters here) so the suite does +// not wait on the panel's 30-second "updated ago" beat. +let slots = [] +let index = 0 +let effects = [] +const react = { + createElement: h, Component: class {}, Fragment: Symbol('F'), + useMemo: (fn) => { const at = index++; if (!(at in slots)) slots[at] = fn(); return slots[at] }, + useState: (initial) => { + const at = index++ + if (!(at in slots)) slots[at] = typeof initial === 'function' ? initial() : initial + return [slots[at], (value) => { slots[at] = typeof value === 'function' ? value(slots[at]) : value }] + }, + useCallback: (fn) => { const at = index++; if (!(at in slots)) slots[at] = fn; return slots[at] }, + useRef: (value) => { const at = index++; if (!(at in slots)) slots[at] = { current: value }; return slots[at] }, + useEffect: (fn) => { const at = index++; if (!(at in slots)) { slots[at] = true; effects.push(fn) } }, + useLayoutEffect() {}, + useSyncExternalStore: (_subscribe, get) => get(), +} +const win = { location: { hash: '' }, addEventListener() {}, removeEventListener() {}, matchMedia: () => ({ matches: false, addEventListener() {}, removeEventListener() {} }) } +const module = { exports: {} } +runInNewContext(outputFiles[0].text, { + module, exports: module.exports, console, setTimeout, clearTimeout, setInterval: () => 0, clearInterval() {}, + requestAnimationFrame: () => 0, cancelAnimationFrame() {}, + document: { hidden: false, addEventListener() {}, removeEventListener() {} }, + navigator: { language: 'en' }, + localStorage: { getItem: () => null, setItem() {} }, + window: win, + require: (name) => { + if (name === 'react') return react + if (name === 'react-dom') return { createPortal: node => node } + if (name === '@deepseek-ai/dsh-client-ui-primitives') return {} + throw new Error(`Unexpected platform module ${name}`) + }, +}) +const { MemoryDialog, hostText } = module.exports + +// The dialog's own tree holds component elements (an entry row is a function, not a node), so the +// draw pass renders every function component the way the renderer would, with one hook index +// continuing through the tree. +const draw = (node) => { + if (Array.isArray(node)) return node.map(draw) + if (!node || typeof node !== 'object') return node + if (typeof node.type === 'function') return draw(node.type(node.props)) + if (node.props?.children === undefined) return node + return { ...node, props: { ...node.props, children: draw(node.props.children) } } +} +const render = (props) => { index = 0; return draw(MemoryDialog(props)) } +const reset = () => { slots = []; effects = []; index = 0 } +const flush = async () => { + const queued = effects + effects = [] + for (const fn of queued) await fn() + // Two microtask turns: the loader's promise resolves, then its continuation draws the view. + await Promise.resolve() + await Promise.resolve() +} +const all = (node, out = []) => { + if (Array.isArray(node)) { for (const item of node) all(item, out); return out } + if (node && typeof node === 'object') { out.push(node); all(node.props?.children, out) } + return out +} +const textOf = (node) => { + const walk = (value) => { + if (Array.isArray(value)) return value.map(walk).join('') + if (typeof value === 'string' || typeof value === 'number') return String(value) + if (value && typeof value === 'object') return walk(value.props?.children) + return '' + } + return walk(node?.props?.children) +} +const byClass = (node, name) => all(node).filter(item => typeof item.props?.className === 'string' && item.props.className.split(' ').includes(name)) + +const view = () => ({ + entries: [ + { text: 'A saved fact', scope: 'own', source: 'memory panel', added: 'today' }, + { text: 'A second fact', scope: 'own', source: 'memory panel', added: 'today' }, + ], + sizes: { own: 14, team: 0 }, + limits: { main: 4000 }, + blocked: [], + summary: { at: Date.now() - 60_000, text: 'One saved fact' }, + fresh: true, + summarizing: false, +}) +const actions = () => ({ + memoryView: async () => view(), + memoryForget: async () => { throw new Error('the host should have refused this write') }, + memoryAsk: async () => { throw new Error('the host should have refused this write') }, + closeMemory() {}, +}) + +// The dialog opens on the summary; the Remove buttons live on the entries page, so every case +// walks the same path a reader does: wait for the view, open ⋯, choose All entries. Removing the +// view wait is what would fake the locked result — the skeleton draws no write either. +async function drawEntries(roster) { + reset() + const props = { bot: { id: 'bot1', name: 'Scout' }, actions: actions(), roster } + let tree = render(props) + await flush() + tree = render(props) + // The view resolved: the summary text is drawn and the loading skeleton is gone. Without this, + // the skeleton's 0 writes would make the locked case pass for the wrong reason. + assert.match(textOf(tree), /One saved fact/, 'the view resolved') + assert.equal(byClass(tree, 'bt-mem-skel-block').length, 0) + const menu = all(tree).find(node => node.props?.['aria-label'] === 'Memory options') + assert.ok(menu, 'the ⋯ menu is there') + menu.props.onClick() + tree = render(props) + const entriesItem = all(tree).find(node => node.props?.role === 'menuitem' && textOf(node).includes('All entries')) + assert.ok(entriesItem, 'the menu offers All entries') + const regenerateItem = all(tree).find(node => node.props?.role === 'menuitem' && textOf(node).includes('Regenerate summary')) + entriesItem.props.onClick() + tree = render(props) + await flush() + return { tree, regenerateItem } +} + +test('P-475: a writable memory panel keeps both writes and draws no read-only line', async () => { + const { tree } = await drawEntries({}) + // Two entries in the fixture, so two Remove buttons: the count is real, not "one at least". + assert.equal(byClass(tree, 'bt-mem-remove').length, 2) + assert.equal(byClass(tree, 'bt-mem-ask').length, 1) + assert.equal(byClass(tree, 'bt-read-only').length, 0) + assert.match(textOf(tree), /A saved fact/) + assert.match(textOf(tree), /A second fact/) +}) + +test('P-475: a read-only memory panel draws no write and says in place why', async () => { + const sentence = 'This window is read-only.' + const { tree, regenerateItem } = await drawEntries({ readOnly: sentence }) + // One read of the whole shape: two writes and no sentence (the defect), or no write and one + // sentence (the fix). A skeleton would read 0/0/0 and prove nothing, which is why the entry + // path above waits for the view instead of mounting. + assert.deepEqual([byClass(tree, 'bt-mem-remove').length, byClass(tree, 'bt-mem-ask').length, byClass(tree, 'bt-read-only').length], [0, 0, 1]) + const hints = byClass(tree, 'bt-read-only') + assert.equal(hints.length, 1) + assert.equal(hints[0].props.role, 'status') + assert.equal(textOf(hints[0]), hostText(sentence)) + // The panel is still readable, and the summary actions that read rather than write stay. + assert.match(textOf(tree), /A saved fact/) + assert.match(textOf(tree), /A second fact/) + // Regenerate reads the view and writes no entry, so the lock must not take it away. + assert.ok(regenerateItem, 'Regenerate summary is still offered') + assert.equal(regenerateItem.props.disabled, false) +}) + +test('P-475: the dialog is handed the roster, or the panel cannot know the window', () => { + const overlays = readFileSync(new URL('../src/client/overlays.js', import.meta.url), 'utf8') + assert.match(overlays, /h\(MemoryDialog, \{ key: memory, bot: roster\.byId\[memory\], roster, actions, leaving: memoryLeaving \}\)/) +}) diff --git a/patches/ds-bot/files/test/questions-readonly.test.js b/patches/ds-bot/files/test/questions-readonly.test.js new file mode 100644 index 00000000..51a365cf --- /dev/null +++ b/patches/ds-bot/files/test/questions-readonly.test.js @@ -0,0 +1,182 @@ +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { createHash } from 'node:crypto' +import { fileURLToPath } from 'node:url' +import { runInNewContext } from 'node:vm' +import { build } from 'esbuild' + +// A read-only window draws the `ask_user` card in the dock and inline in the transcript. The card +// was live there: an option reached `answer-question`, whose refusal PendingQuestion swallowed +// (`.catch(() => setAnswered(null))`), so the card came back with nothing said; the × reached +// `dismiss-question` and hid the card until the next poll, which answered it back. Both are writes +// the host refuses (core/src/host/channel.js READS), so a read-only card offers neither. + +const { outputFiles } = await build({ + stdin: { + contents: [ + "export { PendingQuestion, QuestionDock } from './src/client/questions.js'", + "export { ToolCell } from './src/client/cells.js'", + ].join('\n'), + resolveDir: fileURLToPath(new URL('..', import.meta.url)), + }, + bundle: true, format: 'cjs', platform: 'browser', write: false, logLevel: 'silent', + external: ['react', 'react-dom', '@deepseek-ai/dsh-client-ui-primitives'], +}) + +const h = (type, props, ...children) => ({ type, props: { ...props, children } }) +const written = [] +const react = { + createElement: h, Fragment: Symbol('F'), useEffect() {}, useLayoutEffect() {}, + useState: initial => [typeof initial === 'function' ? initial() : initial, value => written.push(value)], + useMemo: make => make(), useCallback: fn => fn, useRef: current => ({ current }), + useId: () => 'card-id', useSyncExternalStore: (_subscribe, get) => get(), +} +const module = { exports: {} } +runInNewContext(outputFiles[0].text, { + module, exports: module.exports, console, + document: { hidden: false, addEventListener() {}, removeEventListener() {}, querySelector: () => null }, + navigator: { language: 'en' }, localStorage: { getItem: () => null, setItem() {} }, + requestAnimationFrame: run => run(), + require: (name) => { + if (name === 'react') return react + if (name === 'react-dom') return { createPortal: node => node } + if (name === '@deepseek-ai/dsh-client-ui-primitives') return { MarkdownText: ({ text }) => text } + throw new Error(`Unexpected platform module ${name}`) + }, +}) +const { PendingQuestion, QuestionDock, ToolCell } = module.exports + +const LOCK = 'Another FlyBot window has this home open; this window is read-only.' +const WRITABLE = { bots: [], byId: {}, rooms: [], roomsById: {}, readOnly: '', questions: {}, exchangeTurns: {} } +const LOCKED = { ...WRITABLE, readOnly: LOCK } +const single = { id: 'q1', callId: 'call-1', header: 'Draft', question: 'Which draft?', options: [{ label: 'Short' }, { label: 'Long' }], allowCustom: true } +const multi = { ...single, id: 'q2', multiSelect: true } + +const expand = (node) => { + if (node === null || node === undefined || typeof node === 'boolean') return null + if (Array.isArray(node)) return node.map(expand) + if (typeof node !== 'object') return node + if (typeof node.type === 'function') return expand(node.type(node.props)) + return { ...node, props: { ...node.props, children: expand(node.props.children) } } +} +const walk = (node, out = []) => { + if (Array.isArray(node)) { for (const child of node) walk(child, out); return out } + if (!node || typeof node !== 'object') return out + out.push(node) + walk(node.props.children, out) + return out +} +const text = (node) => { + if (node === null || node === undefined) return '' + if (Array.isArray(node)) return node.map(text).join('') + return typeof node === 'object' ? text(node.props.children) : String(node) +} +const controlName = (node) => { + if (node.props['aria-label']) return node.props['aria-label'] + if (node.type === 'form') return 'form:submit' + const named = walk(node.props.children).find(child => child.props.className === 'bt-q-label') + return named ? text(named.props.children) : text(node.props.children) +} + +// What the window can be pressed on: every button and field the card draws, and every handler. +const buttons = tree => walk(tree).filter(node => node.type === 'button') +const fields = tree => walk(tree).filter(node => node.type === 'textarea' || node.type === 'input') +const handlers = tree => walk(tree).filter(node => typeof node.props.onClick === 'function' || typeof node.props.onSubmit === 'function') + +const actionsFor = (rejectAnswer) => { + const log = [] + return { + log, + answerQuestion: (...args) => { log.push(['answer-question', ...args]); return rejectAnswer ? Promise.reject(new Error('bot/read-only')) : Promise.resolve({}) }, + dismissQuestion: (...args) => { log.push(['dismiss-question', ...args]); return Promise.resolve({}) }, + } +} +const pressAll = (tree) => { + for (const node of handlers(tree)) { + if (typeof node.props.onClick === 'function') node.props.onClick({ preventDefault() {}, stopPropagation() {} }) + else node.props.onSubmit({ preventDefault() {} }) + } +} +const settle = () => new Promise(resolve => setTimeout(resolve, 0)) + +const dock = (roster, question, actions) => expand(QuestionDock({ + sessionId: 'chief', actions, + useRoster: select => select({ ...roster, questions: { chief: question } }), + useCards: select => select({ chief: {} }), + useSession: select => select({ openState: 'open' }), +})) +const inline = (roster, question, actions) => expand(PendingQuestion({ sessionId: 'chief', question, roster, actions, inline: true })) + +// The same tree the writable window draws, hashed: the fix must leave it byte-for-byte as it was. +const canonical = node => JSON.stringify(node, (_key, value) => (typeof value === 'function' ? `fn:${value.name || 'anon'}` : value)) +const sha256 = node => createHash('sha256').update(canonical(node)).digest('hex') + +test('a read-only window draws no answer, no skip and no dismissal on the question card', async () => { + const actions = actionsFor(true) + const tree = dock(LOCKED, multi, actions) + const drawn = { buttons: buttons(tree).map(controlName), fields: fields(tree).length, handlers: handlers(tree).length } + assert.deepEqual(drawn, { buttons: [], fields: 0, handlers: 0 }, `the read-only card still draws a write: ${JSON.stringify(drawn)}`) + const shown = text(tree) + assert.ok(!shown.includes('Submit') && !shown.includes('Skip'), `the read-only card still says: ${shown}`) +}) + +test('a read-only window neither sends an answer nor hides the card it cannot answer', async () => { + const actions = actionsFor(true) + const tree = dock(LOCKED, single, actions) + written.length = 0 + pressAll(tree) + await settle() + const seen = { pressed: buttons(tree).map(controlName), writes: actions.log, states: [...written] } + // Nothing was sent, so nothing was swallowed and nothing hid: the card a read-only window draws + // is not a button that silently fails, it is the question with the host's sentence above it. + assert.deepEqual(JSON.parse(JSON.stringify({ writes: seen.writes, states: seen.states })), { writes: [], states: [] }, `read-only card pressed: ${JSON.stringify(seen)}`) + assert.match(text(tree), /Another FlyBot window has this home open; this window is read-only/) +}) + +test('a read-only window says why, in the host sentence, above the card it cannot answer', async () => { + const actions = actionsFor(true) + for (const tree of [dock(LOCKED, single, actions), inline(LOCKED, single, actions)]) { + const hints = walk(tree).filter(node => node.props.className === 'bt-hint bt-read-only' && node.props.role === 'status') + assert.equal(hints.length, 1, `the host sentence is drawn ${hints.length} times: ${text(tree)}`) + assert.equal(text(hints[0]), LOCK) + assert.match(text(tree), /Which draft\?.*Short.*Long/s, 'the question and its rows stay readable') + } +}) + +test('the transcript card a Bot turn draws obeys the same window', async () => { + const actions = actionsFor(true) + const call = { kind: 'tool-result', name: 'ask_user', callId: 'call-1', call: { name: 'ask_user', argsRaw: '{}' }, content: [{ type: 'text', text: '' }], isError: false, subCalls: [] } + const tree = expand(ToolCell({ + node: { key: 'node', data: { root: call }, location: { turn: { turn: 1 } } }, sessionId: 'chief', + useRoster: select => select({ ...LOCKED, questions: { chief: single } }), + useUi: select => select({ reactions: {}, visits: {} }), actions, + })) + const drawn = { buttons: buttons(tree).map(controlName), fields: fields(tree).length, handlers: handlers(tree).length } + assert.deepEqual(drawn, { buttons: [], fields: 0, handlers: 0 }, `the inline read-only card still draws a write: ${JSON.stringify(drawn)}`) +}) + +test('a writable window keeps the answer, the own-answer field and the dismissal, all wired', async () => { + const actions = actionsFor(false) + const docked = dock(WRITABLE, single, actions) + const dockedText = text(docked) + assert.match(dockedText, /Which draft\?.*Short.*Long/s) + assert.equal(fields(docked).length, 1, `custom answer field missing: ${dockedText}`) + const optionRow = buttons(docked).find(node => node.props.className === 'bt-q-opt' && controlName(node) === 'Short') + optionRow.props.onClick({ preventDefault() {}, stopPropagation() {} }) + const dismiss = buttons(docked).find(node => node.props['aria-label'] === 'Dismiss question') + assert.ok(dismiss, `dismissal missing from the writable card: ${dockedText}`) + dismiss.props.onClick({ preventDefault() {}, stopPropagation() {} }) + assert.deepEqual(JSON.parse(JSON.stringify(actions.log)), [ + ['answer-question', 'chief', 'q1', { selected: ['Short'], custom: '' }], + ['dismiss-question', 'chief'], + ]) + assert.equal(walk(docked).filter(node => node.props.className === 'bt-hint bt-read-only').length, 0, 'a writable card draws no read-only hint') + console.log(`writable-question-tree-sha256 ${sha256(docked)}`) +}) + +test('a writable window keeps Skip and Submit on the multi-select card', async () => { + const actions = actionsFor(false) + const tree = dock(WRITABLE, multi, actions) + const drawn = buttons(tree).map(controlName) + assert.ok(drawn.includes('Skip') && drawn.includes('Submit'), `writable multi card: ${JSON.stringify(drawn)}`) +}) diff --git a/patches/ds-bot/topics.json b/patches/ds-bot/topics.json index 05b46c4c..ddaa271b 100644 --- a/patches/ds-bot/topics.json +++ b/patches/ds-bot/topics.json @@ -75,6 +75,7 @@ "src/client/index.js", "src/client/sidebar.js", "src/client/cells.js", + "src/client/questions.js", "src/client/exchange.js", "src/client/context-menu.js", "src/client/at-menu.js",