From 2e33d9fa8db539d74a576d0c34d7a35402c6aee6 Mon Sep 17 00:00:00 2001 From: Danyang Chen Date: Thu, 29 Oct 2026 23:41:22 +0800 Subject: [PATCH 1/2] Add the Project board view and gate who may act on a Project (#1710) The Project board is one view over the read model Stage 2.2 shipped: rows grouped by where the work lives (bot, group, workflow), carrying column, assignee and the run and node a workflow row came from, with the row id equal to the underlying task id so opening a row opens that task. An empty Project renders an empty state, not an error. The host now decides who may act on a Project. project-create stamps the caller as the owner (a single-member install with no session falls back to its first member, the same rule tasks.js uses), project-read answers canManage, and project-bind is refused with bot/forbidden for anyone else, writing nothing. A non-owner still reads the same board. project-board is listed in authz's TASK_ENDPOINTS so a signed-in deployment no longer refuses it as an unknown endpoint. --- changelog.d/1710.added.md | 1 + core/client.js | 771 ++++--- core/src/client/index.js | 3 + core/src/client/project-board.js | 117 + core/src/host/authz.js | 2 +- core/src/host/channel.js | 2 +- core/src/host/store.js | 32 +- core/test/project-board-view.test.js | 107 + core/test/projects.test.js | 69 +- docs/subsystems/core-client.md | 11 + docs/subsystems/core-host.md | 13 + .../ds-bot/0017-project-board-gating.patch | 1956 +++++++++++++++++ .../ds-bot/files/src/client/project-board.js | 117 + .../files/test/project-board-view.test.js | 107 + patches/ds-bot/files/test/projects.test.js | 69 +- 15 files changed, 3036 insertions(+), 341 deletions(-) create mode 100644 changelog.d/1710.added.md create mode 100644 core/src/client/project-board.js create mode 100644 core/test/project-board-view.test.js create mode 100644 patches/ds-bot/0017-project-board-gating.patch create mode 100644 patches/ds-bot/files/src/client/project-board.js create mode 100644 patches/ds-bot/files/test/project-board-view.test.js diff --git a/changelog.d/1710.added.md b/changelog.d/1710.added.md new file mode 100644 index 000000000..1cb105c23 --- /dev/null +++ b/changelog.d/1710.added.md @@ -0,0 +1 @@ +A Project now has a board of its own: one read-only view over the work under it, grouped by where it lives (Bots, groups, workflow runs), showing each row's column, assignee and the run and node it came from, with every row opening the task it comes from. A Project with nothing under it says so instead of failing. Only the Project's owner sees the management controls, and the host refuses a binding or a Project change from anyone else rather than relying on the page to hide a button. Open the board at `#project=` ([#1710](https://github.com/ThinkFlowLab/FlyBot/issues/1710)). diff --git a/core/client.js b/core/client.js index 63625a7ca..990e7720f 100644 --- a/core/client.js +++ b/core/client.js @@ -1438,7 +1438,7 @@ var HOST_ZH = { "saved by a newer FlyBot; update FlyBot": "由更新的 FlyBot 保存;请更新 FlyBot" }; Object.assign(ZH, HOST_ZH); -var fill = (text, params) => params ? text.replace(/\{(\w+)\}/g, (match, name) => name in params ? String(params[name]) : match) : text; +var fill = (text2, params) => params ? text2.replace(/\{(\w+)\}/g, (match, name) => name in params ? String(params[name]) : match) : text2; var service = null; var bound = null; function installLocale(locale) { @@ -1489,23 +1489,23 @@ function noteClientError(where, error) { } const message = String(error?.message ?? error ?? "error").slice(0, 300); const code = typeof error?.code === "string" ? error.code : void 0; - const text = `${where}: ${message}`; + const text2 = `${where}: ${message}`; const now = Date.now(); const last = entries.at(-1); - if (last && last.text === text && now - last.time < REPEAT_MS) { + if (last && last.text === text2 && now - last.time < REPEAT_MS) { last.time = now; last.count = (last.count ?? 1) + 1; return; } - entries.push({ time: now, level: "error", text, ...code ? { code } : {} }); + entries.push({ time: now, level: "error", text: text2, ...code ? { code } : {} }); if (entries.length > MAX) entries.splice(0, entries.length - MAX); } function noteNavigation(how, target, from) { if (typeof navigator === "undefined" || navigator.userActivation?.isActive !== false) return; const stack = String(new Error().stack ?? "").split("\n").slice(2, 6).map((line2) => line2.trim().replace(/\(?https?:\/\/[^/\s)]+/, "(")).join(" < "); - const text = `navigation: ${how} to ${String(target).slice(0, 60)}${from ? ` from ${String(from).slice(0, 60)}` : ""} with no click (${stack.slice(0, 300)})`; - console.warn(`[flybot] ${text}`); - entries.push({ time: Date.now(), level: "warn", text }); + const text2 = `navigation: ${how} to ${String(target).slice(0, 60)}${from ? ` from ${String(from).slice(0, 60)}` : ""} with no click (${stack.slice(0, 300)})`; + console.warn(`[flybot] ${text2}`); + entries.push({ time: Date.now(), level: "warn", text: text2 }); if (entries.length > MAX) entries.splice(0, entries.length - MAX); } var quietly = (where) => (error) => noteClientError(where, error); @@ -1546,9 +1546,9 @@ var WHAT_MAX = 2e3; var PASTE_NOTE = "FlyBot copied the diagnostics to the clipboard. Paste them here."; var REST_NOTE = "The recent errors do not fit in the link. FlyBot copied the diagnostics to the clipboard: paste them below this line."; function issueUrl(base, { what = "", diagnostics }) { - const text = what.trim().slice(0, WHAT_MAX); - const title = text.split("\n")[0].slice(0, 80); - const build = (body) => `${base}?${new URLSearchParams({ template: "bug.yml", ...title ? { title } : {}, ...text ? { what: text } : {}, diagnostics: body })}`; + const text2 = what.trim().slice(0, WHAT_MAX); + const title = text2.split("\n")[0].slice(0, 80); + const build = (body) => `${base}?${new URLSearchParams({ template: "bug.yml", ...title ? { title } : {}, ...text2 ? { what: text2 } : {}, diagnostics: body })}`; const full2 = build(diagnostics); if (full2.length <= URL_LIMIT) return { url: full2, inline: true }; const facts = build(`${diagnostics.split("\n\n")[0]} @@ -1602,8 +1602,8 @@ var call = async (endpoint, payload, signal) => { // src/shared/remote-label.js function cleanPeerName(name, fallback = "") { - const text = String(name ?? "").replace(/[\u0000-\u001f\u007f-\u009f\u2028\u2029]/g, " ").replace(/[[\]"`<>]/g, "").replace(/\s+/g, " ").trim().slice(0, 60); - return text || fallback; + const text2 = String(name ?? "").replace(/[\u0000-\u001f\u007f-\u009f\u2028\u2029]/g, " ").replace(/[[\]"`<>]/g, "").replace(/\s+/g, " ").trim().slice(0, 60); + return text2 || fallback; } function normRemoteHandle(handle) { return `@${String(handle ?? "").trim().replace(/^@+/, "").toLowerCase()}`; @@ -1708,10 +1708,10 @@ function remoteCallName(record) { const handle = String(record?.handle ?? "").replace(/^@+/, "").trim(); const bare2 = remoteBotName(record, "").replace(/^@+/, "").trim(); const unusable = (value) => { - const text = String(value ?? "").replace(/^@+/, "").trim(); - if (!text || /^friend$/i.test(text)) return true; - if (text.includes("@") || /\.home\b/i.test(text)) return true; - if (handle && text.toLowerCase() === handle.toLowerCase()) return true; + const text2 = String(value ?? "").replace(/^@+/, "").trim(); + if (!text2 || /^friend$/i.test(text2)) return true; + if (text2.includes("@") || /\.home\b/i.test(text2)) return true; + if (handle && text2.toLowerCase() === handle.toLowerCase()) return true; return false; }; if (!unusable(bare2)) return bare2; @@ -1772,11 +1772,11 @@ function ungrantedLocalFix(details) { const friends = [...new Set((Array.isArray(details.remotes) ? details.remotes : []).map((remote) => cleanPeerName(remote?.owner || remote?.name, "")).filter(Boolean))]; return { bots, friends }; } -async function copiedToClipboard(text, nav = globalThis.navigator) { +async function copiedToClipboard(text2, nav = globalThis.navigator) { const write = nav?.clipboard?.writeText; if (typeof write !== "function") return false; try { - await write.call(nav.clipboard, text); + await write.call(nav.clipboard, text2); return true; } catch { return false; @@ -1924,8 +1924,8 @@ function remoteMembersByHome(roster) { } return [...homes.values()]; } -function remoteMessageText(text) { - const value = String(text ?? ""); +function remoteMessageText(text2) { + const value = String(text2 ?? ""); const match = value.match(/\n?([\s\S]*?)\n?<\/untrusted-message>/); return match ? match[1] : value; } @@ -2045,9 +2045,9 @@ var MAIN_LOOK = Object.freeze({ shape: "whale", color: "deepseek" }); var CHARACTER_COLORS = ["black", "brown", "red", "orange", "yellow", "green", "cyan", "blue", "violet", "magenta", "gray", "aurora"]; var DEFAULT_COLORS = CHARACTER_COLORS.filter((color) => !["black", "gray", "brown"].includes(color) && !GRADIENTS[color]); var isColor = (color) => isHex(color) || Object.hasOwn(INKS, color); -function fnv1a(text) { +function fnv1a(text2) { let value = 2166136261; - for (let index = 0; index < text.length; index += 1) value = Math.imul(value ^ text.charCodeAt(index), 16777619); + for (let index = 0; index < text2.length; index += 1) value = Math.imul(value ^ text2.charCodeAt(index), 16777619); return value >>> 0; } function defaultColor(id) { @@ -2168,8 +2168,8 @@ function bodyOf(def, paint, flat = false) { return ""; } } -var eyeMarkup = ([cx, cy, w, h48, tilt]) => ``; -var happyEyeMarkup = ([cx, cy, w, h48, tilt]) => ``; +var eyeMarkup = ([cx, cy, w, h49, tilt]) => ``; +var happyEyeMarkup = ([cx, cy, w, h49, tilt]) => ``; var maskBox = (size) => `maskUnits="userSpaceOnUse" x="${-size}" y="${-size}" width="${size * 3}" height="${size * 3}"`; var holes = (def, eyes) => `${eyes}`; var MASK_ID = "%MASK%"; @@ -2220,30 +2220,30 @@ var ALL_WORDS = ["all", "everyone", "所有人", "全体"]; var NAME_GOES_ON = /^(?:[a-z0-9_\-/\\]|\.[a-z0-9_-])/i; var ADDRESS_BEFORE = /[a-z0-9_./]/i; var LITERAL = /```[\s\S]*?(?:```|$)|`[^`\n]*`|\[[^\]\n]*\]\([^)\s]*(?:\s+(?:"[^"\n]*"|'[^'\n]*'))?\)/g; -var fitsAt = (text, at, word) => text.slice(at, at + word.length).toLowerCase() === word.toLowerCase() && !NAME_GOES_ON.test(text.slice(at + word.length)); +var fitsAt = (text2, at, word) => text2.slice(at, at + word.length).toLowerCase() === word.toLowerCase() && !NAME_GOES_ON.test(text2.slice(at + word.length)); var overlaps = (spans, start, end) => spans.some((span) => start < span.end && span.start < end); function nameProblem(name) { - const text = String(name ?? "").trim(); - if (text === "") return "A Bot needs a name"; - if (/[@`\r\n]/.test(text)) return "A Bot name cannot contain @, ` or a line break"; - if (ALL_WORDS.includes(text.toLowerCase())) return `"${text}" calls everyone in a group, so it cannot be a Bot name`; + const text2 = String(name ?? "").trim(); + if (text2 === "") return "A Bot needs a name"; + if (/[@`\r\n]/.test(text2)) return "A Bot name cannot contain @, ` or a line break"; + if (ALL_WORDS.includes(text2.toLowerCase())) return `"${text2}" calls everyone in a group, so it cannot be a Bot name`; return void 0; } -function referenceSpans(text, labels = []) { +function referenceSpans(text2, labels = []) { const spans = []; for (const label of [...labels].sort((a, b) => b.length - a.length)) { const mention = `@${label}`; - for (let at = text.indexOf(mention); at !== -1; at = text.indexOf(mention, at + 1)) { + for (let at = text2.indexOf(mention); at !== -1; at = text2.indexOf(mention, at + 1)) { const end = at + mention.length; - if (at > 0 && ADDRESS_BEFORE.test(text[at - 1]) || NAME_GOES_ON.test(text.slice(end)) || overlaps(spans, at, end)) continue; + if (at > 0 && ADDRESS_BEFORE.test(text2[at - 1]) || NAME_GOES_ON.test(text2.slice(end)) || overlaps(spans, at, end)) continue; spans.push({ start: at, end, label }); break; } } return spans.sort((a, b) => a.start - b.start); } -function mentionSpans(text, bots, { references = [] } = {}) { - const value = String(text ?? ""); +function mentionSpans(text2, bots, { references = [] } = {}) { + const value = String(text2 ?? ""); const skip = [ ...[...value.matchAll(LITERAL)].map((match) => ({ start: match.index, end: match.index + match[0].length })), ...referenceSpans(value, references) @@ -2261,8 +2261,8 @@ function mentionSpans(text, bots, { references = [] } = {}) { } return spans; } -function unmatchedMentions(text, bots, { references = [] } = {}) { - const value = String(text ?? ""); +function unmatchedMentions(text2, bots, { references = [] } = {}) { + const value = String(text2 ?? ""); const skip = [ ...[...value.matchAll(LITERAL)].map((match) => ({ start: match.index, end: match.index + match[0].length })), ...referenceSpans(value, references) @@ -2288,16 +2288,16 @@ var FENCE_TAG = /<\s*\/?\s*untrusted-(?:message|file)\b[^<>]*>/gi; var FENCE_ATTR = /\bfrom\s*=\s*(?:"[^"]*"|'[^']*')\s*>/gi; var FENCE_TAIL = /<[^<>]*\buntrusted-(?:message|file)\b[^<>]*$/i; var FENCE_BARE = /(?:\/\s*)?untrusted-(?:message|file)\b\s*>?/gi; -function stripFence(text) { - let out = String(text ?? ""); +function stripFence(text2) { + let out = String(text2 ?? ""); for (let previous; previous !== out; ) { previous = out; out = out.replace(FENCE_TAG, "").replace(FENCE_ATTR, ""); } return out.replace(FENCE_TAIL, "").replace(FENCE_BARE, ""); } -function visibleText(text) { - let out = String(text ?? ""); +function visibleText(text2) { + let out = String(text2 ?? ""); for (let previous; previous !== out; ) { previous = out; const open = out.indexOf(" Number(Boolean(a?.remote)) - Number(Boolean(b?.remote))); let out = ""; let cursor = 0; - for (const span of mentionSpans(text, ordered)) { + for (const span of mentionSpans(text2, ordered)) { if (!span.bot || span.bot.id === selfId) continue; - out += `${text.slice(cursor, span.start)}[${text.slice(span.start + 1, span.end).replace(/[[\]\\]/g, "\\$&")}](${MENTION_ORIGIN}${span.bot.id})`; + out += `${text2.slice(cursor, span.start)}[${text2.slice(span.start + 1, span.end).replace(/[[\]\\]/g, "\\$&")}](${MENTION_ORIGIN}${span.bot.id})`; cursor = span.end; } - return cursor === 0 ? text : out + text.slice(cursor); + return cursor === 0 ? text2 : out + text2.slice(cursor); } function mentionTarget(event) { const anchor = event.target instanceof Element ? event.target.closest(`a[href^="${MENTION_ORIGIN}"]`) : null; return anchor ? anchor.getAttribute("href").slice(MENTION_ORIGIN.length) : null; } -function nameMentions(text, roster) { - const value = String(text ?? ""); +function nameMentions(text2, roster) { + const value = String(text2 ?? ""); if (!value.includes("@") || roster === null || typeof roster !== "object") return value; const records = [...Array.isArray(roster.remoteBots) ? roster.remoteBots : [], ...Array.isArray(roster.bots) ? roster.bots : []]; const names = /* @__PURE__ */ new Map(); @@ -2370,9 +2370,9 @@ function landingPart(roster, last, mainId, { stay = false, view = "", sessions } const main = roster.byId[mainId]; return main ? currentPart(main) : mainId; } -function reminderPrompts(text) { - const one = /^reminder_prompt_json: (.*)$/m.exec(text); - const batch = /^reminders_json: (.*)$/m.exec(text); +function reminderPrompts(text2) { + const one = /^reminder_prompt_json: (.*)$/m.exec(text2); + const batch = /^reminders_json: (.*)$/m.exec(text2); try { const prompts = [...one ? [JSON.parse(one[1])] : [], ...batch ? JSON.parse(batch[1]).map((entry) => entry.reminder_prompt) : []]; return prompts.filter((prompt) => typeof prompt === "string" && prompt.trim() !== ""); @@ -2380,13 +2380,13 @@ function reminderPrompts(text) { return []; } } -var stripHeader = (text) => text.replace(HEADER, ""); +var stripHeader = (text2) => text2.replace(HEADER, ""); var TRACE = /^[ \t]*[^\n→]{1,60}(?:[ \t]*→[ \t]*[^\n→]{1,60})+[ \t]*\n+/; -function stripTrace(text) { - if (typeof text !== "string") return text; - const line2 = text.match(TRACE)?.[0]; - if (!line2 || /[.!?。!?::,,]/.test(line2.replace(/[^\S\n]*\n+$/, "").replace(/→/g, ""))) return text; - return text.slice(line2.length).replace(/^\s+/, ""); +function stripTrace(text2) { + if (typeof text2 !== "string") return text2; + const line2 = text2.match(TRACE)?.[0]; + if (!line2 || /[.!?。!?::,,]/.test(line2.replace(/[^\S\n]*\n+$/, "").replace(/→/g, ""))) return text2; + return text2.slice(line2.length).replace(/^\s+/, ""); } var COPY_FENCE = /untrusted-(?:message|file)\b/i; var escapeName = (name) => name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); @@ -2396,8 +2396,8 @@ var isTraceLine = (line2, known) => { const hop = `(?:${known.map(escapeName).join("|")})`; return new RegExp(`^[ \\t]*${hop}(?:[ \\t]*→[ \\t]*${hop})+[ \\t]*$`).test(line2); }; -function stripFlatTrace(text, names = []) { - const body = String(text ?? ""); +function stripFlatTrace(text2, names = []) { + const body = String(text2 ?? ""); const known = sortedNames(names); if (known.length === 0) return body; const hop = `(?:${known.map(escapeName).join("|")})`; @@ -2405,8 +2405,8 @@ function stripFlatTrace(text, names = []) { const found = trace.exec(body); return found ? body.slice(found[0].length) : body; } -function copyBodyOf(text, names = []) { - let body = String(text ?? ""); +function copyBodyOf(text2, names = []) { + let body = String(text2 ?? ""); for (let previous; previous !== body; ) { previous = body; body = stripHeader(body); @@ -2458,14 +2458,14 @@ function latestMemoryChange(news, ids) { const at = (id) => news?.[id]?.seen ? news[id].at ?? 0 : 0; return ids.reduce((best, id) => at(id) > at(best) ? id : best, ids[0] ?? null); } -function summaryBlocks(text) { +function summaryBlocks(text2) { const blocks = []; let paragraph = []; const flush = () => { if (paragraph.length > 0) blocks.push({ kind: "p", text: paragraph.join(" ") }); paragraph = []; }; - for (const raw of String(text ?? "").split("\n")) { + for (const raw of String(text2 ?? "").split("\n")) { const line2 = raw.trim().replace(/\*\*|__/g, ""); const heading = /^#{1,6}\s+(.+)$/.exec(line2); if (heading) { @@ -2580,17 +2580,17 @@ function previewOf(list, id, hiddenTurns = [], unsentTurns = [], names = []) { for (let index = outline.length - 1; index >= 0; index -= 1) { const entry = outline[index]; if (hiddenTurns.includes(entry.turn)) continue; - const text = (unsentTurns.includes(entry.turn) ? "" : entry.response) || entry.prompt || ""; - if (text === "") continue; - return readableLine(text, names); + const text2 = (unsentTurns.includes(entry.turn) ? "" : entry.response) || entry.prompt || ""; + if (text2 === "") continue; + return readableLine(text2, names); } return ""; } -function readableLine(text, names = []) { - return dropKnownTrace(collapsedLine(text), names); +function readableLine(text2, names = []) { + return dropKnownTrace(collapsedLine(text2), names); } -function collapsedLine(text) { - return visibleText(stripTrace(stripHeader(`${text}`)).replace(/\s+/g, " ").trim()); +function collapsedLine(text2) { + return visibleText(stripTrace(stripHeader(`${text2}`)).replace(/\s+/g, " ").trim()); } function dropKnownTrace(line2, names) { const known = [...new Set((Array.isArray(names) ? names : []).map((name) => String(name ?? "").trim()).filter((name) => name !== ""))].sort((a, b) => b.length - a.length); @@ -2604,8 +2604,8 @@ function dropKnownTrace(line2, names) { return /^[\s,,.。::]*$/.test(rest) ? "" : rest; } var botNames = (roster) => [...roster?.bots ?? [], ...roster?.remoteBots ?? [], ...roster?.agents ?? []].map((entry) => entry?.name).filter((name) => typeof name === "string" && name !== ""); -function speechOf(text, names = []) { - return readableLine(text, names).replace(/```[\s\S]*?```/g, " ").replace(/\[([^\]]*)\]\([^)]*\)/g, "$1").replace(/[*_`#>|~-]+/g, " ").replace(/\s+/g, " ").trim(); +function speechOf(text2, names = []) { + return readableLine(text2, names).replace(/```[\s\S]*?```/g, " ").replace(/\[([^\]]*)\]\([^)]*\)/g, "$1").replace(/[*_`#>|~-]+/g, " ").replace(/\s+/g, " ").trim(); } var formatTokens = (count) => count >= 1e6 ? `${(count / 1e6).toFixed(1)}M` : count >= 1e3 ? `${(count / 1e3).toFixed(1)}K` : String(count); function usageOf(list, ids) { @@ -2624,10 +2624,10 @@ function usageOf(list, ids) { return t("{tokens} tok · Cache hit {hit}%", { tokens: formatTokens(input + output), hit }); } function faceRowTitle(title) { - const text = String(title ?? ""); - const named = text.match(/^Face-to-face · (.+) · (\d{6})$/); + const text2 = String(title ?? ""); + const named = text2.match(/^Face-to-face · (.+) · (\d{6})$/); if (named) return `${named[2]} · ${named[1]}`; - return text.match(/^Face-to-face · (\d{6})$/)?.[1] ?? text; + return text2.match(/^Face-to-face · (\d{6})$/)?.[1] ?? text2; } function modelOf(bot, roster) { const ref = bot.model ?? bot.appliedModel; @@ -2941,8 +2941,8 @@ function RoomAvatar({ room: room2, roster, size = 36 }) { // src/client/room-invite-preview.js var INVITE_PREVIEW_NAME_MAX = 60; -function stripInviteMarkers(text) { - let out = String(text ?? ""); +function stripInviteMarkers(text2) { + let out = String(text2 ?? ""); for (let previous; previous !== out; ) { previous = out; out = out.replace(/<\/?\s*untrusted-(?:file|message)/gi, ""); @@ -2955,9 +2955,9 @@ function cleanInviteName(value, fallback = "") { function inviteMemberCount(value) { if (typeof value === "number") return Number.isFinite(value) ? value : NaN; if (typeof value !== "string") return NaN; - const text = value.trim(); - if (!text) return NaN; - const count = Number(text); + const text2 = value.trim(); + if (!text2) return NaN; + const count = Number(text2); return Number.isFinite(count) ? count : NaN; } function invitePreviewOf(seen) { @@ -2979,29 +2979,29 @@ function inviteCountdown(expiresAt, offsetMs = 0) { const total = Math.max(1, Math.ceil(left / 1e3)); return `${Math.floor(total / 60)}:${String(total % 60).padStart(2, "0")} left to join`; } -function RoomInvitePreview({ h: h48, preview, kind = "face-link-preview", busy = false, joinLabel = "Join", clearLabel = "Clear", showClear = true, onJoin, onClear, now } = {}) { - if (!preview || typeof h48 !== "function") return null; +function RoomInvitePreview({ h: h49, preview, kind = "face-link-preview", busy = false, joinLabel = "Join", clearLabel = "Clear", showClear = true, onJoin, onClear, now } = {}) { + if (!preview || typeof h49 !== "function") return null; const at = `${kind}`; const child = (name) => `${at}-${name}`; const countdown = now === void 0 ? inviteCountdown(preview.expiresAt, preview.clockOffsetMs) : inviteCountdownAt(preview.expiresAt, preview.clockOffsetMs, now); - return h48( + return h49( "div", { className: "fs-preview", "data-flybot": at, role: "status" }, - h48("div", { className: "fs-preview-name", "data-flybot": child("name") }, preview.name || "Face-to-face room"), - preview.lead ? h48("div", { className: "fs-preview-meta", "data-flybot": child("lead") }, `Lead: ${preview.lead}`) : null, - preview.people ? h48("div", { className: "fs-preview-meta", "data-flybot": child("members") }, preview.people) : null, - preview.expiresAt ? h48("div", { className: "fs-preview-meta", "data-flybot": child("expiry") }, countdown) : null, - onJoin || onClear ? h48( + h49("div", { className: "fs-preview-name", "data-flybot": child("name") }, preview.name || "Face-to-face room"), + preview.lead ? h49("div", { className: "fs-preview-meta", "data-flybot": child("lead") }, `Lead: ${preview.lead}`) : null, + preview.people ? h49("div", { className: "fs-preview-meta", "data-flybot": child("members") }, preview.people) : null, + preview.expiresAt ? h49("div", { className: "fs-preview-meta", "data-flybot": child("expiry") }, countdown) : null, + onJoin || onClear ? h49( "div", { className: "fs-preview-foot", "data-flybot": child("foot") }, - showClear && onClear ? h48("button", { + showClear && onClear ? h49("button", { type: "button", className: "fs-btn fs-btn-sm", "data-flybot": child("clear"), disabled: !!busy, onClick: onClear }, clearLabel) : null, - onJoin ? h48("button", { + onJoin ? h49("button", { type: "button", className: "fs-btn fs-btn-sm fs-btn-primary", "data-flybot": child("join"), @@ -3396,10 +3396,10 @@ var FLYBOT_CSS = ` `; // src/shared/failure-copy.js -var providerCode = (text) => { - const json = /"code"\s*:\s*"([A-Za-z0-9_.-]+)"/.exec(text); +var providerCode = (text2) => { + const json = /"code"\s*:\s*"([A-Za-z0-9_.-]+)"/.exec(text2); if (json) return json[1]; - const plain = /^([A-Za-z][A-Za-z0-9_.-]{2,40}):\s/.exec(text); + const plain = /^([A-Za-z][A-Za-z0-9_.-]{2,40}):\s/.exec(text2); return plain ? plain[1] : ""; }; var KNOWN = { @@ -3410,13 +3410,13 @@ var KNOWN = { InsufficientQuota: "The account is out of quota.", RateLimitExceeded: "Too many requests right now. Try again in a moment." }; -var fill2 = (text, params) => params === void 0 ? text : String(text).replace(/\{(\w+)\}/g, (match, name) => name in params ? String(params[name]) : match); +var fill2 = (text2, params) => params === void 0 ? text2 : String(text2).replace(/\{(\w+)\}/g, (match, name) => name in params ? String(params[name]) : match); function failureSentence(raw, translate = (key) => key) { - const text = String(raw ?? "").trim(); - if (text === "") return text; - const key = KNOWN[providerCode(text)]; - if (key === void 0) return text; - const feature = /does not support\s+([a-z][a-z0-9 _-]{2,40})/i.exec(text)?.[1]?.trim(); + const text2 = String(raw ?? "").trim(); + if (text2 === "") return text2; + const key = KNOWN[providerCode(text2)]; + if (key === void 0) return text2; + const feature = /does not support\s+([a-z][a-z0-9 _-]{2,40})/i.exec(text2)?.[1]?.trim(); if (key !== KNOWN.UnsupportedModel || feature === void 0) return fill2(translate(key), void 0); const withFeature = "This model does not support {feature}. Pick another model in the Bot's settings."; return fill2(translate(withFeature, { feature }), { feature }); @@ -3631,12 +3631,12 @@ function liveSprings(apply2) { const dt = Math.min(0.5, Math.max(1e-3, (now - last) / 1e3)); last = now; const steps = Math.ceil(dt * 240); - const h48 = dt / steps; + const h49 = dt / steps; let moving = false; for (const value of values.values()) { for (let index = 0; index < steps; index += 1) { - value.v += (-value.k * (value.x - value.to) - value.c * value.v) * h48; - value.x += value.v * h48; + value.v += (-value.k * (value.x - value.to) - value.c * value.v) * h49; + value.x += value.v * h49; } if (Math.abs(value.x - value.to) < 0.05 && Math.abs(value.v) < 1) { value.x = value.to; @@ -4914,10 +4914,10 @@ div:has(> nav [class*="_marks"]){display:none!important} .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) { +function installStyles(text2, tag) { const style = document.createElement("style"); style.dataset.dshBot = tag; - style.textContent = text; + style.textContent = text2; document.head.appendChild(style); return style; } @@ -4937,9 +4937,9 @@ function sheetClassNames(sheet) { } return names; } -function compileFragmentSelectors(text, names) { +function compileFragmentSelectors(text2, names) { const compiled = /* @__PURE__ */ new Map(); - return text.replace(FRAGMENT_SELECTOR, (whole, fragment) => { + return text2.replace(FRAGMENT_SELECTOR, (whole, fragment) => { if (!compiled.has(fragment)) { const exact = []; for (const name of names) if (name.includes(fragment)) exact.push(`.${name}`); @@ -4948,7 +4948,7 @@ function compileFragmentSelectors(text, names) { return compiled.get(fragment); }); } -function installCompiledStyles(text, tag) { +function installCompiledStyles(text2, tag) { const style = installStyles("", tag); const cache = /* @__PURE__ */ new WeakMap(); const render = () => { @@ -4962,7 +4962,7 @@ function installCompiledStyles(text, tag) { } for (const name of list) names.add(name); } - const next = compileFragmentSelectors(text, names); + const next = compileFragmentSelectors(text2, names); if (style.textContent !== next) style.textContent = next; }; render(); @@ -5059,11 +5059,11 @@ function flybotMarkSvg({ wing = "#8FB2FF", brand = "#3B5BFD", face = "#FFFFFF" } return ``; } function brandTitle(title) { - const text = String(title ?? ""); - if (text === "DeepSeek Harness" || text === "DS Bot") return "FlyBot"; - if (text.endsWith(" — DeepSeek Harness")) return `${text.slice(0, -" — DeepSeek Harness".length)} — FlyBot`; - if (/\bDS Bot\b/.test(text)) return text.replace(/\bDS Bot\b/g, "FlyBot"); - return text; + const text2 = String(title ?? ""); + if (text2 === "DeepSeek Harness" || text2 === "DS Bot") return "FlyBot"; + if (text2.endsWith(" — DeepSeek Harness")) return `${text2.slice(0, -" — DeepSeek Harness".length)} — FlyBot`; + if (/\bDS Bot\b/.test(text2)) return text2.replace(/\bDS Bot\b/g, "FlyBot"); + return text2; } function sessionTabLabel(roster, sessionId) { if (!roster || !sessionId) return ""; @@ -5542,14 +5542,14 @@ function ScopeSwitch({ value, onChange, bot }) { "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, text]) => (0, import_react7.createElement)("button", { + 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) - }, text)) + }, text2)) ); } function SecretHead({ bot, title, detail, name, onClose }) { @@ -5627,10 +5627,10 @@ function SecretCard({ bot, request, roster, actions }) { } 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 }); + 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": text, "data-status": request.status }, + { 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" }, @@ -5638,7 +5638,7 @@ function SettledSecret({ bot, request, roster }) { (0, import_react7.createElement)("div", { className: "bt-q-text" }, (0, import_react7.createElement)( "p", { className: "bt-q-title" }, - text, + 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)) @@ -5683,11 +5683,11 @@ function ScopeEditor({ secret, roster, onSave }) { (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, text]) => (0, import_react7.createElement)( + [["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) }), - text + text2 )) ), mode === "some" ? (0, import_react7.createElement)("div", { className: "bt-secret-bots" }, roster.bots.map((bot) => (0, import_react7.createElement)("button", { @@ -6059,7 +6059,7 @@ function Bubbles({ texts: texts2, roster, selfId, actions, streaming, sessionId, return (0, import_react9.createElement)( "div", { className: "bt-astack", "data-lead": lead ? "" : void 0, onClickCapture: onClick }, - texts2.map((text, index) => { + texts2.map((text2, index) => { const key = msgKey ? `${msgKey}:${index}` : void 0; const picked = key ? reactions?.[index] ?? [] : []; const last = index === texts2.length - 1; @@ -6074,9 +6074,9 @@ function Bubbles({ texts: texts2, roster, selfId, actions, streaming, sessionId, (0, import_react9.createElement)( "div", { className: "bt-bubble" }, - (0, import_react9.createElement)(import_dsh_client_ui_primitives.MarkdownText, { text: linkMentions(text, roster.bots, selfId), streaming: live, labels: mdLabels(), variant: "compact", fileMentions: mentions }) + (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, name: roster.byId[selfId]?.name ?? t("Bot"), sessionId, react: key ? (emoji) => actions.toggleReaction(msgKey, index, emoji) : void 0, actions }) + live ? null : (0, import_react9.createElement)(MessageTools, { text: text2, name: roster.byId[selfId]?.name ?? t("Bot"), sessionId, react: key ? (emoji) => actions.toggleReaction(msgKey, 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, @@ -6091,9 +6091,9 @@ function Bubbles({ texts: texts2, roster, selfId, actions, streaming, sessionId, } var WAITING_OTHERS_NOTICE = "Waiting for the owner's approval"; var REACTIONS = ["👍", "❤️", "😂", "😮", "😢", "🙏"]; -function leaveNoticeCoveredByClose(room2, text) { +function leaveNoticeCoveredByClose(room2, text2) { if (room2?.origin !== "face-to-face" || room2.face?.status !== "closed") return false; - const match = String(text ?? "").trim().match(/^(.+) left(?: the room)?$/); + const match = String(text2 ?? "").trim().match(/^(.+) left(?: the room)?$/); if (!match) return false; const who = match[1]; for (const participant of room2.participants ?? []) { @@ -6124,7 +6124,7 @@ function useDismiss(open, close, refs) { }; }, [open]); } -function MessageTools({ text, name, sessionId, react, actions }) { +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); @@ -6143,13 +6143,13 @@ function MessageTools({ text, name, sessionId, react, actions }) { setPanel(kind); }; const copy2 = () => { - void navigator.clipboard?.writeText(text).then(() => { + void navigator.clipboard?.writeText(text2).then(() => { setCopied(true); setTimeout(() => setCopied(false), 1e3); }); }; const download2 = () => { - const url = URL.createObjectURL(new Blob([text], { type: "text/markdown" })); + 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); @@ -6162,7 +6162,7 @@ function MessageTools({ text, name, sessionId, react, actions }) { "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, text) }, (0, import_react9.createElement)(ReplyIcon)) : 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", @@ -6195,14 +6195,14 @@ var clip = (line2) => { 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 = (text) => { - const lines = text.split("\n").map((part) => part.replace(/\s+/g, " ").trim()).filter((part) => part !== ""); +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 }) { - if (typeof text !== "string" || text.trim() === "") return null; - const peek = peekLine(text); +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" }, @@ -6213,7 +6213,7 @@ function ThinkingRow({ text }) { 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" }, text) + (0, import_react9.createElement)("div", { className: "bt-think-text" }, text2) ); } function useFileMentions({ node, openFile, fileMentions, useTurnData }) { @@ -6234,7 +6234,7 @@ function useClosingText(node, useTurnData) { } function friendLead(bot, texts2) { if (bot?.remote === void 0 || texts2.length === 0) return void 0; - if (texts2.every((text) => /^Not sent:/.test(text.trim()))) 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 }) { @@ -6247,7 +6247,7 @@ function AssistantCell({ node, groupPart, sessionId, openFile, fileMentions, use 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((text) => text.trim() !== ""); + 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; @@ -6323,11 +6323,11 @@ function EventLine({ prefix, bots, joiner = t("and"), onOpen, whole = false }) { }); return (0, import_react9.createElement)("div", { className: "bt-event" }, parts); } -var heldForm = (text) => String(text ?? "").replace(/\s+/g, " ").trim().replace(/^(to (the )?face[ -]?room|to the room)\s*:\s*/i, "").trim(); +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, text, bot) { +function isHeldLine(room2, text2, bot) { if (room2?.origin !== "face-to-face") return false; - const line2 = heldForm(text); + const line2 = heldForm(text2); if (!line2) return false; return (room2.pendingApprovals ?? []).some((item) => { if (item?.kind !== "face_room_message") return false; @@ -6337,9 +6337,9 @@ function isHeldLine(room2, text, bot) { }); } var HELD_SENDING_MS = 6e4; -function heldLineState(room2, { seq, botId, text } = {}, now = Date.now()) { +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(text ?? "").trim(); + 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"; @@ -6347,8 +6347,8 @@ function heldLineState(room2, { seq, botId, text } = {}, now = Date.now()) { 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, room: room2, roster, actions, msgKey, useUi, seq }) { - const held = heldLineState(room2, { seq, botId: bot.id, text }); +function GroupMessage({ bot, text: text2, room: room2, roster, actions, msgKey, useUi, seq }) { + const held = heldLineState(room2, { seq, botId: bot.id, text: text2 }); if (held === "rejected") { return (0, import_react9.createElement)( "div", @@ -6357,7 +6357,7 @@ function GroupMessage({ bot, text, room: room2, roster, actions, msgKey, useUi, (0, import_react9.createElement)("span", null, t("{name} · Not sent", { name: bot.name })) ); } - if (held === "held" || isHeldLine(room2, text, bot)) return null; + 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) }, @@ -6373,7 +6373,7 @@ function GroupMessage({ bot, text, room: room2, roster, actions, msgKey, useUi, "div", { className: "bt-group-msg" }, name, - (0, import_react9.createElement)(Bubbles, { texts: [text], roster, selfId: bot.id, actions, sessionId: room2?.id, msgKey, useUi, lead }) + (0, import_react9.createElement)(Bubbles, { texts: [text2], roster, selfId: bot.id, actions, sessionId: room2?.id, msgKey, useUi, lead }) ); } function remoteSpeaker(source, roster, sessionId) { @@ -6408,7 +6408,7 @@ function faceSeatLook(room2, handle) { return Object.keys(look).length ? look : void 0; } var cleanCarrier = (value) => typeof value === "string" && value.trim() && !/[@()]/.test(value) ? value.trim() : ""; -function RemoteMessage({ source, text, roster, actions, sessionId, msgKey, useUi }) { +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); @@ -6425,7 +6425,7 @@ function RemoteMessage({ source, text, roster, actions, sessionId, msgKey, useUi person, carrier ? (0, import_react9.createElement)("span", { className: "bt-remote-handle" }, t("via {name}", { name: carrier })) : null ), - (0, import_react9.createElement)(Bubbles, { texts: [stripTrace(text)], 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 })) }), + (0, import_react9.createElement)(Bubbles, { texts: [stripTrace(text2)], 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 ); } @@ -6441,7 +6441,7 @@ function RemoteMessage({ source, text, roster, actions, sessionId, msgKey, useUi 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_react9.createElement)(Bubbles, { texts: [stripTrace(text)], roster, selfId: bot.id, actions, sessionId, msgKey, useUi, lead }), + (0, import_react9.createElement)(Bubbles, { texts: [stripTrace(text2)], 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 ); } @@ -7513,13 +7513,13 @@ function Face({ entry, size }) { } var elapsedSince = (now, at) => typeof at === "number" && now - at >= 6e4 ? elapsedLabel(now - at) : ""; function WorkRow({ bot, tool, step, label, at, now, onOpen }) { - const text = `${label} · ${workText(tool, step)}`; + 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": [text, elapsed].filter(Boolean).join(" · "), onClick: onOpen }, + { 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, text)), + (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 ); } @@ -7770,11 +7770,11 @@ 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, text) => { - if (bot === void 0 || typeof text !== "string" || text === "") return void 0; +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 !== text) continue; + if (question.question !== text2) continue; if (key === bot.id || key === currentPart(bot)) return bot.id; } return void 0; @@ -7790,7 +7790,7 @@ function latestActivity(card, { roster, work, sessions }) { 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((text) => typeof text === "string" && text !== "" && text !== card.title && text !== card.reason) ?? ""; + return options.find((text2) => typeof text2 === "string" && text2 !== "" && text2 !== card.title && text2 !== card.reason) ?? ""; } function toolCalls(card, { roster, work }) { const botId = card.bot?.id; @@ -7945,9 +7945,9 @@ function inboxModel({ roster, work = {}, tasks = [], sessions, now = Date.now() const part = currentPart(bot); const at = sessions.byId[part]?.updatedAt; if (!fresh(at)) continue; - const text = previewOf(sessions, part, [], [], botNames(roster)); - if (text === "") continue; - done.push({ id: `chat:${bot.id}`, kind: "chat", status: "done", bot, at, title: text, place: "", chat: bot.id, ...people(roster, bot.id) }); + 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); @@ -7970,10 +7970,10 @@ function inboxAnswer(actions, card, choice) { return Promise.resolve(actions.answerQuestion(card.sessionId, card.question.id, { selected, custom })); } if (card?.kind === "task" && card.status === "input") { - const text = typeof choice === "string" ? choice.trim() : ""; + const text2 = typeof choice === "string" ? choice.trim() : ""; const questionId = card.task?.inputRequest?.id; - if (text === "" || typeof questionId !== "string") return void 0; - return Promise.resolve(actions.answerTaskInput(card.task.id, questionId, text)); + 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)); @@ -8382,8 +8382,8 @@ function progressOf(task) { // src/shared/board-card.js function taskCardKey(id) { - const text = String(id ?? "").trim(); - return text === "" ? null : `task:${text}`; + const text2 = String(id ?? "").trim(); + return text2 === "" ? null : `task:${text2}`; } function taskCard(task) { const key = taskCardKey(task?.id); @@ -8869,11 +8869,11 @@ function KanbanDialog({ roster, actions, leaving = false }) { } }; const create = async (title) => { - const text = String(title ?? "").trim(); - if (!text || scope?.kind !== "bot") return; + const text2 = String(title ?? "").trim(); + if (!text2 || scope?.kind !== "bot") return; setError(""); try { - replace(await actions.taskCreate({ botId: scope.botId, title: text, column: "not-started" })); + replace(await actions.taskCreate({ botId: scope.botId, title: text2, column: "not-started" })); setDraft(""); } catch (failure) { setError(failure?.message ?? String(failure)); @@ -8989,7 +8989,7 @@ function useMarkedUnread(id, active, marked, actions) { var PinMark = () => (0, import_react16.createElement)("span", { className: "bt-pin", "aria-hidden": true }, (0, import_react16.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) => { +var workKeys = (text2) => text2 === "" ? [] : text2.split(",").map((key) => { const [id, tool] = key.split("|"); return { id, tool: tool || void 0 }; }); @@ -9922,7 +9922,7 @@ function BusyAckBubble({ sessionId, useRoster, useBusyAcks, useSessionStatus, us const host = useRunningHost2(visible); const shown = (0, import_react17.useRef)(null); if (visible) shown.current = room2 ? { text: memberAck?.text ?? "", at: memberAck?.at ?? Date.now() } : { text: busyAckText(hostAck), at: hostAck?.at ?? Date.now() }; - const text = shown.current?.text ?? ""; + const text2 = shown.current?.text ?? ""; if (!visible || !host || !shown.current || !bot) return null; return (0, import_react_dom4.createPortal)((0, import_react17.createElement)( "div", @@ -9931,8 +9931,8 @@ function BusyAckBubble({ sessionId, useRoster, useBusyAcks, useSessionStatus, us "data-flybot": "busy-ack", role: "status", "aria-live": "polite", - "aria-busy": text ? void 0 : "true", - "data-pending": text ? void 0 : "" + "aria-busy": text2 ? void 0 : "true", + "data-pending": text2 ? void 0 : "" }, (0, import_react17.createElement)(BotMark, { bot, size: 28, state: "thinking", live: true }), (0, import_react17.createElement)( @@ -9944,7 +9944,7 @@ function BusyAckBubble({ sessionId, useRoster, useBusyAcks, useSessionStatus, us (0, import_react17.createElement)( "div", { className: "bt-msg-line" }, - text ? (0, import_react17.createElement)("div", { className: "bt-bubble" }, text) : (0, import_react17.createElement)( + text2 ? (0, import_react17.createElement)("div", { className: "bt-bubble" }, text2) : (0, import_react17.createElement)( "div", { className: "bt-bubble bt-busy-dots", "aria-hidden": "true" }, (0, import_react17.createElement)("span", null), @@ -10328,10 +10328,10 @@ function useDictation(onText) { let interim = ""; for (let index = event.resultIndex; index < event.results.length; index += 1) { const result = event.results[index]; - const text = result[0]?.transcript ?? ""; + const text2 = result[0]?.transcript ?? ""; if (result.isFinal) { - if (text.trim()) latest.current(text, true); - } else interim += text; + if (text2.trim()) latest.current(text2, true); + } else interim += text2; } if (interim) latest.current(interim, false); }; @@ -10377,8 +10377,8 @@ function ComposerIdentity({ sessionId, useRoster }) { ); } function ComposerVoice({ sessionId, inputActions, actions, useUi }) { - const dictation = useDictation((text, final) => { - if (final && inputActions) inputActions.insertText(text, inputActions.captureInsertion()); + const dictation = useDictation((text2, final) => { + if (final && inputActions) inputActions.insertText(text2, inputActions.captureInsertion()); }); const quote = useUi((state) => state.quote?.sessionId === sessionId ? state.quote : null); (0, import_react19.useEffect)(() => { @@ -10746,8 +10746,8 @@ async function speechCatalog(speech) { function waveBytes(samples, rate = 16e3) { const bytes = new Uint8Array(44 + samples.length * 2); const view = new DataView(bytes.buffer); - const ascii = (at, text) => { - for (let index = 0; index < text.length; index += 1) bytes[at + index] = text.charCodeAt(index); + const ascii = (at, text2) => { + for (let index = 0; index < text2.length; index += 1) bytes[at + index] = text2.charCodeAt(index); }; ascii(0, "RIFF"); view.setUint32(4, 36 + samples.length * 2, true); @@ -10768,9 +10768,9 @@ function waveBytes(samples, rate = 16e3) { return bytes; } function base64Of(bytes) { - let text = ""; - for (let index = 0; index < bytes.length; index += 32768) text += String.fromCharCode(...bytes.subarray(index, index + 32768)); - return btoa(text); + let text2 = ""; + for (let index = 0; index < bytes.length; index += 32768) text2 += String.fromCharCode(...bytes.subarray(index, index + 32768)); + return btoa(text2); } var captureError = (kind, cause) => Object.assign(new Error(kind), { kind, cause }); async function openCapture() { @@ -11078,7 +11078,7 @@ function ModelChoiceDock({ sessionId, useRoster, actions }) { void actions.refreshModels?.(); }).finally(() => setBusy("")); }; - const text = lost ? t("{model} is not available anymore. Pick another model for me.", { model: bot.model.split("/").slice(1).join("/") }) : models.length === 0 ? t("Hi, I'm {name}. I don't have a model yet. How do you want to set one up?", { name: bot.name }) : t("Hi, I'm {name}. Pick a model for me first. You can change it later in my details.", { name: bot.name }); + const text2 = lost ? t("{model} is not available anymore. Pick another model for me.", { model: bot.model.split("/").slice(1).join("/") }) : models.length === 0 ? t("Hi, I'm {name}. I don't have a model yet. How do you want to set one up?", { name: bot.name }) : t("Hi, I'm {name}. Pick a model for me first. You can change it later in my details.", { name: bot.name }); const choices = models.length === 0 ? [...setupChoices(actions).map(([label, run]) => (0, import_react20.createElement)("button", { key: label, type: "button", className: "bt-model-chip", onClick: run }, label)), (0, import_react20.createElement)(KeyHelpLink, { key: "key-help" })] : [ ...groupModels(models).map(([provider, list]) => (0, import_react20.createElement)( "div", @@ -11110,7 +11110,7 @@ function ModelChoiceDock({ sessionId, useRoster, actions }) { "div", { className: "bt-msg-line" }, (0, import_react20.createElement)(BotMark, { bot, size: 22 }), - (0, import_react20.createElement)("div", { className: "bt-bubble" }, text) + (0, import_react20.createElement)("div", { className: "bt-bubble" }, text2) ) ) ), @@ -11150,9 +11150,9 @@ function KeyHelpLink() { return (0, import_react20.createElement)("a", { className: "bt-model-help", href: KEY_HELP_URL, target: "_blank", rel: "noopener noreferrer" }, t("How to set a DeepSeek API key")); } function missingModelKey(message) { - const text = String(message ?? ""); - if (!/\bno api key\b|\bmissing (?:an? )?api key\b|\bapi key (?:is )?(?:missing|not set|not configured)\b/i.test(text)) return void 0; - return { route: /provider route "([^"]+)"/i.exec(text)?.[1] ?? "" }; + const text2 = String(message ?? ""); + if (!/\bno api key\b|\bmissing (?:an? )?api key\b|\bapi key (?:is )?(?:missing|not set|not configured)\b/i.test(text2)) return void 0; + return { route: /provider route "([^"]+)"/i.exec(text2)?.[1] ?? "" }; } function modelKeyChoices(actions, route) { if (/^deepseek/i.test(route)) return setupChoices(actions); @@ -11163,8 +11163,8 @@ function TurnErrorCell({ node, t: chatT, actions }) { const code = typeof data.code === "string" && data.code ? data.code : void 0; const raw = String(data.message ?? ""); const shell = (key, fallback) => { - const text = typeof chatT === "function" ? chatT(key) : void 0; - return typeof text === "string" && text !== "" && text !== key ? text : fallback; + const text2 = typeof chatT === "function" ? chatT(key) : void 0; + return typeof text2 === "string" && text2 !== "" && text2 !== key ? text2 : fallback; }; const missing = missingModelKey(raw); const title = code === "ACCOUNT_SIGNED_OUT" ? shell("message.accountStopped", t("This turn failed")) : shell("message.turnError", t("This turn failed")); @@ -11550,8 +11550,8 @@ function timingText(task) { return `${t("Every day at {time}", { time: clock2(task.time) })}${zone}`; case "weekly": { const days = (task.weekdays ?? []).join(); - const text = 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 `${text}${zone}`; + 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}`; @@ -12041,10 +12041,10 @@ var SCHEDULE_CSS = ` // src/client/capability-settings.js var import_react24 = require("react"); var blank = () => ({ name: "", kind: "mcp", description: "", transport: "stdio", command: "", argsText: "", envText: "{}", url: "", headersText: "{}", content: "", items: [] }); -var parseMap = (text, label) => { +var parseMap = (text2, label) => { let value; try { - value = JSON.parse(text || "{}"); + value = JSON.parse(text2 || "{}"); } catch { throw new Error(t("{label} must be valid JSON.", { label })); } @@ -12471,11 +12471,11 @@ function Entries({ bot, view, remove }) { ); } function changeLine(change) { - const text = change.text.length > 120 ? `${change.text.slice(0, 120)}…` : change.text; - if (!change.ok) return t("Not changed: {text} ({reason})", { text, reason: hostText(change.error ?? "") }); - if (change.op === "remove") return change.scope === "team" ? t("Removed from team memory: {text}", { text }) : t("Removed: {text}", { text }); - if (change.op === "edit") return t("Updated: {text}", { text }); - return change.scope === "team" ? t("Added to team memory: {text}", { text }) : t("Added: {text}", { text }); + const text2 = change.text.length > 120 ? `${change.text.slice(0, 120)}…` : change.text; + if (!change.ok) return t("Not changed: {text} ({reason})", { text: text2, reason: hostText(change.error ?? "") }); + if (change.op === "remove") return change.scope === "team" ? t("Removed from team memory: {text}", { text: text2 }) : t("Removed: {text}", { text: text2 }); + if (change.op === "edit") return t("Updated: {text}", { text: text2 }); + return change.scope === "team" ? t("Added to team memory: {text}", { text: text2 }) : t("Added: {text}", { text: text2 }); } function Reply({ answer, dismiss }) { return (0, import_react26.createElement)( @@ -12546,7 +12546,7 @@ function MemoryDialog({ bot, actions, leaving = false }) { const [page, setPage] = (0, import_react26.useState)("summary"); const [menu, setMenu] = (0, import_react26.useState)(false); const [menuShown, menuLeaving] = useLinger(menu, MENU_EXIT_MS2); - const [text, setText] = (0, import_react26.useState)(""); + const [text2, setText] = (0, import_react26.useState)(""); const [answer, setAnswer] = (0, import_react26.useState)(null); const menuRef = (0, import_react26.useRef)(null); const dotsRef = (0, import_react26.useRef)(null); @@ -12598,7 +12598,7 @@ function MemoryDialog({ bot, actions, leaving = false }) { }; const ask = async (event) => { event.preventDefault(); - const asked = text.trim(); + const asked = text2.trim(); if (asked === "" || answer?.pending) return; setText(""); setAnswer({ asked, pending: true }); @@ -12664,7 +12664,7 @@ function MemoryDialog({ bot, actions, leaving = false }) { (0, import_react26.createElement)("input", { ref: inputRef, className: "bt-mem-input", - value: text, + value: text2, maxLength: 2e3, disabled: view === null, placeholder: t("Ask or update"), @@ -12673,7 +12673,7 @@ function MemoryDialog({ bot, actions, leaving = false }) { }), (0, import_react26.createElement)( "button", - { type: "submit", className: "bt-mem-send", disabled: busy || text.trim() === "", "aria-label": t("Send") }, + { type: "submit", className: "bt-mem-send", disabled: busy || text2.trim() === "", "aria-label": t("Send") }, busy ? (0, import_react26.createElement)("span", { className: "bt-mem-spin", "aria-hidden": true }) : (0, import_react26.createElement)(ArrowUpIcon) ) ) @@ -13608,11 +13608,11 @@ function CommandPalette({ start, token, roster, actions, useSessions }) { const entry = outline[index2]; if (hidden.includes(entry.turn)) continue; for (const raw of [entry.response, entry.prompt]) { - const text = paletteText(raw, names); - const at = text.toLowerCase().indexOf(wanted); + const text2 = paletteText(raw, names); + const at = text2.toLowerCase().indexOf(wanted); if (at < 0) continue; const from = Math.max(0, at - 24); - hits.push([id, entry.turn, (from > 0 ? "…" : "") + text.slice(from, at + wanted.length + 60)]); + hits.push([id, entry.turn, (from > 0 ? "…" : "") + text2.slice(from, at + wanted.length + 60)]); break; } } @@ -13798,10 +13798,10 @@ function createPayload({ name, look, image, model, effort }) { if (effort) payload.effort = effort; return payload; } -async function afterCreate({ id, text, actions }) { +async function afterCreate({ id, text: text2, actions }) { actions.openSession(id); actions.showDetails(id, "settings"); - const line2 = String(text ?? "").trim(); + const line2 = String(text2 ?? "").trim(); if (line2 === "") return; try { await actions.send(id, line2); @@ -14063,27 +14063,27 @@ function NewChat({ mode, roster, actions }) { }; const makeGroup = async () => { if (admin === null || picked.length === 0) return; - const text = message.trim(); + const text2 = message.trim(); const { members, remotes } = splitRoomPicks(roster, [admin, ...picked]); const participants = remotes.length === 0 ? void 0 : [ ...members.map((id) => ({ kind: "bot", id, role: id === admin ? "admin" : "member" })), ...remotes ]; const room2 = await actions.createRoom(members, { admin, ...participants ? { participants } : {} }); - if (text) await actions.send(room2.id, text); + if (text2) await actions.send(room2.id, text2); actions.openSession(room2.id); close(); }; const createGroup = () => run(makeGroup); const sendMessage = () => run(async () => { - const text = message.trim(); + const text2 = message.trim(); if (group && admin !== null) { await makeGroup(); return; } - if (text === "" || picked.length === 0) return; + if (text2 === "" || picked.length === 0) return; if (picked.length === 1) { - await actions.send(picked[0], text); + await actions.send(picked[0], text2); actions.openSession(picked[0]); close(); return; @@ -14094,7 +14094,7 @@ function NewChat({ mode, roster, actions }) { ...remotes ]; const room2 = await actions.createRoom(members, participants ? { participants } : {}); - await actions.send(room2.id, text); + await actions.send(room2.id, text2); actions.openSession(room2.id); close(); }); @@ -14308,10 +14308,10 @@ ${listed}`, { axis: "y", ...QUICK_EDGES }); // src/client/exchange.js var import_react33 = require("react"); var SHEET_EXIT_MS = 220; -function exchangeText(text) { - if (typeof text !== "string") return ""; +function exchangeText(text2) { + if (typeof text2 !== "string") return ""; const CLOSE = ""; - let body = text; + let body = text2; for (let previous; previous !== body; ) { previous = body; const open = body.indexOf(" { - setHeard(text); + const submit = (text2) => { + setHeard(text2); setPhase("thinking"); waiting.current = reply.current.split("|")[0]; - void actions.send(sessionId, text.trim()).catch(() => setPhase("listening")); + void actions.send(sessionId, text2.trim()).catch(() => setPhase("listening")); }; - const dictation = useDictation((text, final) => { - setHeard(text); + const dictation = useDictation((text2, final) => { + setHeard(text2); if (!final) return; dictation.stop(); - submit(text); + submit(text2); }); (0, import_react34.useEffect)(() => { if (!note) return void 0; @@ -14534,9 +14534,9 @@ function VoiceMode({ sessionId, roster, actions, useSessions, useSessionStatus } setPhase("transcribing"); try { const wave = await current.finish(catalog.maxDurationSeconds); - const text = wave ? await transcribeWave(speech, catalog, wave, new AbortController().signal) : ""; + const text2 = wave ? await transcribeWave(speech, catalog, wave, new AbortController().signal) : ""; if (turn.current !== own) return; - if (text) submit(text); + if (text2) submit(text2); else setPhase("listening"); } catch (error) { if (turn.current !== own) return; @@ -14585,10 +14585,10 @@ function VoiceMode({ sessionId, roster, actions, useSessions, useSessionStatus } }, [speech, catalog, listen, cycle]); (0, import_react34.useEffect)(() => { if (phase !== "thinking" || running || waiting.current === null) return; - const [count, text] = [lastReply.slice(0, lastReply.indexOf("|")), lastReply.slice(lastReply.indexOf("|") + 1)]; - if (count === waiting.current || !text.trim()) return; + const [count, text2] = [lastReply.slice(0, lastReply.indexOf("|")), lastReply.slice(lastReply.indexOf("|") + 1)]; + if (count === waiting.current || !text2.trim()) return; waiting.current = null; - const spoken = speechOf(text, botNames(roster)); + const spoken = speechOf(text2, botNames(roster)); setSaid(spoken); setHeard(""); if (!window.speechSynthesis || !spoken) { @@ -15337,8 +15337,8 @@ function summarize(view, range, keep = () => true, seriesOf = () => "all") { return result; } function trimmed2(value) { - const text = value >= 100 ? value.toFixed(0) : value >= 10 ? value.toFixed(1) : value.toFixed(2); - return text.includes(".") ? text.replace(/\.?0+$/, "") : text; + const text2 = value >= 100 ? value.toFixed(0) : value >= 10 ? value.toFixed(1) : value.toFixed(2); + return text2.includes(".") ? text2.replace(/\.?0+$/, "") : text2; } function compact(count) { for (const [size, unit] of [[1e9, "B"], [1e6, "M"], [1e3, "K"]]) { @@ -15470,14 +15470,14 @@ function kindLabel(kind, botFocus) { return labels[kind] ?? kind; } function Tabs({ label, value, options, onChange, boxRef }) { - const names = options.map(([, text]) => text).join("\n"); + const names = options.map(([, text2]) => text2).join("\n"); const glide = useGlide(`${value} ${names}`, { box: boxRef }); return (0, import_react36.createElement)( "div", { ref: glide.box, className: "bt-us-tabs", role: "group", "aria-label": label }, (0, import_react36.createElement)("span", { ref: glide.thumb, className: "bt-thumb", "aria-hidden": true }), - options.map(([id, text, icon3]) => (0, import_react36.createElement)("button", { key: id, type: "button", "aria-pressed": value === id, onClick: () => onChange(id) }, icon3 ? (0, import_react36.createElement)(icon3) : null, text)) + options.map(([id, text2, icon3]) => (0, import_react36.createElement)("button", { key: id, type: "button", "aria-pressed": value === id, onClick: () => onChange(id) }, icon3 ? (0, import_react36.createElement)(icon3) : null, text2)) ); } function Stats({ range, total, previous }) { @@ -16363,15 +16363,15 @@ function AppPicker({ apps, onChange, search, statuses = {} }) { const names = (0, import_react37.useRef)(new Map(APPS)); const asked = (0, import_react37.useRef)(0); (0, import_react37.useEffect)(() => { - const text = query.trim(); - if (text === "" || search === void 0) { + const text2 = query.trim(); + if (text2 === "" || search === void 0) { setFound(null); setFailed(null); return void 0; } const ask = ++asked.current; const timer = setTimeout(() => { - search(text).then((list) => { + search(text2).then((list) => { if (ask !== asked.current) return; for (const app of list) names.current.set(app.slug, app.name); setFound(list); @@ -16605,15 +16605,15 @@ var FeedbackIcon = () => (0, import_react38.createElement)( (0, import_react38.createElement)("path", { d: "M4 4.5h12a1 1 0 0 1 1 1v7a1 1 0 0 1-1 1H9l-3.5 3v-3H4a1 1 0 0 1-1-1v-7a1 1 0 0 1 1-1z" }), (0, import_react38.createElement)("path", { d: "M10 7v2.6M10 11.4h.01" }) ); -function download(name, text) { - const url = URL.createObjectURL(new Blob([text], { type: "text/plain;charset=utf-8" })); +function download(name, text2) { + const url = URL.createObjectURL(new Blob([text2], { type: "text/plain;charset=utf-8" })); const anchor = document.createElement("a"); anchor.href = url; anchor.download = name; anchor.click(); setTimeout(() => URL.revokeObjectURL(url), 1e3); } -var copy = (text) => navigator.clipboard === void 0 ? Promise.resolve(false) : navigator.clipboard.writeText(text).then(() => true, () => false); +var copy = (text2) => navigator.clipboard === void 0 ? Promise.resolve(false) : navigator.clipboard.writeText(text2).then(() => true, () => false); function whenText2(time) { const date = new Date(time); const today = (/* @__PURE__ */ new Date()).toDateString() === date.toDateString(); @@ -16661,8 +16661,8 @@ function FeedbackPage({ actions, run, Section, Row: Row4 }) { if (!inline && !copied) setPreview(true); })); }; - const downloadLog = () => run(actions.diagnosticsLog().then((text) => { - download("ds-bot-diagnostics.log", text || "No entries.\n"); + const downloadLog = () => run(actions.diagnosticsLog().then((text2) => { + download("ds-bot-diagnostics.log", text2 || "No entries.\n"); setNote(t("Downloaded the log. Keys, tokens, and your home folder are hidden in it.")); })); const clearLog = () => run(actions.diagnosticsClear().then(clearClientErrors).then(load).then(() => setNote(t("Cleared the error log.")))); @@ -17172,13 +17172,13 @@ function SettingsDialog({ page, id, roster, actions, useUpdate, leaving = false "nav", { ref: nav.box, className: "bt-settings-nav", "aria-label": t("Settings pages") }, (0, import_react40.createElement)("span", { ref: nav.thumb, className: "bt-thumb", "aria-hidden": true }), - pages.map(([key, text]) => (0, import_react40.createElement)("button", { + pages.map(([key, text2]) => (0, import_react40.createElement)("button", { key, type: "button", className: "bt-settings-item", "aria-current": shown === key ? "page" : void 0, onClick: () => actions.openSettings(key) - }, (0, import_react40.createElement)(extra.find((item) => item.key === key)?.entry.icon ?? PAGE_ICONS[key] ?? ConnectorIcon), t(text))), + }, (0, import_react40.createElement)(extra.find((item) => item.key === key)?.entry.icon ?? PAGE_ICONS[key] ?? ConnectorIcon), t(text2))), // DeepSeek Harness's own settings are a separate window with its own title and look; FlyBot // does not embed or retitle it (never patch DSH), so the entry says it leaves (#421). (0, import_react40.createElement)("button", { @@ -17543,16 +17543,16 @@ function BrowserCards({ bot, sessionId, roster, actions, useBrowserTabs, useBrow (0, import_react42.createElement)("button", { type: "button", className: "bt-bcard-new", style: { animationDelay: `${state.ids.length * 30}ms` }, onClick: () => actions.browserNewTab(sessionId) }, (0, import_react42.createElement)(PlusIcon), t("New tab")) ); } -function About({ text, title = "About" }) { +function About({ text: text2, title = "About" }) { const [open, setOpen] = (0, import_react42.useState)(false); - const long = text.split("\n").length > 5 || text.length > 240; + const long = text2.split("\n").length > 5 || text2.length > 240; const body = (0, import_react42.useRef)(null); useMorph(body, open); return (0, import_react42.createElement)( "div", null, (0, import_react42.createElement)("div", { className: "bt-section-title" }, t(title)), - (0, import_react42.createElement)("div", { ref: body, className: "bt-about", "data-open": open || void 0 }, text), + (0, import_react42.createElement)("div", { ref: body, className: "bt-about", "data-open": open || void 0 }, text2), long ? (0, import_react42.createElement)("button", { type: "button", className: "bt-more", onClick: () => setOpen((value) => !value) }, open ? t("Show less") : t("Show more")) : null ); } @@ -17739,12 +17739,12 @@ function DetailsDrawer({ sessionId, startRenaming, startTab, hasStrip, roster, a return false; }); const share = () => { - const text = transcript; - if (!text) { + const text2 = transcript; + if (!text2) { actions.notify?.(t("Nothing to copy yet")); return; } - void copiedToClipboard(text).then((written) => { + void copiedToClipboard(text2).then((written) => { if (!written) { actions.reportError?.(new Error(t("Could not copy the conversation")), "share"); return; @@ -18120,11 +18120,11 @@ function BrowserView({ bot, tabId, leaving, landed, actions, useBrowserPages }) }, [tabId, landed]); const go = (event) => { event.preventDefault(); - const text = draft ?? ""; - if (text.trim() === "") return; + const text2 = draft ?? ""; + if (text2.trim() === "") return; setDraft(null); input.current?.blur(); - void actions.browserNavigate(tabId, text).then((opened) => { + void actions.browserNavigate(tabId, text2).then((opened) => { if (opened) actions.browserFocus(tabId); }); }; @@ -18668,19 +18668,19 @@ function readPage(limits) { const auto = String(el.getAttribute("autocomplete") || "").toLowerCase(); return el.type === "password" || /(^|\s)(cc-|one-time-code)/.test(auto) || SECRET_HINT.test(`${el.getAttribute("name") || ""} ${el.id || ""}`); }; - const keep = (text2, end = "") => { - if (text2 === "") return ""; - stash.push(text2); + const keep = (text3, end = "") => { + if (text3 === "") return ""; + stash.push(text3); return ` ${stash.length - 1}${end} `; }; - const expand = (text2) => text2.replace(/\u0001(\d+)[\u0002\u0003]/g, (_, index) => expand(stash[Number(index)])); - const flow = (text2) => text2.split("\n").map((line2) => line2.replace(/[ \t\f\v\u00a0\u2028\u2029]+/g, " ").trim().replace(/^· /, "")).join("\n").replace(/\n{3,}/g, "\n\n").replace(/\u0003\n\n(?=\u0001\d+\u0003)/g, "\n").trim(); - const block = (text2) => expand(flow(text2)); - const oneLine = (text2) => block(text2).replace(/\s*\n+\s*/g, " ").trim(); + const expand = (text3) => text3.replace(/\u0001(\d+)[\u0002\u0003]/g, (_, index) => expand(stash[Number(index)])); + const flow = (text3) => text3.split("\n").map((line2) => line2.replace(/[ \t\f\v\u00a0\u2028\u2029]+/g, " ").trim().replace(/^· /, "")).join("\n").replace(/\n{3,}/g, "\n\n").replace(/\u0003\n\n(?=\u0001\d+\u0003)/g, "\n").trim(); + const block = (text3) => expand(flow(text3)); + const oneLine = (text3) => block(text3).replace(/\s*\n+\s*/g, " ").trim(); const styleOf = (el) => (el.ownerDocument.defaultView || window).getComputedStyle(el); const childrenOf = (el) => { if (el.shadowRoot) return el.shadowRoot.childNodes; @@ -18714,8 +18714,8 @@ function readPage(limits) { items.push(item); return { item, number: items.length, secret }; }; - const nameOf = (el, text2, secret) => collapse( - el.getAttribute("aria-label") || text2 || (el.localName === "input" && !secret && NO_VALUE_TYPES.has(el.type) ? el.value : "") || el.getAttribute("placeholder") || el.title || el.getAttribute("alt") || el.querySelector?.("img[alt]")?.alt || el.getAttribute("name") || "" + const nameOf = (el, text3, secret) => collapse( + el.getAttribute("aria-label") || text3 || (el.localName === "input" && !secret && NO_VALUE_TYPES.has(el.type) ? el.value : "") || el.getAttribute("placeholder") || el.title || el.getAttribute("alt") || el.querySelector?.("img[alt]")?.alt || el.getAttribute("name") || "" ).slice(0, 80); const fenced = (code, lang) => { const longest = Math.max(2, ...(code.match(/`{3,}/g) || []).map((run) => run.length)); @@ -18784,12 +18784,12 @@ ${children(cell, ctx)} for (const cell of row.cells) { place(); if (styleOf(cell).display === "none") continue; - const text2 = cellText(cell, ctx); + const text3 = cellText(cell, ctx); const span = Math.max(1, Math.min(limits.tableCols, Number(cell.colSpan) || 1)); const down = Math.max(1, Math.min(limits.tableRows, Number(cell.rowSpan) || 1)); for (let offset = 0; offset < span; offset += 1) { - line2[column] = offset === 0 ? text2 : ""; - if (down > 1) carry[column] = { text: offset === 0 ? text2 : "", left: down - 1 }; + line2[column] = offset === 0 ? text3 : ""; + if (down > 1) carry[column] = { text: offset === 0 ? text3 : "", left: down - 1 }; column += 1; } } @@ -18854,9 +18854,9 @@ ${body2 || "(no text)"} if (node.nodeType === 3) { if (!ctx.shown) return ""; const data = node.data.replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f]/g, ""); - const text2 = ctx.pre ? data : data.replace(/[\t\n\r ]+/g, " "); - chars += text2.length; - return text2; + const text3 = ctx.pre ? data : data.replace(/[\t\n\r ]+/g, " "); + chars += text3.length; + return text3; } if (node.nodeType !== 1) return ""; const el = node; @@ -18944,12 +18944,12 @@ ${marked} return marked; } const body = document.body; - let text = ""; + let text2 = ""; if (body) { - text = block(render(body, { depth: 0, frames: 0, shown: true, pre: false })).replace(/\u00a0/g, " "); + text2 = block(render(body, { depth: 0, frames: 0, shown: true, pre: false })).replace(/\u00a0/g, " "); } - if (text.length > limits.chars) { - text = text.slice(0, limits.chars); + if (text2.length > limits.chars) { + text2 = text2.slice(0, limits.chars); stop ||= `the text passed ${limits.chars} characters`; } const page = { @@ -18959,7 +18959,7 @@ ${marked} description: document.querySelector('meta[name="description"]')?.content || "", selection: String(getSelection() || "").slice(0, 4e3), format: "markdown", - text, + text: text2, items, stats: { ...stats, ms: Math.round(performance.now() - started) } }; @@ -18987,15 +18987,15 @@ var METADATA_HOST = /^(169\.254\.\d+\.\d+|\[fd00:ec2::254\]|metadata\.google\.in var BLOCKED = "Cloud metadata addresses do not open here."; var blockedAddress = (url) => URL.canParse(url) && METADATA_HOST.test(new URL(url).hostname); function browserAddress(input) { - const text = String(input ?? "").trim(); - if (text === "") return null; - if (/^[a-z][a-z\d+.-]*:\/\//i.test(text)) return /^https?:\/\//i.test(text) && URL.canParse(text) ? new URL(text).href : null; - if (/^(about|javascript|data|file|blob|chrome|devtools|view-source):/i.test(text)) return null; - const host = text.split(/[/?#]/)[0]; + const text2 = String(input ?? "").trim(); + if (text2 === "") return null; + if (/^[a-z][a-z\d+.-]*:\/\//i.test(text2)) return /^https?:\/\//i.test(text2) && URL.canParse(text2) ? new URL(text2).href : null; + if (/^(about|javascript|data|file|blob|chrome|devtools|view-source):/i.test(text2)) return null; + const host = text2.split(/[/?#]/)[0]; const local = /^(localhost|127(\.\d+){3}|10(\.\d+){3}|192\.168(\.\d+){2}|172\.(1[6-9]|2\d|3[01])(\.\d+){2}|\[[\da-f:]+\])(:\d+)?$/i.test(host); - const looksLikeHost = !/\s/.test(text) && (local || /^[^\s.]+(\.[^\s.]+)+(:\d+)?$/.test(host)); - if (looksLikeHost && URL.canParse(`http://${text}`)) return new URL(`${local ? "http" : "https"}://${text}`).href; - return `${SEARCH_URL}${encodeURIComponent(text)}`; + const looksLikeHost = !/\s/.test(text2) && (local || /^[^\s.]+(\.[^\s.]+)+(:\d+)?$/.test(host)); + if (looksLikeHost && URL.canParse(`http://${text2}`)) return new URL(`${local ? "http" : "https"}://${text2}`).href; + return `${SEARCH_URL}${encodeURIComponent(text2)}`; } function createBotBrowsers({ bridge, createSource: createSource2, storage = globalThis.localStorage, document: doc = globalThis.document }) { const views = /* @__PURE__ */ new Map(); @@ -19475,8 +19475,8 @@ function appFrameOf(doc) { const frame = col?.parentElement; if (!frame) return null; const name = frame.className; - const text = typeof name === "string" ? name : name?.baseVal ?? ""; - return text.includes("_frame") ? frame : null; + const text2 = typeof name === "string" ? name : name?.baseVal ?? ""; + return text2.includes("_frame") ? frame : null; } function levelShell(doc, from) { const page = doc.scrollingElement || doc.documentElement; @@ -19517,6 +19517,123 @@ function watchShellLevel(doc) { }; } +// src/client/project-board.js +var import_react46 = require("react"); +var PROJECT_BOARD_CSS = ` +.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-row .bt-project-ref{margin-left:auto;font-size:11px;opacity:.6} +.bt-project-empty{padding:22px 4px;opacity:.7} +.bt-project-actions{display:flex;gap:8px;margin:0 0 14px} +`; +var PROJECT_SOURCES = ["bot", "group", "workflow"]; +var SOURCE_LABELS = { bot: "Bots", group: "Groups", workflow: "Workflows" }; +var text = (value) => typeof value === "string" ? value.trim() : ""; +function projectBoardView({ project, rows, canManage } = {}) { + const list = Array.isArray(rows) ? rows.filter((row) => row !== null && typeof row === "object" && text(row.id) !== "") : []; + const groups = PROJECT_SOURCES.map((source) => ({ + source, + label: SOURCE_LABELS[source], + rows: list.filter((row) => row.source === source).map((row) => ({ + 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 + })) + })).filter((group) => group.rows.length > 0); + return { + projectId: text(project?.id) || null, + title: text(project?.name) || "Project", + canManage: canManage === true, + groups, + count: list.length, + empty: list.length === 0, + emptyText: "Nothing under this Project yet." + }; +} +function ProjectBoardRows({ view, onOpen }) { + if (view.empty) return (0, import_react46.createElement)("div", { className: "bt-project-empty" }, t(view.emptyText)); + return view.groups.map((group) => (0, import_react46.createElement)( + "section", + { key: group.source, className: "bt-project-group", "data-source": group.source }, + (0, import_react46.createElement)("h4", null, t(group.label)), + group.rows.map((row) => (0, import_react46.createElement)( + "div", + { + key: row.key, + className: "bt-project-row", + "data-task-id": row.id, + "data-column": row.column, + role: "button", + tabIndex: 0, + onClick: () => onOpen?.(row.id), + onKeyDown: (event) => { + if (event.key === "Enter" || event.key === " ") onOpen?.(row.id); + } + }, + (0, import_react46.createElement)("span", { className: "bt-project-column" }, t(row.column)), + (0, import_react46.createElement)("span", { className: "bt-project-title" }, row.assignee ? `@${row.assignee}` : "—"), + row.runId === null ? null : (0, import_react46.createElement)("span", { className: "bt-project-ref" }, row.nodeId === null ? row.runId : `${row.runId} · ${row.nodeId}`) + )) + )); +} +function ProjectBoardPane({ project, rows, canManage, onOpen }) { + const view = projectBoardView({ project, rows, canManage }); + return (0, import_react46.createElement)( + "div", + { className: "bt-project-board", "data-can-manage": view.canManage ? "true" : "false" }, + (0, import_react46.createElement)("h3", null, view.title), + // Management affordances are drawn only for the owner; the refusal itself is the host's. + view.canManage ? (0, import_react46.createElement)("div", { className: "bt-project-actions" }, (0, import_react46.createElement)("button", { type: "button" }, t("Project settings")), (0, import_react46.createElement)("button", { type: "button" }, t("Members"))) : null, + (0, import_react46.createElement)(ProjectBoardRows, { view, onOpen }) + ); +} +function ProjectBoardOverlay() { + const [state, setState] = (0, import_react46.useState)(null); + const close = (0, import_react46.useCallback)(() => { + if (typeof window !== "undefined") window.location.hash = ""; + setState(null); + }, []); + (0, import_react46.useEffect)(() => { + if (typeof window === "undefined") return; + if (!window.location.hash.startsWith("#project")) return; + const wanted = window.location.hash.slice("#project".length).replace(/^=/, ""); + let live = true; + void (async () => { + try { + const listing = await call("projects"); + const projects = listing?.projects ?? []; + const project = wanted === "" ? projects[0] : projects.find((item) => item.id === wanted); + if (project === void 0) { + if (live) setState({ project: null, rows: [], canManage: false }); + return; + } + const [read, board] = await Promise.all([call("project-read", { id: project.id }), call("project-board", { id: project.id })]); + if (live) setState({ project: read?.project ?? project, rows: board?.tasks ?? [], canManage: read?.canManage === true }); + } catch { + if (live) setState({ project: null, rows: [], canManage: false }); + } + })(); + return () => { + live = false; + }; + }, []); + if (state === null) return null; + return (0, import_react46.createElement)( + "div", + { className: "bt-overlay bt-project-overlay", role: "dialog", "aria-label": t("Project board") }, + (0, import_react46.createElement)("button", { type: "button", className: "bt-project-close", onClick: close }, t("Close")), + state.project === null ? (0, import_react46.createElement)("div", { className: "bt-project-empty" }, t("No Project here.")) : (0, import_react46.createElement)(ProjectBoardPane, { project: state.project, rows: state.rows, canManage: state.canManage, onOpen: (id) => { + window.location.hash = `#task=${id}`; + } }) + ); +} + // src/client/friend-lines.js function friendLineNews(bots, known, isOpen, selfId) { const next = { ...known }; @@ -19586,7 +19703,7 @@ function writeMode(mode, storage = globalThis.localStorage) { } // src/client/user-message.js -var import_react46 = require("react"); +var import_react47 = require("react"); var ui = __toESM(require("@deepseek-ai/dsh-client-ui-primitives"), 1); var canDrawUserMessages = () => typeof ui.projectUserText === "function"; function contentParts(content) { @@ -19658,58 +19775,58 @@ function messageAuthor(data, roster, sessionId) { mine: roster?.self?.memberId === authorId }; } -function userTextParts(text, bots, referenceLabels = []) { +function userTextParts(text2, bots, referenceLabels = []) { const parts = []; let cursor = 0; - for (const span of mentionSpans(text, bots, { references: referenceLabels })) { + for (const span of mentionSpans(text2, bots, { references: referenceLabels })) { if (!span.bot) continue; - if (span.start > cursor) parts.push({ text: text.slice(cursor, span.start) }); - parts.push({ bot: span.bot, label: text.slice(span.start + 1, span.end) }); + if (span.start > cursor) parts.push({ text: text2.slice(cursor, span.start) }); + parts.push({ bot: span.bot, label: text2.slice(span.start + 1, span.end) }); cursor = span.end; - if (text.startsWith(" ", cursor)) cursor += 1; + if (text2.startsWith(" ", cursor)) cursor += 1; } - if (cursor < text.length) parts.push({ text: text.slice(cursor) }); + if (cursor < text2.length) parts.push({ text: text2.slice(cursor) }); return parts; } -function UserText({ text, bots, referenceLabels, skillNames, references, actions }) { - const chip = (part) => [(0, import_react46.createElement)(BotMark, { bot: part.bot.draw ?? part.bot, size: 16 }), `@${part.bot.mentionLabel ?? part.label}`]; - return userTextParts(text, bots, referenceLabels).map((part, index) => { - if (!part.bot) return (0, import_react46.createElement)(import_react46.Fragment, { key: index }, ui.projectUserText(part.text, referenceLabels, skillNames, "skill", references)); +function UserText({ text: text2, bots, referenceLabels, skillNames, references, actions }) { + const chip = (part) => [(0, import_react47.createElement)(BotMark, { bot: part.bot.draw ?? part.bot, size: 16 }), `@${part.bot.mentionLabel ?? part.label}`]; + return userTextParts(text2, bots, referenceLabels).map((part, index) => { + if (!part.bot) return (0, import_react47.createElement)(import_react47.Fragment, { key: index }, ui.projectUserText(part.text, referenceLabels, skillNames, "skill", references)); const title = `@${part.bot.name}`; - return typeof part.bot.id === "string" ? (0, import_react46.createElement)("button", { key: index, type: "button", className: "bt-umention", title, onClick: () => actions.openSession(part.bot.id) }, chip(part)) : (0, import_react46.createElement)("span", { key: index, className: "bt-umention", title }, chip(part)); + return typeof part.bot.id === "string" ? (0, import_react47.createElement)("button", { key: index, type: "button", className: "bt-umention", title, onClick: () => actions.openSession(part.bot.id) }, chip(part)) : (0, import_react47.createElement)("span", { key: index, className: "bt-umention", title }, chip(part)); }); } function Attachments({ attachments, renderMessageImages }) { const compact2 = attachments.length > 1; - return (0, import_react46.createElement)("div", { className: "bt-uattach" }, attachments.map((block, index) => { + return (0, import_react47.createElement)("div", { className: "bt-uattach" }, attachments.map((block, index) => { if (block.type === "image") { - return renderMessageImages ? (0, import_react46.createElement)(import_react46.Fragment, { key: index }, renderMessageImages({ images: [{ attachment: block.attachment }], align: "end", compact: compact2 })) : null; + return renderMessageImages ? (0, import_react47.createElement)(import_react47.Fragment, { key: index }, renderMessageImages({ images: [{ attachment: block.attachment }], align: "end", compact: compact2 })) : null; } const file = block.attachment; const meta = [ui.fileExtension?.(file.name)?.toUpperCase().slice(0, 8), ui.fileSizeText?.(file.bytes)].filter(Boolean).join(" · "); - return (0, import_react46.createElement)( + return (0, import_react47.createElement)( "span", { key: index, className: "bt-ufile", title: file.name }, - ui.FileTypeIcon ? (0, import_react46.createElement)(ui.FileTypeIcon, { path: file.name, className: "bt-ufile-icon" }) : null, - (0, import_react46.createElement)("span", { className: "bt-ufile-text" }, (0, import_react46.createElement)("span", { className: "bt-ufile-name" }, file.name), meta ? (0, import_react46.createElement)("span", { className: "bt-ufile-meta" }, meta) : null) + ui.FileTypeIcon ? (0, import_react47.createElement)(ui.FileTypeIcon, { path: file.name, className: "bt-ufile-icon" }) : null, + (0, import_react47.createElement)("span", { className: "bt-ufile-text" }, (0, import_react47.createElement)("span", { className: "bt-ufile-name" }, file.name), meta ? (0, import_react47.createElement)("span", { className: "bt-ufile-meta" }, meta) : null) ); })); } -function CopyButton({ text }) { - const [copied, setCopied] = (0, import_react46.useState)(false); +function CopyButton({ text: text2 }) { + const [copied, setCopied] = (0, import_react47.useState)(false); const copy2 = () => { - void navigator.clipboard?.writeText(text).then(() => { + void navigator.clipboard?.writeText(text2).then(() => { setCopied(true); setTimeout(() => setCopied(false), 1e3); }); }; - return (0, import_react46.createElement)("button", { type: "button", className: "bt-tool", "aria-label": copied ? t("Copied") : t("Copy"), title: copied ? t("Copied") : t("Copy"), onClick: copy2 }, copied ? (0, import_react46.createElement)(CheckIcon) : (0, import_react46.createElement)(CopyIcon)); + return (0, import_react47.createElement)("button", { type: "button", className: "bt-tool", "aria-label": copied ? t("Copied") : t("Copy"), title: copied ? t("Copied") : t("Copy"), onClick: copy2 }, copied ? (0, import_react47.createElement)(CheckIcon) : (0, import_react47.createElement)(CopyIcon)); } var clock3 = (time) => typeof time === "number" ? new Date(time).toLocaleTimeString(dateLocale(), { hour: "numeric", minute: "2-digit" }) : void 0; function UserMessageCell({ node, sessionId, useRoster, actions, renderMessageImages, openFile, openSkill }) { const roster = useRoster((value) => value); const data = node.data ?? {}; - const { text, attachments, rest } = contentParts(data.content); + const { text: text2, attachments, rest } = contentParts(data.content); const referenceLabels = data.referenceLabels ?? []; const references = openFile && openSkill ? { openFile, openSkill } : void 0; const steering = node.kind === "steering"; @@ -19720,7 +19837,7 @@ function UserMessageCell({ node, sessionId, useRoster, actions, renderMessageIma const person = incoming && author.authorKind === "human" && !author.ownerName; const faceMine = Boolean(room2?.origin === "face-to-face" && !incoming); const myName = faceMine ? facePeople(room2, roster).find((entry) => entry.mine)?.name || cleanPeerName(roster.self?.displayName, "") || author?.name || "" : ""; - return (0, import_react46.createElement)( + return (0, import_react47.createElement)( "div", { className: incoming ? "bt-umsg bt-umsg-other" : "bt-umsg", @@ -19730,22 +19847,22 @@ function UserMessageCell({ node, sessionId, useRoster, actions, renderMessageIma "data-author-kind": author?.authorKind, "data-author-name": author?.name }, - person ? (0, import_react46.createElement)("span", { className: "bt-lead", title: author.name }, (0, import_react46.createElement)(PersonMark, { name: author.name })) : null, - faceMine ? (0, import_react46.createElement)("span", { className: "bt-lead", title: myName || void 0 }, (0, import_react46.createElement)(PersonMark, { name: myName || void 0 })) : null, - (0, import_react46.createElement)( + person ? (0, import_react47.createElement)("span", { className: "bt-lead", title: author.name }, (0, import_react47.createElement)(PersonMark, { name: author.name })) : null, + faceMine ? (0, import_react47.createElement)("span", { className: "bt-lead", title: myName || void 0 }, (0, import_react47.createElement)(PersonMark, { name: myName || void 0 })) : null, + (0, import_react47.createElement)( "div", { className: "bt-ustack" }, - faceMine && myName ? (0, import_react46.createElement)("span", { className: "bt-uauthor" }, myName) : author && (incoming || multiHuman) ? (0, import_react46.createElement)("span", { className: "bt-uauthor" }, author.name, author.ownerName ? (0, import_react46.createElement)("span", { className: "bt-chip" }, t("Friend's Bot")) : null) : null, - attachments.length > 0 ? (0, import_react46.createElement)(Attachments, { attachments, renderMessageImages }) : null, - text !== "" || rest.length > 0 ? (0, import_react46.createElement)( + faceMine && myName ? (0, import_react47.createElement)("span", { className: "bt-uauthor" }, myName) : author && (incoming || multiHuman) ? (0, import_react47.createElement)("span", { className: "bt-uauthor" }, author.name, author.ownerName ? (0, import_react47.createElement)("span", { className: "bt-chip" }, t("Friend's Bot")) : null) : null, + attachments.length > 0 ? (0, import_react47.createElement)(Attachments, { attachments, renderMessageImages }) : null, + text2 !== "" || rest.length > 0 ? (0, import_react47.createElement)( "div", { className: "bt-ubody", title: clock3(data.time) }, - (0, import_react46.createElement)(UserText, { text, bots: mentionableBots(roster, sessionId, { fromFriend: Boolean(incoming && author.ownerName) }), referenceLabels, skillNames: data.skillNames ?? [], references, actions }), - rest.map((block, index) => ui.JsonBlock ? (0, import_react46.createElement)(ui.JsonBlock, { key: index, label: t("Content"), payload: block }) : null) + (0, import_react47.createElement)(UserText, { text: text2, bots: mentionableBots(roster, sessionId, { fromFriend: Boolean(incoming && author.ownerName) }), referenceLabels, skillNames: data.skillNames ?? [], references, actions }), + rest.map((block, index) => ui.JsonBlock ? (0, import_react47.createElement)(ui.JsonBlock, { key: index, label: t("Content"), payload: block }) : null) ) : null, - referenceLabels.length > 0 ? (0, import_react46.createElement)("div", { className: "bt-urefs" }, t("Referenced {labels}", { labels: referenceLabels.join(t(", ")) })) : null + referenceLabels.length > 0 ? (0, import_react47.createElement)("div", { className: "bt-urefs" }, t("Referenced {labels}", { labels: referenceLabels.join(t(", ")) })) : null ), - text === "" ? null : (0, import_react46.createElement)("div", { className: "bt-utools" }, (0, import_react46.createElement)(CopyButton, { text })) + text2 === "" ? null : (0, import_react47.createElement)("div", { className: "bt-utools" }, (0, import_react47.createElement)(CopyButton, { text: text2 })) ); } var USER_MESSAGE_CSS = ` @@ -19843,33 +19960,33 @@ function noticeApprovals({ work, roster, shownSession, openSession, since: since } // src/client/stats-dock.js -var import_react47 = require("react"); +var import_react48 = require("react"); var STATS_DOCK = "conversation.composer.dock"; var STATS_ID = "stats"; var STATS_IDS = [STATS_ID, "activity"]; var noun = (count, one, many) => `${count} ${Number(count) === 1 ? one : many}`; function pluralStats(t2) { return (key, params) => { - const text = t2(key, params); - if (key !== "stats.counts" || !params) return text; + const text2 = t2(key, params); + if (key !== "stats.counts" || !params) return text2; const { turns, steps } = params; - return text === `${turns} turns ${steps} steps` ? `${noun(turns, "turn", "turns")} ${noun(steps, "step", "steps")}` : text; + return text2 === `${turns} turns ${steps} steps` ? `${noun(turns, "turn", "turns")} ${noun(steps, "step", "steps")}` : text2; }; } function statsDock(entriesOf, id = STATS_ID) { const shell = () => (entriesOf(STATS_DOCK) ?? []).find((entry) => entry?.component !== StatsDock && entry?.options?.id === id); function StatsDock(props) { - const t2 = (0, import_react47.useMemo)(() => typeof props.t === "function" ? pluralStats(props.t) : props.t, [props.t]); + const t2 = (0, import_react48.useMemo)(() => typeof props.t === "function" ? pluralStats(props.t) : props.t, [props.t]); const entry = shell(); if (!entry || typeof props.usePerformanceUsage !== "function") return null; - return (0, import_react47.createElement)(entry.component, { ...props, t: t2 }); + return (0, import_react48.createElement)(entry.component, { ...props, t: t2 }); } return { component: StatsDock, inject: (...args) => shell()?.inject?.(...args) ?? {} }; } var statsDocks = (entriesOf) => STATS_IDS.map((id) => ({ id, ...statsDock(entriesOf, id) })); // src/client/index.js -var import_react48 = require("react"); +var import_react49 = require("react"); var inject = ["sessions", "uiWorkspace", "uiConversation", "slots", "theme", "layout"]; function apply(ctx) { const flybot = createClientApi2(); @@ -20459,7 +20576,7 @@ function apply(ctx) { } return { ...state, reactions }; }), - quote: (sessionId, text) => ui2.set((state) => ({ ...state, quote: { sessionId, text, token: Date.now() } })), + quote: (sessionId, text2) => ui2.set((state) => ({ ...state, quote: { sessionId, text: text2, token: Date.now() } })), clearQuote: (token) => ui2.set((state) => state.quote?.token === token ? { ...state, quote: null } : state), clearUnread: (id) => setUnread(id, false), setFlags: (id, flags) => after(call("set-flags", { id, ...flags })), @@ -20487,7 +20604,7 @@ function apply(ctx) { setPrefs: (prefs) => after(call("set-prefs", prefs)), setMain: (id, main = true, options = {}) => after(call("set-main", { id, main, ...options })), updateRoom: (id, patch) => after(call("update-room", { id, ...patch })), - send: (sessionId, text) => after(call("send", { sessionId: partFor(sessionId), text })), + send: (sessionId, text2) => after(call("send", { sessionId: partFor(sessionId), text: text2 })), answerQuestion: (sessionId, questionId, answer) => after(call("answer-question", { sessionId: partFor(sessionId), questionId, answer })), answerTaskInput: (id, questionId, answer) => after(call("task-answer-input", { id, questionId, answer })), // The Done list is the acceptance surface (#401): a result is accepted, or sent back with a note @@ -20514,7 +20631,7 @@ function apply(ctx) { connectorBlock: (id, openId) => after(call("connector-block", { id, openId })), connectorDisconnect: (id) => after(call("connector-disconnect", { id })), connectorRetry: (id) => after(call("connector-retry", { id })), - connectorSend: (id, text) => after(call("connector-send", { id, text })), + connectorSend: (id, text2) => after(call("connector-send", { id, text: text2 })), saveCapability: (capability) => after(call("capability-save", capability)), deleteCapability: (name) => after(call("capability-delete", { name })), approveCapability: (name) => after(call("capability-approve", { name })), @@ -20537,7 +20654,7 @@ function apply(ctx) { usage: () => call("usage", { timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone }), // `summarize` starts a summary when the entries changed; `regenerate` always does. memoryView: (botId, flags = {}) => call("memory-view", { botId, ...flags }), - memoryAsk: (botId, text) => call("memory-ask", { botId, text }), + memoryAsk: (botId, text2) => call("memory-ask", { botId, text: text2 }), memoryForget: (botId, entry) => call("memory-forget", { botId, scope: entry.scope, topic: entry.topic, text: entry.text }), diagnostics: (payload) => call("diagnostics", payload), diagnosticsLog: () => call("diagnostics-log"), @@ -20548,7 +20665,7 @@ function apply(ctx) { ui2.set((state) => ({ ...state, toast: { text: error?.message ?? String(error), token: Date.now() } })); }, // A short confirmation (e.g. "Invite link copied") in the same toast, without the error styling. - notify: (text) => ui2.set((state) => ({ ...state, toast: { text: String(text), tone: "info", token: Date.now() } })), + notify: (text2) => ui2.set((state) => ({ ...state, toast: { text: String(text2), tone: "info", token: Date.now() } })), dismissToast: (token) => ui2.set((state) => state.toast && (token === void 0 || state.toast.token === token) ? { ...state, toast: null } : state) }; if (browsers) { @@ -20766,6 +20883,7 @@ function apply(ctx) { installCompiledStyles(USER_MESSAGE_CSS, "user-messages"), installCompiledStyles(MOBILE_CSS, "mobile"), installCompiledStyles(PANE_MIN_WIDTH_CSS, "panes"), + installCompiledStyles(PROJECT_BOARD_CSS, "project-board"), typeof document !== "undefined" ? watchShellLevel(document) : null, browsers ? installCompiledStyles(BROWSER_CSS, "browser") : null, installTheme(), @@ -20783,7 +20901,8 @@ function apply(ctx) { shadow("shell.overlay", { id: "bot", order: 50, inject: face }, Overlays), shadow("shell.overlay", { id: "bot-signin", order: 55, inject: face }, DeepSeekSignIn), shadow("shell.overlay", { id: "bot-toast", order: 60, inject: face }, ErrorToast), - shadow("shell.overlay", { id: "flybot-approvals", order: 58, inject: face }, FlyBotApprovals) + shadow("shell.overlay", { id: "flybot-approvals", order: 58, inject: face }, FlyBotApprovals), + shadow("shell.overlay", { id: "project-board", order: 57 }, ProjectBoardOverlay) ]; const installConversation = () => [ installCompiledStyles(CONVERSATION_CSS, "conversation"), @@ -20836,7 +20955,7 @@ function apply(ctx) { ]; ctx.effect(() => { const style = installStyles(TURN_FAILURES_CSS, "turn-failures"); - const off = registry().register("flybot.session.dock", { id: "flybot-turn-failures", order: 20, component: (props) => (0, import_react48.createElement)(TurnFailures, { ...props, work }) }); + const off = registry().register("flybot.session.dock", { id: "flybot-turn-failures", order: 20, component: (props) => (0, import_react49.createElement)(TurnFailures, { ...props, work }) }); return () => { off(); style.remove(); diff --git a/core/src/client/index.js b/core/src/client/index.js index 9ecf02230..0e5a7a823 100644 --- a/core/src/client/index.js +++ b/core/src/client/index.js @@ -36,6 +36,7 @@ import { INBOX_CSS } from './inbox.js' import { KANBAN_CSS } from './kanban.js' import { MOBILE_CSS } from './mobile.js' import { PANE_MIN_WIDTH_CSS, watchShellLevel } from './panes.js' +import { PROJECT_BOARD_CSS, ProjectBoardOverlay } from './project-board.js' import { STORAGE, migrateStorage } from './storage.js' import { friendLineNews } from './friend-lines.js' import { groupsFor, readMode, surfaceGroups, writeMode } from './surface.js' @@ -887,6 +888,7 @@ export function apply(ctx) { installCompiledStyles(USER_MESSAGE_CSS, 'user-messages'), installCompiledStyles(MOBILE_CSS, 'mobile'), installCompiledStyles(PANE_MIN_WIDTH_CSS, 'panes'), + installCompiledStyles(PROJECT_BOARD_CSS, 'project-board'), typeof document !== 'undefined' ? watchShellLevel(document) : null, browsers ? installCompiledStyles(BROWSER_CSS, 'browser') : null, installTheme(), @@ -905,6 +907,7 @@ export function apply(ctx) { shadow('shell.overlay', { id: 'bot-signin', order: 55, inject: face }, DeepSeekSignIn), shadow('shell.overlay', { id: 'bot-toast', order: 60, inject: face }, ErrorToast), shadow('shell.overlay', { id: 'flybot-approvals', order: 58, inject: face }, FlyBotApprovals), + shadow('shell.overlay', { id: 'project-board', order: 57 }, ProjectBoardOverlay), ] const installConversation = () => [ installCompiledStyles(CONVERSATION_CSS, 'conversation'), diff --git a/core/src/client/project-board.js b/core/src/client/project-board.js new file mode 100644 index 000000000..192f6fc19 --- /dev/null +++ b/core/src/client/project-board.js @@ -0,0 +1,117 @@ +// The Project board view (#1710): one Project, the rows its read model already aggregated +// (host `project-board`), grouped by where the work lives. The rows are the tasks themselves — +// `row.id` is the underlying task id — so opening one opens that task and nothing is copied here. +import { createElement as h, useCallback, useEffect, useState } from 'react' +import { call } from './api.js' +import { t } from './i18n.js' + +export const PROJECT_BOARD_CSS = ` +.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-row .bt-project-ref{margin-left:auto;font-size:11px;opacity:.6} +.bt-project-empty{padding:22px 4px;opacity:.7} +.bt-project-actions{display:flex;gap:8px;margin:0 0 14px} +` + +// Sources in the order the board reads them; a source with no rows is left out so three empty +// groups never crowd out the one that has work. +export const PROJECT_SOURCES = ['bot', 'group', 'workflow'] +export const SOURCE_LABELS = { bot: 'Bots', group: 'Groups', workflow: 'Workflows' } + +const text = value => (typeof value === 'string' ? value.trim() : '') + +// Pure: no fetch, no React, no store. `rows` is the host's read model, `canManage` comes from +// `project-read` and only decides what the caller may *ask* for — the host refuses regardless. +export function projectBoardView({ project, rows, canManage } = {}) { + const list = Array.isArray(rows) ? rows.filter(row => row !== null && typeof row === 'object' && text(row.id) !== '') : [] + const groups = PROJECT_SOURCES + .map(source => ({ + source, + label: SOURCE_LABELS[source], + rows: list + .filter(row => row.source === source) + .map(row => ({ + 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, + })), + })) + .filter(group => group.rows.length > 0) + return { + projectId: text(project?.id) || null, + title: text(project?.name) || 'Project', + canManage: canManage === true, + groups, + count: list.length, + empty: list.length === 0, + emptyText: 'Nothing under this Project yet.', + } +} + +function ProjectBoardRows({ view, onOpen }) { + // An empty Project is a state, not an error: the view says so and renders no groups. + if (view.empty) return h('div', { className: 'bt-project-empty' }, t(view.emptyText)) + return view.groups.map(group => h('section', { key: group.source, className: 'bt-project-group', 'data-source': group.source }, + h('h4', null, t(group.label)), + group.rows.map(row => h('div', { + key: row.key, + className: 'bt-project-row', + 'data-task-id': row.id, + 'data-column': row.column, + role: 'button', + tabIndex: 0, + onClick: () => onOpen?.(row.id), + onKeyDown: event => { if (event.key === 'Enter' || event.key === ' ') onOpen?.(row.id) }, + }, + h('span', { className: 'bt-project-column' }, t(row.column)), + h('span', { className: 'bt-project-title' }, row.assignee ? `@${row.assignee}` : '—'), + row.runId === null ? null : h('span', { className: 'bt-project-ref' }, row.nodeId === null ? row.runId : `${row.runId} · ${row.nodeId}`))))) +} + +export function ProjectBoardPane({ project, rows, canManage, onOpen }) { + const view = projectBoardView({ project, rows, canManage }) + return h('div', { className: 'bt-project-board', 'data-can-manage': view.canManage ? 'true' : 'false' }, + h('h3', null, view.title), + // Management affordances are drawn only for the owner; the refusal itself is the host's. + view.canManage ? h('div', { className: 'bt-project-actions' }, h('button', { type: 'button' }, t('Project settings')), h('button', { type: 'button' }, t('Members'))) : null, + h(ProjectBoardRows, { view, onOpen })) +} + +// The pane entry point (#1710): `#project=` opens one Project's board, `#project` the first. +// Reading is a POST through the same host channel every other view uses. +export function ProjectBoardOverlay() { + const [state, setState] = useState(null) + const close = useCallback(() => { + if (typeof window !== 'undefined') window.location.hash = '' + setState(null) + }, []) + useEffect(() => { + if (typeof window === 'undefined') return + if (!window.location.hash.startsWith('#project')) return + const wanted = window.location.hash.slice('#project'.length).replace(/^=/, '') + let live = true + void (async () => { + try { + const listing = await call('projects') + const projects = listing?.projects ?? [] + const project = wanted === '' ? projects[0] : projects.find(item => item.id === wanted) + if (project === undefined) { if (live) setState({ project: null, rows: [], canManage: false }); return } + const [read, board] = await Promise.all([call('project-read', { id: project.id }), call('project-board', { id: project.id })]) + if (live) setState({ project: read?.project ?? project, rows: board?.tasks ?? [], canManage: read?.canManage === true }) + } catch { if (live) setState({ project: null, rows: [], canManage: false }) } + })() + return () => { live = false } + }, []) + if (state === null) return null + return h('div', { className: 'bt-overlay bt-project-overlay', role: 'dialog', 'aria-label': t('Project board') }, + h('button', { type: 'button', className: 'bt-project-close', onClick: close }, t('Close')), + state.project === null + ? h('div', { className: 'bt-project-empty' }, t('No Project here.')) + : h(ProjectBoardPane, { project: state.project, rows: state.rows, canManage: state.canManage, onOpen: id => { window.location.hash = `#task=${id}` } })) +} diff --git a/core/src/host/authz.js b/core/src/host/authz.js index ebbc24e5d..41f3dc621 100644 --- a/core/src/host/authz.js +++ b/core/src/host/authz.js @@ -65,7 +65,7 @@ const TASK_ENDPOINTS = new Set([ 'workflow-save', // The Project record and its bindings (#1691) are team state: a signed-in actor may create, // read and bind, like the task channel. - 'projects', 'project-create', 'project-read', 'project-bind', 'project-of', + 'projects', 'project-create', 'project-read', 'project-bind', 'project-of', 'project-board', ]) const deny = (code, message) => ({ ok: false, code, message }) diff --git a/core/src/host/channel.js b/core/src/host/channel.js index dc2e03879..60817a898 100644 --- a/core/src/host/channel.js +++ b/core/src/host/channel.js @@ -673,7 +673,7 @@ export function install(rt) { case 'project-of': case 'project-board': await rt.load() - return ok(await rt.projectEndpoint(endpoint, body)) + return ok(await rt.projectEndpoint(endpoint, body, actor)) case 'share-brief': await rt.load() await rt.setBriefShare(String(body.id ?? ''), String(body.botId ?? ''), body.share === true, actor) diff --git a/core/src/host/store.js b/core/src/host/store.js index 480e9db6c..5b189c1b4 100644 --- a/core/src/host/store.js +++ b/core/src/host/store.js @@ -420,16 +420,42 @@ export function install(rt) { if (project !== null) project.updatedAt = Math.max(project.updatedAt, Date.now()) return project } - const projectEndpoint = async (endpoint, body = {}) => { + // Who may act on a Project (#1710). The record names its owner member; an install with no + // session falls back to the first member, exactly like `requesterOf` in tasks.js. The host + // decides this, never the page: a caller that is not the owner may read and nothing else. + const projectActorId = actor => projectText(actor?.memberId, 120) || Object.keys(state.members ?? {})[0] || null + const projectCanManage = (project, actor) => { + if (project === null || typeof project !== 'object') return false + const owner = projectText(project.ownerUserId, 120) + if (owner === '') return true + const actorId = projectActorId(actor) + return actorId !== null && actorId === owner + } + const requireProjectOwner = (project, actor) => { + if (project === null) return + if (projectCanManage(project, actor)) return + throw projectRefusal('Only the owner of this Project may change it', 'bot/forbidden') + } + + const projectEndpoint = async (endpoint, body = {}, actor = null) => { switch (endpoint) { case 'projects': return { projects: listProjects() } case 'project-create': { - const project = createProject(body) + // The caller is stamped as the owner; a member id from the body cannot mint a + // Project someone else manages. + const project = createProject({ ...body, ownerUserId: projectActorId(actor) ?? undefined }) await save() return { project } } - case 'project-read': return { project: readProject(projectText(body.id, 200)) } + case 'project-read': { + const project = readProject(projectText(body.id, 200)) + return { project, canManage: projectCanManage(project, actor) } + } case 'project-bind': { + const target = body.projectId === undefined || body.projectId === null + ? projectOfMember(projectText(body.kind, 20), projectText(body.id, 200)) + : requireProject(projectText(body.projectId, 200)) + requireProjectOwner(target, actor) const project = bindProject(body) await save() return { project } diff --git a/core/test/project-board-view.test.js b/core/test/project-board-view.test.js new file mode 100644 index 000000000..f42af09b2 --- /dev/null +++ b/core/test/project-board-view.test.js @@ -0,0 +1,107 @@ +// The Project board view (#1710): one view over the read model Stage 2.2 shipped. It groups the +// rows by where the work lives, carries the run/node a workflow row came from, and keeps the row +// id equal to the underlying task id, so opening a row opens that task and no row is a copy. +import assert from 'node:assert/strict' +import { test } from 'node:test' +import { fileURLToPath } from 'node:url' +import { runInNewContext } from 'node:vm' +import { build } from 'esbuild' + +const bundle = await build({ + entryPoints: [fileURLToPath(new URL('../src/client/project-board.js', import.meta.url))], + bundle: true, format: 'cjs', platform: 'node', write: false, + external: ['react', 'react-dom'], +}) +const module = { exports: {} } +runInNewContext(bundle.outputFiles[0].text, { + module, exports: module.exports, setTimeout, clearTimeout, setInterval, clearInterval, Date, Promise, console, + require: (name) => { + const platform = { + react: { + createElement: (type, props, ...children) => ({ type, props: props ?? {}, children: children.flat(Infinity) }), + useCallback: fn => fn, + useEffect: () => {}, + useState: initial => [initial, () => {}], + }, + } + if (!(name in platform)) throw new Error(`no platform module ${name}`) + return platform[name] + }, +}) +const { ProjectBoardPane, projectBoardView } = module.exports + +const rows = [ + { id: 'task-b1', source: 'bot', column: 'in-progress', assignee: 'Dev4', updatedAt: 3 }, + { id: 'task-g1', source: 'group', column: 'done', assignee: 'Chief', updatedAt: 2 }, + { id: 'task-w1', source: 'workflow', column: 'not-started', assignee: null, runId: 'run-7', nodeId: 'build', updatedAt: 1 }, +] + +const all = (node, out = []) => { + if (Array.isArray(node)) { for (const item of node) all(item, out); return out } + if (node === null || typeof node !== 'object') return out + // The fake createElement does not render components; the test asks them for their tree. + if (typeof node.type === 'function') return all(node.type(node.props), out) + out.push(node) + all(node.children, out) + return out +} +// Arrays minted inside the vm have another realm's prototype, so compare plain copies. +const plain = value => JSON.parse(JSON.stringify(value)) +const textOf = node => (typeof node === 'string' ? node : node && typeof node === 'object' ? (node.children ?? []).map(textOf).join('') : '') + +test('the board groups rows from all three sources and keeps each task id', () => { + const view = plain(projectBoardView({ project: { id: 'project-1', name: 'FlyBot' }, rows, canManage: true })) + assert.equal(view.projectId, 'project-1') + assert.equal(view.title, 'FlyBot') + assert.equal(view.canManage, true) + assert.equal(view.empty, false) + assert.equal(view.count, 3) + assert.deepEqual(view.groups.map(group => group.source), ['bot', 'group', 'workflow']) + assert.deepEqual(view.groups.map(group => group.rows.length), [1, 1, 1]) + const ids = view.groups.flatMap(group => group.rows.map(row => row.id)) + assert.deepEqual(ids, ['task-b1', 'task-g1', 'task-w1'], 'the row id is the task id') + assert.deepEqual(view.groups.flatMap(group => group.rows.map(row => row.key)), ['task:task-b1', 'task:task-g1', 'task:task-w1']) +}) + +test('a workflow row carries its run and node; other rows carry none', () => { + const view = plain(projectBoardView({ project: { id: 'p', name: 'P' }, rows, canManage: false })) + const [botRow] = view.groups[0].rows + const workflowRow = view.groups[2].rows[0] + assert.equal(botRow.runId, null) + assert.equal(botRow.nodeId, null) + assert.equal(workflowRow.runId, 'run-7') + assert.equal(workflowRow.nodeId, 'build') + assert.equal(workflowRow.assignee, null) + assert.equal(botRow.column, 'in-progress') +}) + +test('a Project with nothing under it is an empty state, not an error', () => { + const view = plain(projectBoardView({ project: { id: 'p', name: 'P' }, rows: [], canManage: false })) + assert.equal(view.empty, true) + assert.deepEqual(view.groups, []) + assert.equal(view.count, 0) + const missing = plain(projectBoardView({})) + assert.equal(missing.empty, true) + assert.equal(missing.projectId, null) + assert.equal(missing.canManage, false, 'no canManage answer is read as read-only') +}) + +test('the pane draws the grouped rows and only the owner sees the management controls', () => { + const owner = all(ProjectBoardPane({ project: { id: 'p', name: 'FlyBot' }, rows, canManage: true })) + const member = all(ProjectBoardPane({ project: { id: 'p', name: 'FlyBot' }, rows, canManage: false })) + assert.match(textOf(owner[0]), /FlyBot/) + const ownerRows = owner.filter(node => node.props?.['data-task-id'] !== undefined) + assert.deepEqual(ownerRows.map(node => node.props['data-task-id']), ['task-b1', 'task-g1', 'task-w1']) + const workflow = ownerRows[2] + assert.match(textOf(workflow), /run-7 · build/) + assert.equal(owner.some(node => textOf(node) === 'Project settings'), true) + const memberRows = member.filter(node => node.props?.['data-task-id'] !== undefined) + assert.deepEqual(memberRows.map(node => node.props['data-task-id']), ['task-b1', 'task-g1', 'task-w1'], 'a member still reads the board') + assert.equal(member.some(node => textOf(node) === 'Project settings'), false) +}) + +test('an empty Project renders the empty text and no row', () => { + const nodes = all(ProjectBoardPane({ project: { id: 'p', name: 'P' }, rows: [], canManage: false })) + assert.equal(nodes.filter(node => node.props?.['data-task-id'] !== undefined).length, 0) + assert.match(nodes.map(textOf).join(' '), /Nothing under this Project yet/) +}) diff --git a/core/test/projects.test.js b/core/test/projects.test.js index d01f89baf..cdd88ddf0 100644 --- a/core/test/projects.test.js +++ b/core/test/projects.test.js @@ -5,15 +5,31 @@ import assert from 'node:assert/strict' import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' +import { createHash } from 'node:crypto' import { test } from 'node:test' import { apply } from '../index.js' import { fakeContext } from './fake-ctx.js' -const seed = () => ({ +const hashToken = token => createHash('sha256').update(token).digest('hex') + +// #1710: a second member and two signed-in sessions, so an owner and a non-owner caller both +// exist without a second process. +const seed = (sessions = true) => ({ version: 2, revision: 1, mainBotIds: ['a'], - members: { 'owner-1': { id: 'owner-1', createdAt: 1, designatedChiefId: 'a' } }, + members: { + 'owner-1': { id: 'owner-1', createdAt: 1, designatedChiefId: 'a' }, + 'owner-2': { id: 'owner-2', createdAt: 2, designatedChiefId: null }, + }, + ...(sessions + ? { + authSessions: { + 's-a': { id: 's-a', memberId: 'owner-1', tokenHash: hashToken('tok-a'), createdAt: 1 }, + 's-b': { id: 's-b', memberId: 'owner-2', tokenHash: hashToken('tok-b'), createdAt: 2 }, + }, + } + : {}), bots: { a: { id: 'a', name: 'Chief', role: 'Boss', sessionId: 'a', createdAt: 1, ownerId: 'owner-1' }, b: { id: 'b', name: 'Writer', role: 'Write', sessionId: 'b', createdAt: 2, ownerId: 'owner-1' }, @@ -22,8 +38,8 @@ const seed = () => ({ agents: {}, exchanges: [], exchangeTurns: {}, questions: {}, prefs: {}, grants: {}, }) -async function start(home = mkdtempSync(join(tmpdir(), 'ds-bot-projects-'))) { - if (!existsSync(join(home, 'state.json'))) writeFileSync(join(home, 'state.json'), JSON.stringify(seed())) +async function start(home = mkdtempSync(join(tmpdir(), 'ds-bot-projects-')), { sessions = true } = {}) { + if (!existsSync(join(home, 'state.json'))) writeFileSync(join(home, 'state.json'), JSON.stringify(seed(sessions))) const ctx = fakeContext() const agents = new Map() const agentOf = (sessionId) => { @@ -41,7 +57,11 @@ async function start(home = mkdtempSync(join(tmpdir(), 'ds-bot-projects-'))) { const register = ctx.connection.fetch.register ctx.connection.fetch.register = (options) => { route = options; return register(options) } apply(ctx, { home }) - const call = async (endpoint, payload) => (await route.fetch(new Request('http://localhost/api/bot', { method: 'POST', body: JSON.stringify({ endpoint, payload }) }))).json() + const call = async (endpoint, payload, token = 'tok-a') => (await route.fetch(new Request('http://localhost/api/bot', { + method: 'POST', + headers: token ? { 'x-flybot-member-session': token } : {}, + body: JSON.stringify({ endpoint, payload }), + }))).json() const stop = async ({ keepHome = false } = {}) => { await ctx.dispose() if (!keepHome) rmSync(home, { recursive: true, force: true, maxRetries: 5 }) @@ -143,3 +163,42 @@ test('the record and its bindings survive a store re-open', async () => { rmSync(home, { recursive: true, force: true, maxRetries: 5 }) } }) + +// #1710: the owner of a Project may manage it, a member reads the same board read-only, and the +// refusal comes from the host action — not from a control the page happens to hide. +test('the caller becomes the owner, and only the owner may change the Project', async () => { + const { call, stop } = await start() + try { + const project = created(await call('project-create', { name: 'Owned', repository: 'acme/owned', ownerUserId: 'owner-2' }, 'tok-a')) + assert.equal(project.ownerUserId, 'owner-1', 'the caller is stamped as the owner, not the body') + + assert.equal(value(await call('project-read', { id: project.id }, 'tok-a')).canManage, true) + assert.equal(value(await call('project-read', { id: project.id }, 'tok-b')).canManage, false) + + const refused = await call('project-bind', { kind: 'bot', id: 'a', projectId: project.id }, 'tok-b') + assert.equal(refused.ok, false) + assert.equal(refused.error.code, 'bot/forbidden') + assert.equal(value(await call('project-of', { kind: 'bot', id: 'a' })).project, null, 'the refusal wrote nothing') + + assert.equal(created(await call('project-bind', { kind: 'bot', id: 'a', projectId: project.id }, 'tok-a')).id, project.id) + } finally { await stop() } +}) + +test('an install with no signed-in session falls back to its first member as the owner', async () => { + const { call, stop } = await start(undefined, { sessions: false }) + try { + const project = created(await call('project-create', { name: 'Solo', repository: 'acme/solo' })) + assert.equal(project.ownerUserId, 'owner-1', 'the single-member install stamps its member') + assert.equal(value(await call('project-read', { id: project.id })).canManage, true) + } finally { await stop() } +}) + +test('a signed-in member may read the Project board even though it may not manage it', async () => { + const { call, stop } = await start() + try { + const project = created(await call('project-create', { name: 'Board', repository: 'acme/board' }, 'tok-a')) + const board = await call('project-board', { id: project.id }, 'tok-b') + assert.equal(board.ok, true, JSON.stringify(board), 'project-board is a known endpoint once someone signed in') + assert.deepEqual(value(board).tasks, []) + } finally { await stop() } +}) diff --git a/docs/subsystems/core-client.md b/docs/subsystems/core-client.md index 6337b6c15..5a9408d40 100644 --- a/docs/subsystems/core-client.md +++ b/docs/subsystems/core-client.md @@ -266,3 +266,14 @@ A discarded `.catch(() => {})` in the client sources either starts with `void` ( - A friend's line in my Bot's own chat (#420): `friend-lines.js` `friendLineNews` compares each Bot's host stamp `friendLineAt` with the last stamp this browser has shown (`localStorage` `ds-bot:friend-lines`; stamps against stamps, never this browser's clock) and, when a newer one arrives while that chat is not visited, `index.js` marks the Bot unread the way **Mark as Unread** does, so the row shows the blue dot until the chat is opened. A browser with no record yet takes what is there as shown; another member's shared Bot and a friend's Bot chat are skipped. The Main Bot tile shows the same dot (`.bt-tile-unread`; a wait still shows on its avatar). A line whose source has `remote.firstContact` opens with one muted `FirstContact` line (`cells.js`): ` is talking to through Bot Social`. Covered by `core/test/friend-lines.test.js` and `core/test/inbound-ui.test.js`. - The roster poll (`index.js` `pull`) sends `watching: !document.hidden` with each `state` call (#497), so the host knows a window is on screen and the social plugin reads friends' inboxes at its live cadence: a friend's first line, its held reply's card, the Inbox count and the unread badge arrive in that window within seconds, with no reload. - The **NEW** divider (`cells.js` `TurnTailCell` / `TurnProcessCell` / the unread opening) compares turn times with the visit's `seen` (when the chat was last left) and `entered` (when this visit opened). Once the divider has been on screen (`NewMessagePills` in `header.js`, `view.divider === 'in-view'`) and a shown turn begins after the visit opened (`movedOnSince`: a friend's next line, the user's own message; hidden exchange turns do not count), `actions.settleVisit` sets `seen` to `entered` (`settledVisit`) and every divider of the visit goes, as on leaving and coming back. A divider still scrolled out of view, or a chat where nothing new happened, keeps it until the visit ends. Same rule in every chat, including a friend's typed line in the Bot's own chat (#275). Covered by `core/test/turn-tail.test.js`. + +### Project board pane (#1710) + +`src/client/project-board.js` draws one Project over the read model the host already aggregates +(`project-board`). `projectBoardView({ project, rows, canManage })` is pure: it groups the rows by +`source` (`bot` | `group` | `workflow`, empty sources left out), carries `column`, `assignee` and the +`runId`/`nodeId` a workflow row came from, and keeps `row.id` equal to the underlying task id, so +opening a row opens that task and the view never copies a row. An empty Project renders the empty +state, not an error. `canManage` comes from `project-read` and only decides whether the management +affordances are drawn; the refusal itself is the host's. The pane is registered on the existing +`shell.overlay` surface and opens at `#project=`. diff --git a/docs/subsystems/core-host.md b/docs/subsystems/core-host.md index a2d9de5c3..d577861f1 100644 --- a/docs/subsystems/core-host.md +++ b/docs/subsystems/core-host.md @@ -559,3 +559,16 @@ A discarded `.catch(() => {})` in the host sources either starts with `void` (fi - That placeholder names the file when the Bot's own file tools can open it: `attachmentFilePath` takes the attachment store's `imageHostPath` through the file sandbox's `processPathFromHostPath` (the same mapping `dsh-spill-policy` applies to spooled tool output) and falls back to the local object layout `/attachments/v1/objects//`. Either candidate must `stat` as a regular file, so a Bot is never sent after a file that is not there (a host path the sandbox cannot open is worse than no path), and only a `sha256:<64 hex>` id has an object path at all. - Nothing is copied into a Bot's working folder: a copy would need a lifecycle and a cleanup policy of its own, and the readable path already lets a Bot move or submit the bytes itself. - Covered by `core/test/images.test.js` and, through the Session hook and the group relay, `core/test/session-identity.test.js`. + +### The Project board and who may act on it (#1710) + +`projectEndpoint` takes the authenticated actor. `project-create` stamps the caller's member id as +`ownerUserId` (an `ownerUserId` in the body cannot mint a Project someone else manages); an install +with no signed-in session falls back to the first member, the same rule `tasks.js` uses for +`requesterOf`. `project-read` answers `{ project, canManage }`, where `canManage` is true only for +the owner — or for every caller on a Project whose record names no owner. `project-bind` is refused +by the host with `bot/forbidden` unless the caller may manage the Project the binding names (or the +one it is leaving, when unbinding); the refusal writes nothing, so hiding a button is never the +gate. Reading the board (`project-board`) stays open to a non-owner: the same rows, read-only. +`project-board` is listed in `authz.js`'s `TASK_ENDPOINTS`, so a signed-in deployment does not +refuse it as an unknown endpoint. diff --git a/patches/ds-bot/0017-project-board-gating.patch b/patches/ds-bot/0017-project-board-gating.patch new file mode 100644 index 000000000..7d755b15d --- /dev/null +++ b/patches/ds-bot/0017-project-board-gating.patch @@ -0,0 +1,1956 @@ +FlyBot patch 0017-project-board-gating on Fei2411/DeepSeek-Bot@2b10c70 (ds-bot/). Generated by scripts/ds-bot-patches.mjs; edits to upstream files only (files FlyBot adds are in files/). + +diff --git a/client.js b/client.js +--- a/client.js ++++ b/client.js +@@ -1438,7 +1438,7 @@ + "saved by a newer FlyBot; update FlyBot": "由更新的 FlyBot 保存;请更新 FlyBot" + }; + Object.assign(ZH, HOST_ZH); +-var fill = (text, params) => params ? text.replace(/\{(\w+)\}/g, (match, name) => name in params ? String(params[name]) : match) : text; ++var fill = (text2, params) => params ? text2.replace(/\{(\w+)\}/g, (match, name) => name in params ? String(params[name]) : match) : text2; + var service = null; + var bound = null; + function installLocale(locale) { +@@ -1489,23 +1489,23 @@ + } + const message = String(error?.message ?? error ?? "error").slice(0, 300); + const code = typeof error?.code === "string" ? error.code : void 0; +- const text = `${where}: ${message}`; ++ const text2 = `${where}: ${message}`; + const now = Date.now(); + const last = entries.at(-1); +- if (last && last.text === text && now - last.time < REPEAT_MS) { ++ if (last && last.text === text2 && now - last.time < REPEAT_MS) { + last.time = now; + last.count = (last.count ?? 1) + 1; + return; + } +- entries.push({ time: now, level: "error", text, ...code ? { code } : {} }); ++ entries.push({ time: now, level: "error", text: text2, ...code ? { code } : {} }); + if (entries.length > MAX) entries.splice(0, entries.length - MAX); + } + function noteNavigation(how, target, from) { + if (typeof navigator === "undefined" || navigator.userActivation?.isActive !== false) return; + const stack = String(new Error().stack ?? "").split("\n").slice(2, 6).map((line2) => line2.trim().replace(/\(?https?:\/\/[^/\s)]+/, "(")).join(" < "); +- const text = `navigation: ${how} to ${String(target).slice(0, 60)}${from ? ` from ${String(from).slice(0, 60)}` : ""} with no click (${stack.slice(0, 300)})`; +- console.warn(`[flybot] ${text}`); +- entries.push({ time: Date.now(), level: "warn", text }); ++ const text2 = `navigation: ${how} to ${String(target).slice(0, 60)}${from ? ` from ${String(from).slice(0, 60)}` : ""} with no click (${stack.slice(0, 300)})`; ++ console.warn(`[flybot] ${text2}`); ++ entries.push({ time: Date.now(), level: "warn", text: text2 }); + if (entries.length > MAX) entries.splice(0, entries.length - MAX); + } + var quietly = (where) => (error) => noteClientError(where, error); +@@ -1546,9 +1546,9 @@ + var PASTE_NOTE = "FlyBot copied the diagnostics to the clipboard. Paste them here."; + var REST_NOTE = "The recent errors do not fit in the link. FlyBot copied the diagnostics to the clipboard: paste them below this line."; + function issueUrl(base, { what = "", diagnostics }) { +- const text = what.trim().slice(0, WHAT_MAX); +- const title = text.split("\n")[0].slice(0, 80); +- const build = (body) => `${base}?${new URLSearchParams({ template: "bug.yml", ...title ? { title } : {}, ...text ? { what: text } : {}, diagnostics: body })}`; ++ const text2 = what.trim().slice(0, WHAT_MAX); ++ const title = text2.split("\n")[0].slice(0, 80); ++ const build = (body) => `${base}?${new URLSearchParams({ template: "bug.yml", ...title ? { title } : {}, ...text2 ? { what: text2 } : {}, diagnostics: body })}`; + const full2 = build(diagnostics); + if (full2.length <= URL_LIMIT) return { url: full2, inline: true }; + const facts = build(`${diagnostics.split("\n\n")[0]} +@@ -1602,8 +1602,8 @@ + + // src/shared/remote-label.js + function cleanPeerName(name, fallback = "") { +- const text = String(name ?? "").replace(/[\u0000-\u001f\u007f-\u009f\u2028\u2029]/g, " ").replace(/[[\]"`<>]/g, "").replace(/\s+/g, " ").trim().slice(0, 60); +- return text || fallback; ++ const text2 = String(name ?? "").replace(/[\u0000-\u001f\u007f-\u009f\u2028\u2029]/g, " ").replace(/[[\]"`<>]/g, "").replace(/\s+/g, " ").trim().slice(0, 60); ++ return text2 || fallback; + } + function normRemoteHandle(handle) { + return `@${String(handle ?? "").trim().replace(/^@+/, "").toLowerCase()}`; +@@ -1708,10 +1708,10 @@ + const handle = String(record?.handle ?? "").replace(/^@+/, "").trim(); + const bare2 = remoteBotName(record, "").replace(/^@+/, "").trim(); + const unusable = (value) => { +- const text = String(value ?? "").replace(/^@+/, "").trim(); +- if (!text || /^friend$/i.test(text)) return true; +- if (text.includes("@") || /\.home\b/i.test(text)) return true; +- if (handle && text.toLowerCase() === handle.toLowerCase()) return true; ++ const text2 = String(value ?? "").replace(/^@+/, "").trim(); ++ if (!text2 || /^friend$/i.test(text2)) return true; ++ if (text2.includes("@") || /\.home\b/i.test(text2)) return true; ++ if (handle && text2.toLowerCase() === handle.toLowerCase()) return true; + return false; + }; + if (!unusable(bare2)) return bare2; +@@ -1772,11 +1772,11 @@ + const friends = [...new Set((Array.isArray(details.remotes) ? details.remotes : []).map((remote) => cleanPeerName(remote?.owner || remote?.name, "")).filter(Boolean))]; + return { bots, friends }; + } +-async function copiedToClipboard(text, nav = globalThis.navigator) { ++async function copiedToClipboard(text2, nav = globalThis.navigator) { + const write = nav?.clipboard?.writeText; + if (typeof write !== "function") return false; + try { +- await write.call(nav.clipboard, text); ++ await write.call(nav.clipboard, text2); + return true; + } catch { + return false; +@@ -1924,8 +1924,8 @@ + } + return [...homes.values()]; + } +-function remoteMessageText(text) { +- const value = String(text ?? ""); ++function remoteMessageText(text2) { ++ const value = String(text2 ?? ""); + const match = value.match(/\n?([\s\S]*?)\n?<\/untrusted-message>/); + return match ? match[1] : value; + } +@@ -2045,9 +2045,9 @@ + var CHARACTER_COLORS = ["black", "brown", "red", "orange", "yellow", "green", "cyan", "blue", "violet", "magenta", "gray", "aurora"]; + var DEFAULT_COLORS = CHARACTER_COLORS.filter((color) => !["black", "gray", "brown"].includes(color) && !GRADIENTS[color]); + var isColor = (color) => isHex(color) || Object.hasOwn(INKS, color); +-function fnv1a(text) { ++function fnv1a(text2) { + let value = 2166136261; +- for (let index = 0; index < text.length; index += 1) value = Math.imul(value ^ text.charCodeAt(index), 16777619); ++ for (let index = 0; index < text2.length; index += 1) value = Math.imul(value ^ text2.charCodeAt(index), 16777619); + return value >>> 0; + } + function defaultColor(id) { +@@ -2168,8 +2168,8 @@ + return ""; + } + } +-var eyeMarkup = ([cx, cy, w, h48, tilt]) => ``; +-var happyEyeMarkup = ([cx, cy, w, h48, tilt]) => ``; ++var eyeMarkup = ([cx, cy, w, h49, tilt]) => ``; ++var happyEyeMarkup = ([cx, cy, w, h49, tilt]) => ``; + var maskBox = (size) => `maskUnits="userSpaceOnUse" x="${-size}" y="${-size}" width="${size * 3}" height="${size * 3}"`; + var holes = (def, eyes) => `${eyes}`; + var MASK_ID = "%MASK%"; +@@ -2220,30 +2220,30 @@ + var NAME_GOES_ON = /^(?:[a-z0-9_\-/\\]|\.[a-z0-9_-])/i; + var ADDRESS_BEFORE = /[a-z0-9_./]/i; + var LITERAL = /```[\s\S]*?(?:```|$)|`[^`\n]*`|\[[^\]\n]*\]\([^)\s]*(?:\s+(?:"[^"\n]*"|'[^'\n]*'))?\)/g; +-var fitsAt = (text, at, word) => text.slice(at, at + word.length).toLowerCase() === word.toLowerCase() && !NAME_GOES_ON.test(text.slice(at + word.length)); ++var fitsAt = (text2, at, word) => text2.slice(at, at + word.length).toLowerCase() === word.toLowerCase() && !NAME_GOES_ON.test(text2.slice(at + word.length)); + var overlaps = (spans, start, end) => spans.some((span) => start < span.end && span.start < end); + function nameProblem(name) { +- const text = String(name ?? "").trim(); +- if (text === "") return "A Bot needs a name"; +- if (/[@`\r\n]/.test(text)) return "A Bot name cannot contain @, ` or a line break"; +- if (ALL_WORDS.includes(text.toLowerCase())) return `"${text}" calls everyone in a group, so it cannot be a Bot name`; ++ const text2 = String(name ?? "").trim(); ++ if (text2 === "") return "A Bot needs a name"; ++ if (/[@`\r\n]/.test(text2)) return "A Bot name cannot contain @, ` or a line break"; ++ if (ALL_WORDS.includes(text2.toLowerCase())) return `"${text2}" calls everyone in a group, so it cannot be a Bot name`; + return void 0; + } +-function referenceSpans(text, labels = []) { ++function referenceSpans(text2, labels = []) { + const spans = []; + for (const label of [...labels].sort((a, b) => b.length - a.length)) { + const mention = `@${label}`; +- for (let at = text.indexOf(mention); at !== -1; at = text.indexOf(mention, at + 1)) { ++ for (let at = text2.indexOf(mention); at !== -1; at = text2.indexOf(mention, at + 1)) { + const end = at + mention.length; +- if (at > 0 && ADDRESS_BEFORE.test(text[at - 1]) || NAME_GOES_ON.test(text.slice(end)) || overlaps(spans, at, end)) continue; ++ if (at > 0 && ADDRESS_BEFORE.test(text2[at - 1]) || NAME_GOES_ON.test(text2.slice(end)) || overlaps(spans, at, end)) continue; + spans.push({ start: at, end, label }); + break; + } + } + return spans.sort((a, b) => a.start - b.start); + } +-function mentionSpans(text, bots, { references = [] } = {}) { +- const value = String(text ?? ""); ++function mentionSpans(text2, bots, { references = [] } = {}) { ++ const value = String(text2 ?? ""); + const skip = [ + ...[...value.matchAll(LITERAL)].map((match) => ({ start: match.index, end: match.index + match[0].length })), + ...referenceSpans(value, references) +@@ -2261,8 +2261,8 @@ + } + return spans; + } +-function unmatchedMentions(text, bots, { references = [] } = {}) { +- const value = String(text ?? ""); ++function unmatchedMentions(text2, bots, { references = [] } = {}) { ++ const value = String(text2 ?? ""); + const skip = [ + ...[...value.matchAll(LITERAL)].map((match) => ({ start: match.index, end: match.index + match[0].length })), + ...referenceSpans(value, references) +@@ -2288,16 +2288,16 @@ + var FENCE_ATTR = /\bfrom\s*=\s*(?:"[^"]*"|'[^']*')\s*>/gi; + var FENCE_TAIL = /<[^<>]*\buntrusted-(?:message|file)\b[^<>]*$/i; + var FENCE_BARE = /(?:\/\s*)?untrusted-(?:message|file)\b\s*>?/gi; +-function stripFence(text) { +- let out = String(text ?? ""); ++function stripFence(text2) { ++ let out = String(text2 ?? ""); + for (let previous; previous !== out; ) { + previous = out; + out = out.replace(FENCE_TAG, "").replace(FENCE_ATTR, ""); + } + return out.replace(FENCE_TAIL, "").replace(FENCE_BARE, ""); + } +-function visibleText(text) { +- let out = String(text ?? ""); ++function visibleText(text2) { ++ let out = String(text2 ?? ""); + for (let previous; previous !== out; ) { + previous = out; + const open = out.indexOf(" Number(Boolean(a?.remote)) - Number(Boolean(b?.remote))); + let out = ""; + let cursor = 0; +- for (const span of mentionSpans(text, ordered)) { ++ for (const span of mentionSpans(text2, ordered)) { + if (!span.bot || span.bot.id === selfId) continue; +- out += `${text.slice(cursor, span.start)}[${text.slice(span.start + 1, span.end).replace(/[[\]\\]/g, "\\$&")}](${MENTION_ORIGIN}${span.bot.id})`; ++ out += `${text2.slice(cursor, span.start)}[${text2.slice(span.start + 1, span.end).replace(/[[\]\\]/g, "\\$&")}](${MENTION_ORIGIN}${span.bot.id})`; + cursor = span.end; + } +- return cursor === 0 ? text : out + text.slice(cursor); ++ return cursor === 0 ? text2 : out + text2.slice(cursor); + } + function mentionTarget(event) { + const anchor = event.target instanceof Element ? event.target.closest(`a[href^="${MENTION_ORIGIN}"]`) : null; + return anchor ? anchor.getAttribute("href").slice(MENTION_ORIGIN.length) : null; + } +-function nameMentions(text, roster) { +- const value = String(text ?? ""); ++function nameMentions(text2, roster) { ++ const value = String(text2 ?? ""); + if (!value.includes("@") || roster === null || typeof roster !== "object") return value; + const records = [...Array.isArray(roster.remoteBots) ? roster.remoteBots : [], ...Array.isArray(roster.bots) ? roster.bots : []]; + const names = /* @__PURE__ */ new Map(); +@@ -2370,9 +2370,9 @@ + const main = roster.byId[mainId]; + return main ? currentPart(main) : mainId; + } +-function reminderPrompts(text) { +- const one = /^reminder_prompt_json: (.*)$/m.exec(text); +- const batch = /^reminders_json: (.*)$/m.exec(text); ++function reminderPrompts(text2) { ++ const one = /^reminder_prompt_json: (.*)$/m.exec(text2); ++ const batch = /^reminders_json: (.*)$/m.exec(text2); + try { + const prompts = [...one ? [JSON.parse(one[1])] : [], ...batch ? JSON.parse(batch[1]).map((entry) => entry.reminder_prompt) : []]; + return prompts.filter((prompt) => typeof prompt === "string" && prompt.trim() !== ""); +@@ -2380,13 +2380,13 @@ + return []; + } + } +-var stripHeader = (text) => text.replace(HEADER, ""); ++var stripHeader = (text2) => text2.replace(HEADER, ""); + var TRACE = /^[ \t]*[^\n→]{1,60}(?:[ \t]*→[ \t]*[^\n→]{1,60})+[ \t]*\n+/; +-function stripTrace(text) { +- if (typeof text !== "string") return text; +- const line2 = text.match(TRACE)?.[0]; +- if (!line2 || /[.!?。!?::,,]/.test(line2.replace(/[^\S\n]*\n+$/, "").replace(/→/g, ""))) return text; +- return text.slice(line2.length).replace(/^\s+/, ""); ++function stripTrace(text2) { ++ if (typeof text2 !== "string") return text2; ++ const line2 = text2.match(TRACE)?.[0]; ++ if (!line2 || /[.!?。!?::,,]/.test(line2.replace(/[^\S\n]*\n+$/, "").replace(/→/g, ""))) return text2; ++ return text2.slice(line2.length).replace(/^\s+/, ""); + } + var COPY_FENCE = /untrusted-(?:message|file)\b/i; + var escapeName = (name) => name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +@@ -2396,8 +2396,8 @@ + const hop = `(?:${known.map(escapeName).join("|")})`; + return new RegExp(`^[ \\t]*${hop}(?:[ \\t]*→[ \\t]*${hop})+[ \\t]*$`).test(line2); + }; +-function stripFlatTrace(text, names = []) { +- const body = String(text ?? ""); ++function stripFlatTrace(text2, names = []) { ++ const body = String(text2 ?? ""); + const known = sortedNames(names); + if (known.length === 0) return body; + const hop = `(?:${known.map(escapeName).join("|")})`; +@@ -2405,8 +2405,8 @@ + const found = trace.exec(body); + return found ? body.slice(found[0].length) : body; + } +-function copyBodyOf(text, names = []) { +- let body = String(text ?? ""); ++function copyBodyOf(text2, names = []) { ++ let body = String(text2 ?? ""); + for (let previous; previous !== body; ) { + previous = body; + body = stripHeader(body); +@@ -2458,14 +2458,14 @@ + const at = (id) => news?.[id]?.seen ? news[id].at ?? 0 : 0; + return ids.reduce((best, id) => at(id) > at(best) ? id : best, ids[0] ?? null); + } +-function summaryBlocks(text) { ++function summaryBlocks(text2) { + const blocks = []; + let paragraph = []; + const flush = () => { + if (paragraph.length > 0) blocks.push({ kind: "p", text: paragraph.join(" ") }); + paragraph = []; + }; +- for (const raw of String(text ?? "").split("\n")) { ++ for (const raw of String(text2 ?? "").split("\n")) { + const line2 = raw.trim().replace(/\*\*|__/g, ""); + const heading = /^#{1,6}\s+(.+)$/.exec(line2); + if (heading) { +@@ -2580,17 +2580,17 @@ + for (let index = outline.length - 1; index >= 0; index -= 1) { + const entry = outline[index]; + if (hiddenTurns.includes(entry.turn)) continue; +- const text = (unsentTurns.includes(entry.turn) ? "" : entry.response) || entry.prompt || ""; +- if (text === "") continue; +- return readableLine(text, names); ++ const text2 = (unsentTurns.includes(entry.turn) ? "" : entry.response) || entry.prompt || ""; ++ if (text2 === "") continue; ++ return readableLine(text2, names); + } + return ""; + } +-function readableLine(text, names = []) { +- return dropKnownTrace(collapsedLine(text), names); ++function readableLine(text2, names = []) { ++ return dropKnownTrace(collapsedLine(text2), names); + } +-function collapsedLine(text) { +- return visibleText(stripTrace(stripHeader(`${text}`)).replace(/\s+/g, " ").trim()); ++function collapsedLine(text2) { ++ return visibleText(stripTrace(stripHeader(`${text2}`)).replace(/\s+/g, " ").trim()); + } + function dropKnownTrace(line2, names) { + const known = [...new Set((Array.isArray(names) ? names : []).map((name) => String(name ?? "").trim()).filter((name) => name !== ""))].sort((a, b) => b.length - a.length); +@@ -2604,8 +2604,8 @@ + return /^[\s,,.。::]*$/.test(rest) ? "" : rest; + } + var botNames = (roster) => [...roster?.bots ?? [], ...roster?.remoteBots ?? [], ...roster?.agents ?? []].map((entry) => entry?.name).filter((name) => typeof name === "string" && name !== ""); +-function speechOf(text, names = []) { +- return readableLine(text, names).replace(/```[\s\S]*?```/g, " ").replace(/\[([^\]]*)\]\([^)]*\)/g, "$1").replace(/[*_`#>|~-]+/g, " ").replace(/\s+/g, " ").trim(); ++function speechOf(text2, names = []) { ++ return readableLine(text2, names).replace(/```[\s\S]*?```/g, " ").replace(/\[([^\]]*)\]\([^)]*\)/g, "$1").replace(/[*_`#>|~-]+/g, " ").replace(/\s+/g, " ").trim(); + } + var formatTokens = (count) => count >= 1e6 ? `${(count / 1e6).toFixed(1)}M` : count >= 1e3 ? `${(count / 1e3).toFixed(1)}K` : String(count); + function usageOf(list, ids) { +@@ -2624,10 +2624,10 @@ + return t("{tokens} tok · Cache hit {hit}%", { tokens: formatTokens(input + output), hit }); + } + function faceRowTitle(title) { +- const text = String(title ?? ""); +- const named = text.match(/^Face-to-face · (.+) · (\d{6})$/); ++ const text2 = String(title ?? ""); ++ const named = text2.match(/^Face-to-face · (.+) · (\d{6})$/); + if (named) return `${named[2]} · ${named[1]}`; +- return text.match(/^Face-to-face · (\d{6})$/)?.[1] ?? text; ++ return text2.match(/^Face-to-face · (\d{6})$/)?.[1] ?? text2; + } + function modelOf(bot, roster) { + const ref = bot.model ?? bot.appliedModel; +@@ -2941,8 +2941,8 @@ + + // src/client/room-invite-preview.js + var INVITE_PREVIEW_NAME_MAX = 60; +-function stripInviteMarkers(text) { +- let out = String(text ?? ""); ++function stripInviteMarkers(text2) { ++ let out = String(text2 ?? ""); + for (let previous; previous !== out; ) { + previous = out; + out = out.replace(/<\/?\s*untrusted-(?:file|message)/gi, ""); +@@ -2955,9 +2955,9 @@ + function inviteMemberCount(value) { + if (typeof value === "number") return Number.isFinite(value) ? value : NaN; + if (typeof value !== "string") return NaN; +- const text = value.trim(); +- if (!text) return NaN; +- const count = Number(text); ++ const text2 = value.trim(); ++ if (!text2) return NaN; ++ const count = Number(text2); + return Number.isFinite(count) ? count : NaN; + } + function invitePreviewOf(seen) { +@@ -2979,29 +2979,29 @@ + const total = Math.max(1, Math.ceil(left / 1e3)); + return `${Math.floor(total / 60)}:${String(total % 60).padStart(2, "0")} left to join`; + } +-function RoomInvitePreview({ h: h48, preview, kind = "face-link-preview", busy = false, joinLabel = "Join", clearLabel = "Clear", showClear = true, onJoin, onClear, now } = {}) { +- if (!preview || typeof h48 !== "function") return null; ++function RoomInvitePreview({ h: h49, preview, kind = "face-link-preview", busy = false, joinLabel = "Join", clearLabel = "Clear", showClear = true, onJoin, onClear, now } = {}) { ++ if (!preview || typeof h49 !== "function") return null; + const at = `${kind}`; + const child = (name) => `${at}-${name}`; + const countdown = now === void 0 ? inviteCountdown(preview.expiresAt, preview.clockOffsetMs) : inviteCountdownAt(preview.expiresAt, preview.clockOffsetMs, now); +- return h48( ++ return h49( + "div", + { className: "fs-preview", "data-flybot": at, role: "status" }, +- h48("div", { className: "fs-preview-name", "data-flybot": child("name") }, preview.name || "Face-to-face room"), +- preview.lead ? h48("div", { className: "fs-preview-meta", "data-flybot": child("lead") }, `Lead: ${preview.lead}`) : null, +- preview.people ? h48("div", { className: "fs-preview-meta", "data-flybot": child("members") }, preview.people) : null, +- preview.expiresAt ? h48("div", { className: "fs-preview-meta", "data-flybot": child("expiry") }, countdown) : null, +- onJoin || onClear ? h48( ++ h49("div", { className: "fs-preview-name", "data-flybot": child("name") }, preview.name || "Face-to-face room"), ++ preview.lead ? h49("div", { className: "fs-preview-meta", "data-flybot": child("lead") }, `Lead: ${preview.lead}`) : null, ++ preview.people ? h49("div", { className: "fs-preview-meta", "data-flybot": child("members") }, preview.people) : null, ++ preview.expiresAt ? h49("div", { className: "fs-preview-meta", "data-flybot": child("expiry") }, countdown) : null, ++ onJoin || onClear ? h49( + "div", + { className: "fs-preview-foot", "data-flybot": child("foot") }, +- showClear && onClear ? h48("button", { ++ showClear && onClear ? h49("button", { + type: "button", + className: "fs-btn fs-btn-sm", + "data-flybot": child("clear"), + disabled: !!busy, + onClick: onClear + }, clearLabel) : null, +- onJoin ? h48("button", { ++ onJoin ? h49("button", { + type: "button", + className: "fs-btn fs-btn-sm fs-btn-primary", + "data-flybot": child("join"), +@@ -3396,10 +3396,10 @@ + `; + + // src/shared/failure-copy.js +-var providerCode = (text) => { +- const json = /"code"\s*:\s*"([A-Za-z0-9_.-]+)"/.exec(text); ++var providerCode = (text2) => { ++ const json = /"code"\s*:\s*"([A-Za-z0-9_.-]+)"/.exec(text2); + if (json) return json[1]; +- const plain = /^([A-Za-z][A-Za-z0-9_.-]{2,40}):\s/.exec(text); ++ const plain = /^([A-Za-z][A-Za-z0-9_.-]{2,40}):\s/.exec(text2); + return plain ? plain[1] : ""; + }; + var KNOWN = { +@@ -3410,13 +3410,13 @@ + InsufficientQuota: "The account is out of quota.", + RateLimitExceeded: "Too many requests right now. Try again in a moment." + }; +-var fill2 = (text, params) => params === void 0 ? text : String(text).replace(/\{(\w+)\}/g, (match, name) => name in params ? String(params[name]) : match); ++var fill2 = (text2, params) => params === void 0 ? text2 : String(text2).replace(/\{(\w+)\}/g, (match, name) => name in params ? String(params[name]) : match); + function failureSentence(raw, translate = (key) => key) { +- const text = String(raw ?? "").trim(); +- if (text === "") return text; +- const key = KNOWN[providerCode(text)]; +- if (key === void 0) return text; +- const feature = /does not support\s+([a-z][a-z0-9 _-]{2,40})/i.exec(text)?.[1]?.trim(); ++ const text2 = String(raw ?? "").trim(); ++ if (text2 === "") return text2; ++ const key = KNOWN[providerCode(text2)]; ++ if (key === void 0) return text2; ++ const feature = /does not support\s+([a-z][a-z0-9 _-]{2,40})/i.exec(text2)?.[1]?.trim(); + if (key !== KNOWN.UnsupportedModel || feature === void 0) return fill2(translate(key), void 0); + const withFeature = "This model does not support {feature}. Pick another model in the Bot's settings."; + return fill2(translate(withFeature, { feature }), { feature }); +@@ -3631,12 +3631,12 @@ + const dt = Math.min(0.5, Math.max(1e-3, (now - last) / 1e3)); + last = now; + const steps = Math.ceil(dt * 240); +- const h48 = dt / steps; ++ const h49 = dt / steps; + let moving = false; + for (const value of values.values()) { + for (let index = 0; index < steps; index += 1) { +- value.v += (-value.k * (value.x - value.to) - value.c * value.v) * h48; +- value.x += value.v * h48; ++ value.v += (-value.k * (value.x - value.to) - value.c * value.v) * h49; ++ value.x += value.v * h49; + } + if (Math.abs(value.x - value.to) < 0.05 && Math.abs(value.v) < 1) { + value.x = value.to; +@@ -4914,10 +4914,10 @@ + .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) { ++function installStyles(text2, tag) { + const style = document.createElement("style"); + style.dataset.dshBot = tag; +- style.textContent = text; ++ style.textContent = text2; + document.head.appendChild(style); + return style; + } +@@ -4937,9 +4937,9 @@ + } + return names; + } +-function compileFragmentSelectors(text, names) { ++function compileFragmentSelectors(text2, names) { + const compiled = /* @__PURE__ */ new Map(); +- return text.replace(FRAGMENT_SELECTOR, (whole, fragment) => { ++ return text2.replace(FRAGMENT_SELECTOR, (whole, fragment) => { + if (!compiled.has(fragment)) { + const exact = []; + for (const name of names) if (name.includes(fragment)) exact.push(`.${name}`); +@@ -4948,7 +4948,7 @@ + return compiled.get(fragment); + }); + } +-function installCompiledStyles(text, tag) { ++function installCompiledStyles(text2, tag) { + const style = installStyles("", tag); + const cache = /* @__PURE__ */ new WeakMap(); + const render = () => { +@@ -4962,7 +4962,7 @@ + } + for (const name of list) names.add(name); + } +- const next = compileFragmentSelectors(text, names); ++ const next = compileFragmentSelectors(text2, names); + if (style.textContent !== next) style.textContent = next; + }; + render(); +@@ -5059,11 +5059,11 @@ + return ``; + } + function brandTitle(title) { +- const text = String(title ?? ""); +- if (text === "DeepSeek Harness" || text === "DS Bot") return "FlyBot"; +- if (text.endsWith(" — DeepSeek Harness")) return `${text.slice(0, -" — DeepSeek Harness".length)} — FlyBot`; +- if (/\bDS Bot\b/.test(text)) return text.replace(/\bDS Bot\b/g, "FlyBot"); +- return text; ++ const text2 = String(title ?? ""); ++ if (text2 === "DeepSeek Harness" || text2 === "DS Bot") return "FlyBot"; ++ if (text2.endsWith(" — DeepSeek Harness")) return `${text2.slice(0, -" — DeepSeek Harness".length)} — FlyBot`; ++ if (/\bDS Bot\b/.test(text2)) return text2.replace(/\bDS Bot\b/g, "FlyBot"); ++ return text2; + } + function sessionTabLabel(roster, sessionId) { + if (!roster || !sessionId) return ""; +@@ -5542,14 +5542,14 @@ + "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, text]) => (0, import_react7.createElement)("button", { ++ 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) +- }, text)) ++ }, text2)) + ); + } + function SecretHead({ bot, title, detail, name, onClose }) { +@@ -5627,10 +5627,10 @@ + } + 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 }); ++ 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": text, "data-status": request.status }, ++ { 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" }, +@@ -5638,7 +5638,7 @@ + (0, import_react7.createElement)("div", { className: "bt-q-text" }, (0, import_react7.createElement)( + "p", + { className: "bt-q-title" }, +- text, ++ 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)) +@@ -5683,11 +5683,11 @@ + (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, text]) => (0, import_react7.createElement)( ++ [["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) }), +- text ++ text2 + )) + ), + mode === "some" ? (0, import_react7.createElement)("div", { className: "bt-secret-bots" }, roster.bots.map((bot) => (0, import_react7.createElement)("button", { +@@ -6059,7 +6059,7 @@ + return (0, import_react9.createElement)( + "div", + { className: "bt-astack", "data-lead": lead ? "" : void 0, onClickCapture: onClick }, +- texts2.map((text, index) => { ++ texts2.map((text2, index) => { + const key = msgKey ? `${msgKey}:${index}` : void 0; + const picked = key ? reactions?.[index] ?? [] : []; + const last = index === texts2.length - 1; +@@ -6074,9 +6074,9 @@ + (0, import_react9.createElement)( + "div", + { className: "bt-bubble" }, +- (0, import_react9.createElement)(import_dsh_client_ui_primitives.MarkdownText, { text: linkMentions(text, roster.bots, selfId), streaming: live, labels: mdLabels(), variant: "compact", fileMentions: mentions }) ++ (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, name: roster.byId[selfId]?.name ?? t("Bot"), sessionId, react: key ? (emoji) => actions.toggleReaction(msgKey, index, emoji) : void 0, actions }) ++ live ? null : (0, import_react9.createElement)(MessageTools, { text: text2, name: roster.byId[selfId]?.name ?? t("Bot"), sessionId, react: key ? (emoji) => actions.toggleReaction(msgKey, 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, +@@ -6091,9 +6091,9 @@ + } + var WAITING_OTHERS_NOTICE = "Waiting for the owner's approval"; + var REACTIONS = ["👍", "❤️", "😂", "😮", "😢", "🙏"]; +-function leaveNoticeCoveredByClose(room2, text) { ++function leaveNoticeCoveredByClose(room2, text2) { + if (room2?.origin !== "face-to-face" || room2.face?.status !== "closed") return false; +- const match = String(text ?? "").trim().match(/^(.+) left(?: the room)?$/); ++ const match = String(text2 ?? "").trim().match(/^(.+) left(?: the room)?$/); + if (!match) return false; + const who = match[1]; + for (const participant of room2.participants ?? []) { +@@ -6124,7 +6124,7 @@ + }; + }, [open]); + } +-function MessageTools({ text, name, sessionId, react, actions }) { ++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); +@@ -6143,13 +6143,13 @@ + setPanel(kind); + }; + const copy2 = () => { +- void navigator.clipboard?.writeText(text).then(() => { ++ void navigator.clipboard?.writeText(text2).then(() => { + setCopied(true); + setTimeout(() => setCopied(false), 1e3); + }); + }; + const download2 = () => { +- const url = URL.createObjectURL(new Blob([text], { type: "text/markdown" })); ++ 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); +@@ -6162,7 +6162,7 @@ + "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, text) }, (0, import_react9.createElement)(ReplyIcon)) : 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", +@@ -6195,14 +6195,14 @@ + 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 = (text) => { +- const lines = text.split("\n").map((part) => part.replace(/\s+/g, " ").trim()).filter((part) => part !== ""); ++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 }) { +- if (typeof text !== "string" || text.trim() === "") return null; +- const peek = peekLine(text); ++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" }, +@@ -6213,7 +6213,7 @@ + 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" }, text) ++ (0, import_react9.createElement)("div", { className: "bt-think-text" }, text2) + ); + } + function useFileMentions({ node, openFile, fileMentions, useTurnData }) { +@@ -6234,7 +6234,7 @@ + } + function friendLead(bot, texts2) { + if (bot?.remote === void 0 || texts2.length === 0) return void 0; +- if (texts2.every((text) => /^Not sent:/.test(text.trim()))) 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 }) { +@@ -6247,7 +6247,7 @@ + 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((text) => text.trim() !== ""); ++ 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; +@@ -6323,11 +6323,11 @@ + }); + return (0, import_react9.createElement)("div", { className: "bt-event" }, parts); + } +-var heldForm = (text) => String(text ?? "").replace(/\s+/g, " ").trim().replace(/^(to (the )?face[ -]?room|to the room)\s*:\s*/i, "").trim(); ++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, text, bot) { ++function isHeldLine(room2, text2, bot) { + if (room2?.origin !== "face-to-face") return false; +- const line2 = heldForm(text); ++ const line2 = heldForm(text2); + if (!line2) return false; + return (room2.pendingApprovals ?? []).some((item) => { + if (item?.kind !== "face_room_message") return false; +@@ -6337,9 +6337,9 @@ + }); + } + var HELD_SENDING_MS = 6e4; +-function heldLineState(room2, { seq, botId, text } = {}, now = Date.now()) { ++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(text ?? "").trim(); ++ 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"; +@@ -6347,8 +6347,8 @@ + 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, room: room2, roster, actions, msgKey, useUi, seq }) { +- const held = heldLineState(room2, { seq, botId: bot.id, text }); ++function GroupMessage({ bot, text: text2, room: room2, roster, actions, msgKey, useUi, seq }) { ++ const held = heldLineState(room2, { seq, botId: bot.id, text: text2 }); + if (held === "rejected") { + return (0, import_react9.createElement)( + "div", +@@ -6357,7 +6357,7 @@ + (0, import_react9.createElement)("span", null, t("{name} · Not sent", { name: bot.name })) + ); + } +- if (held === "held" || isHeldLine(room2, text, bot)) return null; ++ 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) }, +@@ -6373,7 +6373,7 @@ + "div", + { className: "bt-group-msg" }, + name, +- (0, import_react9.createElement)(Bubbles, { texts: [text], roster, selfId: bot.id, actions, sessionId: room2?.id, msgKey, useUi, lead }) ++ (0, import_react9.createElement)(Bubbles, { texts: [text2], roster, selfId: bot.id, actions, sessionId: room2?.id, msgKey, useUi, lead }) + ); + } + function remoteSpeaker(source, roster, sessionId) { +@@ -6408,7 +6408,7 @@ + return Object.keys(look).length ? look : void 0; + } + var cleanCarrier = (value) => typeof value === "string" && value.trim() && !/[@()]/.test(value) ? value.trim() : ""; +-function RemoteMessage({ source, text, roster, actions, sessionId, msgKey, useUi }) { ++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); +@@ -6425,7 +6425,7 @@ + person, + carrier ? (0, import_react9.createElement)("span", { className: "bt-remote-handle" }, t("via {name}", { name: carrier })) : null + ), +- (0, import_react9.createElement)(Bubbles, { texts: [stripTrace(text)], 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 })) }), ++ (0, import_react9.createElement)(Bubbles, { texts: [stripTrace(text2)], 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 + ); + } +@@ -6441,7 +6441,7 @@ + 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_react9.createElement)(Bubbles, { texts: [stripTrace(text)], roster, selfId: bot.id, actions, sessionId, msgKey, useUi, lead }), ++ (0, import_react9.createElement)(Bubbles, { texts: [stripTrace(text2)], 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 + ); + } +@@ -7513,13 +7513,13 @@ + } + var elapsedSince = (now, at) => typeof at === "number" && now - at >= 6e4 ? elapsedLabel(now - at) : ""; + function WorkRow({ bot, tool, step, label, at, now, onOpen }) { +- const text = `${label} · ${workText(tool, step)}`; ++ 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": [text, elapsed].filter(Boolean).join(" · "), onClick: onOpen }, ++ { 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, text)), ++ (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 + ); + } +@@ -7770,11 +7770,11 @@ + 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, text) => { +- if (bot === void 0 || typeof text !== "string" || text === "") return void 0; ++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 !== text) continue; ++ if (question.question !== text2) continue; + if (key === bot.id || key === currentPart(bot)) return bot.id; + } + return void 0; +@@ -7790,7 +7790,7 @@ + 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((text) => typeof text === "string" && text !== "" && text !== card.title && text !== card.reason) ?? ""; ++ return options.find((text2) => typeof text2 === "string" && text2 !== "" && text2 !== card.title && text2 !== card.reason) ?? ""; + } + function toolCalls(card, { roster, work }) { + const botId = card.bot?.id; +@@ -7945,9 +7945,9 @@ + const part = currentPart(bot); + const at = sessions.byId[part]?.updatedAt; + if (!fresh(at)) continue; +- const text = previewOf(sessions, part, [], [], botNames(roster)); +- if (text === "") continue; +- done.push({ id: `chat:${bot.id}`, kind: "chat", status: "done", bot, at, title: text, place: "", chat: bot.id, ...people(roster, bot.id) }); ++ 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); +@@ -7970,10 +7970,10 @@ + return Promise.resolve(actions.answerQuestion(card.sessionId, card.question.id, { selected, custom })); + } + if (card?.kind === "task" && card.status === "input") { +- const text = typeof choice === "string" ? choice.trim() : ""; ++ const text2 = typeof choice === "string" ? choice.trim() : ""; + const questionId = card.task?.inputRequest?.id; +- if (text === "" || typeof questionId !== "string") return void 0; +- return Promise.resolve(actions.answerTaskInput(card.task.id, questionId, text)); ++ 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)); +@@ -8382,8 +8382,8 @@ + + // src/shared/board-card.js + function taskCardKey(id) { +- const text = String(id ?? "").trim(); +- return text === "" ? null : `task:${text}`; ++ const text2 = String(id ?? "").trim(); ++ return text2 === "" ? null : `task:${text2}`; + } + function taskCard(task) { + const key = taskCardKey(task?.id); +@@ -8869,11 +8869,11 @@ + } + }; + const create = async (title) => { +- const text = String(title ?? "").trim(); +- if (!text || scope?.kind !== "bot") return; ++ const text2 = String(title ?? "").trim(); ++ if (!text2 || scope?.kind !== "bot") return; + setError(""); + try { +- replace(await actions.taskCreate({ botId: scope.botId, title: text, column: "not-started" })); ++ replace(await actions.taskCreate({ botId: scope.botId, title: text2, column: "not-started" })); + setDraft(""); + } catch (failure) { + setError(failure?.message ?? String(failure)); +@@ -8989,7 +8989,7 @@ + var PinMark = () => (0, import_react16.createElement)("span", { className: "bt-pin", "aria-hidden": true }, (0, import_react16.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) => { ++var workKeys = (text2) => text2 === "" ? [] : text2.split(",").map((key) => { + const [id, tool] = key.split("|"); + return { id, tool: tool || void 0 }; + }); +@@ -9922,7 +9922,7 @@ + const host = useRunningHost2(visible); + const shown = (0, import_react17.useRef)(null); + if (visible) shown.current = room2 ? { text: memberAck?.text ?? "", at: memberAck?.at ?? Date.now() } : { text: busyAckText(hostAck), at: hostAck?.at ?? Date.now() }; +- const text = shown.current?.text ?? ""; ++ const text2 = shown.current?.text ?? ""; + if (!visible || !host || !shown.current || !bot) return null; + return (0, import_react_dom4.createPortal)((0, import_react17.createElement)( + "div", +@@ -9931,8 +9931,8 @@ + "data-flybot": "busy-ack", + role: "status", + "aria-live": "polite", +- "aria-busy": text ? void 0 : "true", +- "data-pending": text ? void 0 : "" ++ "aria-busy": text2 ? void 0 : "true", ++ "data-pending": text2 ? void 0 : "" + }, + (0, import_react17.createElement)(BotMark, { bot, size: 28, state: "thinking", live: true }), + (0, import_react17.createElement)( +@@ -9944,7 +9944,7 @@ + (0, import_react17.createElement)( + "div", + { className: "bt-msg-line" }, +- text ? (0, import_react17.createElement)("div", { className: "bt-bubble" }, text) : (0, import_react17.createElement)( ++ text2 ? (0, import_react17.createElement)("div", { className: "bt-bubble" }, text2) : (0, import_react17.createElement)( + "div", + { className: "bt-bubble bt-busy-dots", "aria-hidden": "true" }, + (0, import_react17.createElement)("span", null), +@@ -10328,10 +10328,10 @@ + let interim = ""; + for (let index = event.resultIndex; index < event.results.length; index += 1) { + const result = event.results[index]; +- const text = result[0]?.transcript ?? ""; ++ const text2 = result[0]?.transcript ?? ""; + if (result.isFinal) { +- if (text.trim()) latest.current(text, true); +- } else interim += text; ++ if (text2.trim()) latest.current(text2, true); ++ } else interim += text2; + } + if (interim) latest.current(interim, false); + }; +@@ -10377,8 +10377,8 @@ + ); + } + function ComposerVoice({ sessionId, inputActions, actions, useUi }) { +- const dictation = useDictation((text, final) => { +- if (final && inputActions) inputActions.insertText(text, inputActions.captureInsertion()); ++ const dictation = useDictation((text2, final) => { ++ if (final && inputActions) inputActions.insertText(text2, inputActions.captureInsertion()); + }); + const quote = useUi((state) => state.quote?.sessionId === sessionId ? state.quote : null); + (0, import_react19.useEffect)(() => { +@@ -10746,8 +10746,8 @@ + function waveBytes(samples, rate = 16e3) { + const bytes = new Uint8Array(44 + samples.length * 2); + const view = new DataView(bytes.buffer); +- const ascii = (at, text) => { +- for (let index = 0; index < text.length; index += 1) bytes[at + index] = text.charCodeAt(index); ++ const ascii = (at, text2) => { ++ for (let index = 0; index < text2.length; index += 1) bytes[at + index] = text2.charCodeAt(index); + }; + ascii(0, "RIFF"); + view.setUint32(4, 36 + samples.length * 2, true); +@@ -10768,9 +10768,9 @@ + return bytes; + } + function base64Of(bytes) { +- let text = ""; +- for (let index = 0; index < bytes.length; index += 32768) text += String.fromCharCode(...bytes.subarray(index, index + 32768)); +- return btoa(text); ++ let text2 = ""; ++ for (let index = 0; index < bytes.length; index += 32768) text2 += String.fromCharCode(...bytes.subarray(index, index + 32768)); ++ return btoa(text2); + } + var captureError = (kind, cause) => Object.assign(new Error(kind), { kind, cause }); + async function openCapture() { +@@ -11078,7 +11078,7 @@ + void actions.refreshModels?.(); + }).finally(() => setBusy("")); + }; +- const text = lost ? t("{model} is not available anymore. Pick another model for me.", { model: bot.model.split("/").slice(1).join("/") }) : models.length === 0 ? t("Hi, I'm {name}. I don't have a model yet. How do you want to set one up?", { name: bot.name }) : t("Hi, I'm {name}. Pick a model for me first. You can change it later in my details.", { name: bot.name }); ++ const text2 = lost ? t("{model} is not available anymore. Pick another model for me.", { model: bot.model.split("/").slice(1).join("/") }) : models.length === 0 ? t("Hi, I'm {name}. I don't have a model yet. How do you want to set one up?", { name: bot.name }) : t("Hi, I'm {name}. Pick a model for me first. You can change it later in my details.", { name: bot.name }); + const choices = models.length === 0 ? [...setupChoices(actions).map(([label, run]) => (0, import_react20.createElement)("button", { key: label, type: "button", className: "bt-model-chip", onClick: run }, label)), (0, import_react20.createElement)(KeyHelpLink, { key: "key-help" })] : [ + ...groupModels(models).map(([provider, list]) => (0, import_react20.createElement)( + "div", +@@ -11110,7 +11110,7 @@ + "div", + { className: "bt-msg-line" }, + (0, import_react20.createElement)(BotMark, { bot, size: 22 }), +- (0, import_react20.createElement)("div", { className: "bt-bubble" }, text) ++ (0, import_react20.createElement)("div", { className: "bt-bubble" }, text2) + ) + ) + ), +@@ -11150,9 +11150,9 @@ + return (0, import_react20.createElement)("a", { className: "bt-model-help", href: KEY_HELP_URL, target: "_blank", rel: "noopener noreferrer" }, t("How to set a DeepSeek API key")); + } + function missingModelKey(message) { +- const text = String(message ?? ""); +- if (!/\bno api key\b|\bmissing (?:an? )?api key\b|\bapi key (?:is )?(?:missing|not set|not configured)\b/i.test(text)) return void 0; +- return { route: /provider route "([^"]+)"/i.exec(text)?.[1] ?? "" }; ++ const text2 = String(message ?? ""); ++ if (!/\bno api key\b|\bmissing (?:an? )?api key\b|\bapi key (?:is )?(?:missing|not set|not configured)\b/i.test(text2)) return void 0; ++ return { route: /provider route "([^"]+)"/i.exec(text2)?.[1] ?? "" }; + } + function modelKeyChoices(actions, route) { + if (/^deepseek/i.test(route)) return setupChoices(actions); +@@ -11163,8 +11163,8 @@ + const code = typeof data.code === "string" && data.code ? data.code : void 0; + const raw = String(data.message ?? ""); + const shell = (key, fallback) => { +- const text = typeof chatT === "function" ? chatT(key) : void 0; +- return typeof text === "string" && text !== "" && text !== key ? text : fallback; ++ const text2 = typeof chatT === "function" ? chatT(key) : void 0; ++ return typeof text2 === "string" && text2 !== "" && text2 !== key ? text2 : fallback; + }; + const missing = missingModelKey(raw); + const title = code === "ACCOUNT_SIGNED_OUT" ? shell("message.accountStopped", t("This turn failed")) : shell("message.turnError", t("This turn failed")); +@@ -11550,8 +11550,8 @@ + return `${t("Every day at {time}", { time: clock2(task.time) })}${zone}`; + case "weekly": { + const days = (task.weekdays ?? []).join(); +- const text = 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 `${text}${zone}`; ++ 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}`; +@@ -12041,10 +12041,10 @@ + // src/client/capability-settings.js + var import_react24 = require("react"); + var blank = () => ({ name: "", kind: "mcp", description: "", transport: "stdio", command: "", argsText: "", envText: "{}", url: "", headersText: "{}", content: "", items: [] }); +-var parseMap = (text, label) => { ++var parseMap = (text2, label) => { + let value; + try { +- value = JSON.parse(text || "{}"); ++ value = JSON.parse(text2 || "{}"); + } catch { + throw new Error(t("{label} must be valid JSON.", { label })); + } +@@ -12471,11 +12471,11 @@ + ); + } + function changeLine(change) { +- const text = change.text.length > 120 ? `${change.text.slice(0, 120)}…` : change.text; +- if (!change.ok) return t("Not changed: {text} ({reason})", { text, reason: hostText(change.error ?? "") }); +- if (change.op === "remove") return change.scope === "team" ? t("Removed from team memory: {text}", { text }) : t("Removed: {text}", { text }); +- if (change.op === "edit") return t("Updated: {text}", { text }); +- return change.scope === "team" ? t("Added to team memory: {text}", { text }) : t("Added: {text}", { text }); ++ const text2 = change.text.length > 120 ? `${change.text.slice(0, 120)}…` : change.text; ++ if (!change.ok) return t("Not changed: {text} ({reason})", { text: text2, reason: hostText(change.error ?? "") }); ++ if (change.op === "remove") return change.scope === "team" ? t("Removed from team memory: {text}", { text: text2 }) : t("Removed: {text}", { text: text2 }); ++ if (change.op === "edit") return t("Updated: {text}", { text: text2 }); ++ return change.scope === "team" ? t("Added to team memory: {text}", { text: text2 }) : t("Added: {text}", { text: text2 }); + } + function Reply({ answer, dismiss }) { + return (0, import_react26.createElement)( +@@ -12546,7 +12546,7 @@ + const [page, setPage] = (0, import_react26.useState)("summary"); + const [menu, setMenu] = (0, import_react26.useState)(false); + const [menuShown, menuLeaving] = useLinger(menu, MENU_EXIT_MS2); +- const [text, setText] = (0, import_react26.useState)(""); ++ const [text2, setText] = (0, import_react26.useState)(""); + const [answer, setAnswer] = (0, import_react26.useState)(null); + const menuRef = (0, import_react26.useRef)(null); + const dotsRef = (0, import_react26.useRef)(null); +@@ -12598,7 +12598,7 @@ + }; + const ask = async (event) => { + event.preventDefault(); +- const asked = text.trim(); ++ const asked = text2.trim(); + if (asked === "" || answer?.pending) return; + setText(""); + setAnswer({ asked, pending: true }); +@@ -12664,7 +12664,7 @@ + (0, import_react26.createElement)("input", { + ref: inputRef, + className: "bt-mem-input", +- value: text, ++ value: text2, + maxLength: 2e3, + disabled: view === null, + placeholder: t("Ask or update"), +@@ -12673,7 +12673,7 @@ + }), + (0, import_react26.createElement)( + "button", +- { type: "submit", className: "bt-mem-send", disabled: busy || text.trim() === "", "aria-label": t("Send") }, ++ { type: "submit", className: "bt-mem-send", disabled: busy || text2.trim() === "", "aria-label": t("Send") }, + busy ? (0, import_react26.createElement)("span", { className: "bt-mem-spin", "aria-hidden": true }) : (0, import_react26.createElement)(ArrowUpIcon) + ) + ) +@@ -13608,11 +13608,11 @@ + const entry = outline[index2]; + if (hidden.includes(entry.turn)) continue; + for (const raw of [entry.response, entry.prompt]) { +- const text = paletteText(raw, names); +- const at = text.toLowerCase().indexOf(wanted); ++ const text2 = paletteText(raw, names); ++ const at = text2.toLowerCase().indexOf(wanted); + if (at < 0) continue; + const from = Math.max(0, at - 24); +- hits.push([id, entry.turn, (from > 0 ? "…" : "") + text.slice(from, at + wanted.length + 60)]); ++ hits.push([id, entry.turn, (from > 0 ? "…" : "") + text2.slice(from, at + wanted.length + 60)]); + break; + } + } +@@ -13798,10 +13798,10 @@ + if (effort) payload.effort = effort; + return payload; + } +-async function afterCreate({ id, text, actions }) { ++async function afterCreate({ id, text: text2, actions }) { + actions.openSession(id); + actions.showDetails(id, "settings"); +- const line2 = String(text ?? "").trim(); ++ const line2 = String(text2 ?? "").trim(); + if (line2 === "") return; + try { + await actions.send(id, line2); +@@ -14063,27 +14063,27 @@ + }; + const makeGroup = async () => { + if (admin === null || picked.length === 0) return; +- const text = message.trim(); ++ const text2 = message.trim(); + const { members, remotes } = splitRoomPicks(roster, [admin, ...picked]); + const participants = remotes.length === 0 ? void 0 : [ + ...members.map((id) => ({ kind: "bot", id, role: id === admin ? "admin" : "member" })), + ...remotes + ]; + const room2 = await actions.createRoom(members, { admin, ...participants ? { participants } : {} }); +- if (text) await actions.send(room2.id, text); ++ if (text2) await actions.send(room2.id, text2); + actions.openSession(room2.id); + close(); + }; + const createGroup = () => run(makeGroup); + const sendMessage = () => run(async () => { +- const text = message.trim(); ++ const text2 = message.trim(); + if (group && admin !== null) { + await makeGroup(); + return; + } +- if (text === "" || picked.length === 0) return; ++ if (text2 === "" || picked.length === 0) return; + if (picked.length === 1) { +- await actions.send(picked[0], text); ++ await actions.send(picked[0], text2); + actions.openSession(picked[0]); + close(); + return; +@@ -14094,7 +14094,7 @@ + ...remotes + ]; + const room2 = await actions.createRoom(members, participants ? { participants } : {}); +- await actions.send(room2.id, text); ++ await actions.send(room2.id, text2); + actions.openSession(room2.id); + close(); + }); +@@ -14308,10 +14308,10 @@ + // src/client/exchange.js + var import_react33 = require("react"); + var SHEET_EXIT_MS = 220; +-function exchangeText(text) { +- if (typeof text !== "string") return ""; ++function exchangeText(text2) { ++ if (typeof text2 !== "string") return ""; + const CLOSE = ""; +- let body = text; ++ let body = text2; + for (let previous; previous !== body; ) { + previous = body; + const open = body.indexOf(" { +- setHeard(text); ++ const submit = (text2) => { ++ setHeard(text2); + setPhase("thinking"); + waiting.current = reply.current.split("|")[0]; +- void actions.send(sessionId, text.trim()).catch(() => setPhase("listening")); ++ void actions.send(sessionId, text2.trim()).catch(() => setPhase("listening")); + }; +- const dictation = useDictation((text, final) => { +- setHeard(text); ++ const dictation = useDictation((text2, final) => { ++ setHeard(text2); + if (!final) return; + dictation.stop(); +- submit(text); ++ submit(text2); + }); + (0, import_react34.useEffect)(() => { + if (!note) return void 0; +@@ -14534,9 +14534,9 @@ + setPhase("transcribing"); + try { + const wave = await current.finish(catalog.maxDurationSeconds); +- const text = wave ? await transcribeWave(speech, catalog, wave, new AbortController().signal) : ""; ++ const text2 = wave ? await transcribeWave(speech, catalog, wave, new AbortController().signal) : ""; + if (turn.current !== own) return; +- if (text) submit(text); ++ if (text2) submit(text2); + else setPhase("listening"); + } catch (error) { + if (turn.current !== own) return; +@@ -14585,10 +14585,10 @@ + }, [speech, catalog, listen, cycle]); + (0, import_react34.useEffect)(() => { + if (phase !== "thinking" || running || waiting.current === null) return; +- const [count, text] = [lastReply.slice(0, lastReply.indexOf("|")), lastReply.slice(lastReply.indexOf("|") + 1)]; +- if (count === waiting.current || !text.trim()) return; ++ const [count, text2] = [lastReply.slice(0, lastReply.indexOf("|")), lastReply.slice(lastReply.indexOf("|") + 1)]; ++ if (count === waiting.current || !text2.trim()) return; + waiting.current = null; +- const spoken = speechOf(text, botNames(roster)); ++ const spoken = speechOf(text2, botNames(roster)); + setSaid(spoken); + setHeard(""); + if (!window.speechSynthesis || !spoken) { +@@ -15337,8 +15337,8 @@ + return result; + } + function trimmed2(value) { +- const text = value >= 100 ? value.toFixed(0) : value >= 10 ? value.toFixed(1) : value.toFixed(2); +- return text.includes(".") ? text.replace(/\.?0+$/, "") : text; ++ const text2 = value >= 100 ? value.toFixed(0) : value >= 10 ? value.toFixed(1) : value.toFixed(2); ++ return text2.includes(".") ? text2.replace(/\.?0+$/, "") : text2; + } + function compact(count) { + for (const [size, unit] of [[1e9, "B"], [1e6, "M"], [1e3, "K"]]) { +@@ -15470,14 +15470,14 @@ + return labels[kind] ?? kind; + } + function Tabs({ label, value, options, onChange, boxRef }) { +- const names = options.map(([, text]) => text).join("\n"); ++ const names = options.map(([, text2]) => text2).join("\n"); + const glide = useGlide(`${value} + ${names}`, { box: boxRef }); + return (0, import_react36.createElement)( + "div", + { ref: glide.box, className: "bt-us-tabs", role: "group", "aria-label": label }, + (0, import_react36.createElement)("span", { ref: glide.thumb, className: "bt-thumb", "aria-hidden": true }), +- options.map(([id, text, icon3]) => (0, import_react36.createElement)("button", { key: id, type: "button", "aria-pressed": value === id, onClick: () => onChange(id) }, icon3 ? (0, import_react36.createElement)(icon3) : null, text)) ++ options.map(([id, text2, icon3]) => (0, import_react36.createElement)("button", { key: id, type: "button", "aria-pressed": value === id, onClick: () => onChange(id) }, icon3 ? (0, import_react36.createElement)(icon3) : null, text2)) + ); + } + function Stats({ range, total, previous }) { +@@ -16363,15 +16363,15 @@ + const names = (0, import_react37.useRef)(new Map(APPS)); + const asked = (0, import_react37.useRef)(0); + (0, import_react37.useEffect)(() => { +- const text = query.trim(); +- if (text === "" || search === void 0) { ++ const text2 = query.trim(); ++ if (text2 === "" || search === void 0) { + setFound(null); + setFailed(null); + return void 0; + } + const ask = ++asked.current; + const timer = setTimeout(() => { +- search(text).then((list) => { ++ search(text2).then((list) => { + if (ask !== asked.current) return; + for (const app of list) names.current.set(app.slug, app.name); + setFound(list); +@@ -16605,15 +16605,15 @@ + (0, import_react38.createElement)("path", { d: "M4 4.5h12a1 1 0 0 1 1 1v7a1 1 0 0 1-1 1H9l-3.5 3v-3H4a1 1 0 0 1-1-1v-7a1 1 0 0 1 1-1z" }), + (0, import_react38.createElement)("path", { d: "M10 7v2.6M10 11.4h.01" }) + ); +-function download(name, text) { +- const url = URL.createObjectURL(new Blob([text], { type: "text/plain;charset=utf-8" })); ++function download(name, text2) { ++ const url = URL.createObjectURL(new Blob([text2], { type: "text/plain;charset=utf-8" })); + const anchor = document.createElement("a"); + anchor.href = url; + anchor.download = name; + anchor.click(); + setTimeout(() => URL.revokeObjectURL(url), 1e3); + } +-var copy = (text) => navigator.clipboard === void 0 ? Promise.resolve(false) : navigator.clipboard.writeText(text).then(() => true, () => false); ++var copy = (text2) => navigator.clipboard === void 0 ? Promise.resolve(false) : navigator.clipboard.writeText(text2).then(() => true, () => false); + function whenText2(time) { + const date = new Date(time); + const today = (/* @__PURE__ */ new Date()).toDateString() === date.toDateString(); +@@ -16661,8 +16661,8 @@ + if (!inline && !copied) setPreview(true); + })); + }; +- const downloadLog = () => run(actions.diagnosticsLog().then((text) => { +- download("ds-bot-diagnostics.log", text || "No entries.\n"); ++ const downloadLog = () => run(actions.diagnosticsLog().then((text2) => { ++ download("ds-bot-diagnostics.log", text2 || "No entries.\n"); + setNote(t("Downloaded the log. Keys, tokens, and your home folder are hidden in it.")); + })); + const clearLog = () => run(actions.diagnosticsClear().then(clearClientErrors).then(load).then(() => setNote(t("Cleared the error log.")))); +@@ -17172,13 +17172,13 @@ + "nav", + { ref: nav.box, className: "bt-settings-nav", "aria-label": t("Settings pages") }, + (0, import_react40.createElement)("span", { ref: nav.thumb, className: "bt-thumb", "aria-hidden": true }), +- pages.map(([key, text]) => (0, import_react40.createElement)("button", { ++ pages.map(([key, text2]) => (0, import_react40.createElement)("button", { + key, + type: "button", + className: "bt-settings-item", + "aria-current": shown === key ? "page" : void 0, + onClick: () => actions.openSettings(key) +- }, (0, import_react40.createElement)(extra.find((item) => item.key === key)?.entry.icon ?? PAGE_ICONS[key] ?? ConnectorIcon), t(text))), ++ }, (0, import_react40.createElement)(extra.find((item) => item.key === key)?.entry.icon ?? PAGE_ICONS[key] ?? ConnectorIcon), t(text2))), + // DeepSeek Harness's own settings are a separate window with its own title and look; FlyBot + // does not embed or retitle it (never patch DSH), so the entry says it leaves (#421). + (0, import_react40.createElement)("button", { +@@ -17543,16 +17543,16 @@ + (0, import_react42.createElement)("button", { type: "button", className: "bt-bcard-new", style: { animationDelay: `${state.ids.length * 30}ms` }, onClick: () => actions.browserNewTab(sessionId) }, (0, import_react42.createElement)(PlusIcon), t("New tab")) + ); + } +-function About({ text, title = "About" }) { ++function About({ text: text2, title = "About" }) { + const [open, setOpen] = (0, import_react42.useState)(false); +- const long = text.split("\n").length > 5 || text.length > 240; ++ const long = text2.split("\n").length > 5 || text2.length > 240; + const body = (0, import_react42.useRef)(null); + useMorph(body, open); + return (0, import_react42.createElement)( + "div", + null, + (0, import_react42.createElement)("div", { className: "bt-section-title" }, t(title)), +- (0, import_react42.createElement)("div", { ref: body, className: "bt-about", "data-open": open || void 0 }, text), ++ (0, import_react42.createElement)("div", { ref: body, className: "bt-about", "data-open": open || void 0 }, text2), + long ? (0, import_react42.createElement)("button", { type: "button", className: "bt-more", onClick: () => setOpen((value) => !value) }, open ? t("Show less") : t("Show more")) : null + ); + } +@@ -17739,12 +17739,12 @@ + return false; + }); + const share = () => { +- const text = transcript; +- if (!text) { ++ const text2 = transcript; ++ if (!text2) { + actions.notify?.(t("Nothing to copy yet")); + return; + } +- void copiedToClipboard(text).then((written) => { ++ void copiedToClipboard(text2).then((written) => { + if (!written) { + actions.reportError?.(new Error(t("Could not copy the conversation")), "share"); + return; +@@ -18120,11 +18120,11 @@ + }, [tabId, landed]); + const go = (event) => { + event.preventDefault(); +- const text = draft ?? ""; +- if (text.trim() === "") return; ++ const text2 = draft ?? ""; ++ if (text2.trim() === "") return; + setDraft(null); + input.current?.blur(); +- void actions.browserNavigate(tabId, text).then((opened) => { ++ void actions.browserNavigate(tabId, text2).then((opened) => { + if (opened) actions.browserFocus(tabId); + }); + }; +@@ -18668,19 +18668,19 @@ + const auto = String(el.getAttribute("autocomplete") || "").toLowerCase(); + return el.type === "password" || /(^|\s)(cc-|one-time-code)/.test(auto) || SECRET_HINT.test(`${el.getAttribute("name") || ""} ${el.id || ""}`); + }; +- const keep = (text2, end = "") => { +- if (text2 === "") return ""; +- stash.push(text2); ++ const keep = (text3, end = "") => { ++ if (text3 === "") return ""; ++ stash.push(text3); + return ` + + ${stash.length - 1}${end} + + `; + }; +- const expand = (text2) => text2.replace(/\u0001(\d+)[\u0002\u0003]/g, (_, index) => expand(stash[Number(index)])); +- const flow = (text2) => text2.split("\n").map((line2) => line2.replace(/[ \t\f\v\u00a0\u2028\u2029]+/g, " ").trim().replace(/^· /, "")).join("\n").replace(/\n{3,}/g, "\n\n").replace(/\u0003\n\n(?=\u0001\d+\u0003)/g, "\n").trim(); +- const block = (text2) => expand(flow(text2)); +- const oneLine = (text2) => block(text2).replace(/\s*\n+\s*/g, " ").trim(); ++ const expand = (text3) => text3.replace(/\u0001(\d+)[\u0002\u0003]/g, (_, index) => expand(stash[Number(index)])); ++ const flow = (text3) => text3.split("\n").map((line2) => line2.replace(/[ \t\f\v\u00a0\u2028\u2029]+/g, " ").trim().replace(/^· /, "")).join("\n").replace(/\n{3,}/g, "\n\n").replace(/\u0003\n\n(?=\u0001\d+\u0003)/g, "\n").trim(); ++ const block = (text3) => expand(flow(text3)); ++ const oneLine = (text3) => block(text3).replace(/\s*\n+\s*/g, " ").trim(); + const styleOf = (el) => (el.ownerDocument.defaultView || window).getComputedStyle(el); + const childrenOf = (el) => { + if (el.shadowRoot) return el.shadowRoot.childNodes; +@@ -18714,8 +18714,8 @@ + items.push(item); + return { item, number: items.length, secret }; + }; +- const nameOf = (el, text2, secret) => collapse( +- el.getAttribute("aria-label") || text2 || (el.localName === "input" && !secret && NO_VALUE_TYPES.has(el.type) ? el.value : "") || el.getAttribute("placeholder") || el.title || el.getAttribute("alt") || el.querySelector?.("img[alt]")?.alt || el.getAttribute("name") || "" ++ const nameOf = (el, text3, secret) => collapse( ++ el.getAttribute("aria-label") || text3 || (el.localName === "input" && !secret && NO_VALUE_TYPES.has(el.type) ? el.value : "") || el.getAttribute("placeholder") || el.title || el.getAttribute("alt") || el.querySelector?.("img[alt]")?.alt || el.getAttribute("name") || "" + ).slice(0, 80); + const fenced = (code, lang) => { + const longest = Math.max(2, ...(code.match(/`{3,}/g) || []).map((run) => run.length)); +@@ -18784,12 +18784,12 @@ + for (const cell of row.cells) { + place(); + if (styleOf(cell).display === "none") continue; +- const text2 = cellText(cell, ctx); ++ const text3 = cellText(cell, ctx); + const span = Math.max(1, Math.min(limits.tableCols, Number(cell.colSpan) || 1)); + const down = Math.max(1, Math.min(limits.tableRows, Number(cell.rowSpan) || 1)); + for (let offset = 0; offset < span; offset += 1) { +- line2[column] = offset === 0 ? text2 : ""; +- if (down > 1) carry[column] = { text: offset === 0 ? text2 : "", left: down - 1 }; ++ line2[column] = offset === 0 ? text3 : ""; ++ if (down > 1) carry[column] = { text: offset === 0 ? text3 : "", left: down - 1 }; + column += 1; + } + } +@@ -18854,9 +18854,9 @@ + if (node.nodeType === 3) { + if (!ctx.shown) return ""; + const data = node.data.replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f]/g, ""); +- const text2 = ctx.pre ? data : data.replace(/[\t\n\r ]+/g, " "); +- chars += text2.length; +- return text2; ++ const text3 = ctx.pre ? data : data.replace(/[\t\n\r ]+/g, " "); ++ chars += text3.length; ++ return text3; + } + if (node.nodeType !== 1) return ""; + const el = node; +@@ -18944,12 +18944,12 @@ + return marked; + } + const body = document.body; +- let text = ""; ++ let text2 = ""; + if (body) { +- text = block(render(body, { depth: 0, frames: 0, shown: true, pre: false })).replace(/\u00a0/g, " "); ++ text2 = block(render(body, { depth: 0, frames: 0, shown: true, pre: false })).replace(/\u00a0/g, " "); + } +- if (text.length > limits.chars) { +- text = text.slice(0, limits.chars); ++ if (text2.length > limits.chars) { ++ text2 = text2.slice(0, limits.chars); + stop ||= `the text passed ${limits.chars} characters`; + } + const page = { +@@ -18959,7 +18959,7 @@ + description: document.querySelector('meta[name="description"]')?.content || "", + selection: String(getSelection() || "").slice(0, 4e3), + format: "markdown", +- text, ++ text: text2, + items, + stats: { ...stats, ms: Math.round(performance.now() - started) } + }; +@@ -18987,15 +18987,15 @@ + var BLOCKED = "Cloud metadata addresses do not open here."; + var blockedAddress = (url) => URL.canParse(url) && METADATA_HOST.test(new URL(url).hostname); + function browserAddress(input) { +- const text = String(input ?? "").trim(); +- if (text === "") return null; +- if (/^[a-z][a-z\d+.-]*:\/\//i.test(text)) return /^https?:\/\//i.test(text) && URL.canParse(text) ? new URL(text).href : null; +- if (/^(about|javascript|data|file|blob|chrome|devtools|view-source):/i.test(text)) return null; +- const host = text.split(/[/?#]/)[0]; ++ const text2 = String(input ?? "").trim(); ++ if (text2 === "") return null; ++ if (/^[a-z][a-z\d+.-]*:\/\//i.test(text2)) return /^https?:\/\//i.test(text2) && URL.canParse(text2) ? new URL(text2).href : null; ++ if (/^(about|javascript|data|file|blob|chrome|devtools|view-source):/i.test(text2)) return null; ++ const host = text2.split(/[/?#]/)[0]; + const local = /^(localhost|127(\.\d+){3}|10(\.\d+){3}|192\.168(\.\d+){2}|172\.(1[6-9]|2\d|3[01])(\.\d+){2}|\[[\da-f:]+\])(:\d+)?$/i.test(host); +- const looksLikeHost = !/\s/.test(text) && (local || /^[^\s.]+(\.[^\s.]+)+(:\d+)?$/.test(host)); +- if (looksLikeHost && URL.canParse(`http://${text}`)) return new URL(`${local ? "http" : "https"}://${text}`).href; +- return `${SEARCH_URL}${encodeURIComponent(text)}`; ++ const looksLikeHost = !/\s/.test(text2) && (local || /^[^\s.]+(\.[^\s.]+)+(:\d+)?$/.test(host)); ++ if (looksLikeHost && URL.canParse(`http://${text2}`)) return new URL(`${local ? "http" : "https"}://${text2}`).href; ++ return `${SEARCH_URL}${encodeURIComponent(text2)}`; + } + function createBotBrowsers({ bridge, createSource: createSource2, storage = globalThis.localStorage, document: doc = globalThis.document }) { + const views = /* @__PURE__ */ new Map(); +@@ -19475,8 +19475,8 @@ + const frame = col?.parentElement; + if (!frame) return null; + const name = frame.className; +- const text = typeof name === "string" ? name : name?.baseVal ?? ""; +- return text.includes("_frame") ? frame : null; ++ const text2 = typeof name === "string" ? name : name?.baseVal ?? ""; ++ return text2.includes("_frame") ? frame : null; + } + function levelShell(doc, from) { + const page = doc.scrollingElement || doc.documentElement; +@@ -19517,6 +19517,123 @@ + }; + } + ++// src/client/project-board.js ++var import_react46 = require("react"); ++var PROJECT_BOARD_CSS = ` ++.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-row .bt-project-ref{margin-left:auto;font-size:11px;opacity:.6} ++.bt-project-empty{padding:22px 4px;opacity:.7} ++.bt-project-actions{display:flex;gap:8px;margin:0 0 14px} ++`; ++var PROJECT_SOURCES = ["bot", "group", "workflow"]; ++var SOURCE_LABELS = { bot: "Bots", group: "Groups", workflow: "Workflows" }; ++var text = (value) => typeof value === "string" ? value.trim() : ""; ++function projectBoardView({ project, rows, canManage } = {}) { ++ const list = Array.isArray(rows) ? rows.filter((row) => row !== null && typeof row === "object" && text(row.id) !== "") : []; ++ const groups = PROJECT_SOURCES.map((source) => ({ ++ source, ++ label: SOURCE_LABELS[source], ++ rows: list.filter((row) => row.source === source).map((row) => ({ ++ 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 ++ })) ++ })).filter((group) => group.rows.length > 0); ++ return { ++ projectId: text(project?.id) || null, ++ title: text(project?.name) || "Project", ++ canManage: canManage === true, ++ groups, ++ count: list.length, ++ empty: list.length === 0, ++ emptyText: "Nothing under this Project yet." ++ }; ++} ++function ProjectBoardRows({ view, onOpen }) { ++ if (view.empty) return (0, import_react46.createElement)("div", { className: "bt-project-empty" }, t(view.emptyText)); ++ return view.groups.map((group) => (0, import_react46.createElement)( ++ "section", ++ { key: group.source, className: "bt-project-group", "data-source": group.source }, ++ (0, import_react46.createElement)("h4", null, t(group.label)), ++ group.rows.map((row) => (0, import_react46.createElement)( ++ "div", ++ { ++ key: row.key, ++ className: "bt-project-row", ++ "data-task-id": row.id, ++ "data-column": row.column, ++ role: "button", ++ tabIndex: 0, ++ onClick: () => onOpen?.(row.id), ++ onKeyDown: (event) => { ++ if (event.key === "Enter" || event.key === " ") onOpen?.(row.id); ++ } ++ }, ++ (0, import_react46.createElement)("span", { className: "bt-project-column" }, t(row.column)), ++ (0, import_react46.createElement)("span", { className: "bt-project-title" }, row.assignee ? `@${row.assignee}` : "—"), ++ row.runId === null ? null : (0, import_react46.createElement)("span", { className: "bt-project-ref" }, row.nodeId === null ? row.runId : `${row.runId} · ${row.nodeId}`) ++ )) ++ )); ++} ++function ProjectBoardPane({ project, rows, canManage, onOpen }) { ++ const view = projectBoardView({ project, rows, canManage }); ++ return (0, import_react46.createElement)( ++ "div", ++ { className: "bt-project-board", "data-can-manage": view.canManage ? "true" : "false" }, ++ (0, import_react46.createElement)("h3", null, view.title), ++ // Management affordances are drawn only for the owner; the refusal itself is the host's. ++ view.canManage ? (0, import_react46.createElement)("div", { className: "bt-project-actions" }, (0, import_react46.createElement)("button", { type: "button" }, t("Project settings")), (0, import_react46.createElement)("button", { type: "button" }, t("Members"))) : null, ++ (0, import_react46.createElement)(ProjectBoardRows, { view, onOpen }) ++ ); ++} ++function ProjectBoardOverlay() { ++ const [state, setState] = (0, import_react46.useState)(null); ++ const close = (0, import_react46.useCallback)(() => { ++ if (typeof window !== "undefined") window.location.hash = ""; ++ setState(null); ++ }, []); ++ (0, import_react46.useEffect)(() => { ++ if (typeof window === "undefined") return; ++ if (!window.location.hash.startsWith("#project")) return; ++ const wanted = window.location.hash.slice("#project".length).replace(/^=/, ""); ++ let live = true; ++ void (async () => { ++ try { ++ const listing = await call("projects"); ++ const projects = listing?.projects ?? []; ++ const project = wanted === "" ? projects[0] : projects.find((item) => item.id === wanted); ++ if (project === void 0) { ++ if (live) setState({ project: null, rows: [], canManage: false }); ++ return; ++ } ++ const [read, board] = await Promise.all([call("project-read", { id: project.id }), call("project-board", { id: project.id })]); ++ if (live) setState({ project: read?.project ?? project, rows: board?.tasks ?? [], canManage: read?.canManage === true }); ++ } catch { ++ if (live) setState({ project: null, rows: [], canManage: false }); ++ } ++ })(); ++ return () => { ++ live = false; ++ }; ++ }, []); ++ if (state === null) return null; ++ return (0, import_react46.createElement)( ++ "div", ++ { className: "bt-overlay bt-project-overlay", role: "dialog", "aria-label": t("Project board") }, ++ (0, import_react46.createElement)("button", { type: "button", className: "bt-project-close", onClick: close }, t("Close")), ++ state.project === null ? (0, import_react46.createElement)("div", { className: "bt-project-empty" }, t("No Project here.")) : (0, import_react46.createElement)(ProjectBoardPane, { project: state.project, rows: state.rows, canManage: state.canManage, onOpen: (id) => { ++ window.location.hash = `#task=${id}`; ++ } }) ++ ); ++} ++ + // src/client/friend-lines.js + function friendLineNews(bots, known, isOpen, selfId) { + const next = { ...known }; +@@ -19586,7 +19703,7 @@ + } + + // src/client/user-message.js +-var import_react46 = require("react"); ++var import_react47 = require("react"); + var ui = __toESM(require("@deepseek-ai/dsh-client-ui-primitives"), 1); + var canDrawUserMessages = () => typeof ui.projectUserText === "function"; + function contentParts(content) { +@@ -19658,58 +19775,58 @@ + mine: roster?.self?.memberId === authorId + }; + } +-function userTextParts(text, bots, referenceLabels = []) { ++function userTextParts(text2, bots, referenceLabels = []) { + const parts = []; + let cursor = 0; +- for (const span of mentionSpans(text, bots, { references: referenceLabels })) { ++ for (const span of mentionSpans(text2, bots, { references: referenceLabels })) { + if (!span.bot) continue; +- if (span.start > cursor) parts.push({ text: text.slice(cursor, span.start) }); +- parts.push({ bot: span.bot, label: text.slice(span.start + 1, span.end) }); ++ if (span.start > cursor) parts.push({ text: text2.slice(cursor, span.start) }); ++ parts.push({ bot: span.bot, label: text2.slice(span.start + 1, span.end) }); + cursor = span.end; +- if (text.startsWith(" ", cursor)) cursor += 1; ++ if (text2.startsWith(" ", cursor)) cursor += 1; + } +- if (cursor < text.length) parts.push({ text: text.slice(cursor) }); ++ if (cursor < text2.length) parts.push({ text: text2.slice(cursor) }); + return parts; + } +-function UserText({ text, bots, referenceLabels, skillNames, references, actions }) { +- const chip = (part) => [(0, import_react46.createElement)(BotMark, { bot: part.bot.draw ?? part.bot, size: 16 }), `@${part.bot.mentionLabel ?? part.label}`]; +- return userTextParts(text, bots, referenceLabels).map((part, index) => { +- if (!part.bot) return (0, import_react46.createElement)(import_react46.Fragment, { key: index }, ui.projectUserText(part.text, referenceLabels, skillNames, "skill", references)); ++function UserText({ text: text2, bots, referenceLabels, skillNames, references, actions }) { ++ const chip = (part) => [(0, import_react47.createElement)(BotMark, { bot: part.bot.draw ?? part.bot, size: 16 }), `@${part.bot.mentionLabel ?? part.label}`]; ++ return userTextParts(text2, bots, referenceLabels).map((part, index) => { ++ if (!part.bot) return (0, import_react47.createElement)(import_react47.Fragment, { key: index }, ui.projectUserText(part.text, referenceLabels, skillNames, "skill", references)); + const title = `@${part.bot.name}`; +- return typeof part.bot.id === "string" ? (0, import_react46.createElement)("button", { key: index, type: "button", className: "bt-umention", title, onClick: () => actions.openSession(part.bot.id) }, chip(part)) : (0, import_react46.createElement)("span", { key: index, className: "bt-umention", title }, chip(part)); ++ return typeof part.bot.id === "string" ? (0, import_react47.createElement)("button", { key: index, type: "button", className: "bt-umention", title, onClick: () => actions.openSession(part.bot.id) }, chip(part)) : (0, import_react47.createElement)("span", { key: index, className: "bt-umention", title }, chip(part)); + }); + } + function Attachments({ attachments, renderMessageImages }) { + const compact2 = attachments.length > 1; +- return (0, import_react46.createElement)("div", { className: "bt-uattach" }, attachments.map((block, index) => { ++ return (0, import_react47.createElement)("div", { className: "bt-uattach" }, attachments.map((block, index) => { + if (block.type === "image") { +- return renderMessageImages ? (0, import_react46.createElement)(import_react46.Fragment, { key: index }, renderMessageImages({ images: [{ attachment: block.attachment }], align: "end", compact: compact2 })) : null; ++ return renderMessageImages ? (0, import_react47.createElement)(import_react47.Fragment, { key: index }, renderMessageImages({ images: [{ attachment: block.attachment }], align: "end", compact: compact2 })) : null; + } + const file = block.attachment; + const meta = [ui.fileExtension?.(file.name)?.toUpperCase().slice(0, 8), ui.fileSizeText?.(file.bytes)].filter(Boolean).join(" · "); +- return (0, import_react46.createElement)( ++ return (0, import_react47.createElement)( + "span", + { key: index, className: "bt-ufile", title: file.name }, +- ui.FileTypeIcon ? (0, import_react46.createElement)(ui.FileTypeIcon, { path: file.name, className: "bt-ufile-icon" }) : null, +- (0, import_react46.createElement)("span", { className: "bt-ufile-text" }, (0, import_react46.createElement)("span", { className: "bt-ufile-name" }, file.name), meta ? (0, import_react46.createElement)("span", { className: "bt-ufile-meta" }, meta) : null) ++ ui.FileTypeIcon ? (0, import_react47.createElement)(ui.FileTypeIcon, { path: file.name, className: "bt-ufile-icon" }) : null, ++ (0, import_react47.createElement)("span", { className: "bt-ufile-text" }, (0, import_react47.createElement)("span", { className: "bt-ufile-name" }, file.name), meta ? (0, import_react47.createElement)("span", { className: "bt-ufile-meta" }, meta) : null) + ); + })); + } +-function CopyButton({ text }) { +- const [copied, setCopied] = (0, import_react46.useState)(false); ++function CopyButton({ text: text2 }) { ++ const [copied, setCopied] = (0, import_react47.useState)(false); + const copy2 = () => { +- void navigator.clipboard?.writeText(text).then(() => { ++ void navigator.clipboard?.writeText(text2).then(() => { + setCopied(true); + setTimeout(() => setCopied(false), 1e3); + }); + }; +- return (0, import_react46.createElement)("button", { type: "button", className: "bt-tool", "aria-label": copied ? t("Copied") : t("Copy"), title: copied ? t("Copied") : t("Copy"), onClick: copy2 }, copied ? (0, import_react46.createElement)(CheckIcon) : (0, import_react46.createElement)(CopyIcon)); ++ return (0, import_react47.createElement)("button", { type: "button", className: "bt-tool", "aria-label": copied ? t("Copied") : t("Copy"), title: copied ? t("Copied") : t("Copy"), onClick: copy2 }, copied ? (0, import_react47.createElement)(CheckIcon) : (0, import_react47.createElement)(CopyIcon)); + } + var clock3 = (time) => typeof time === "number" ? new Date(time).toLocaleTimeString(dateLocale(), { hour: "numeric", minute: "2-digit" }) : void 0; + function UserMessageCell({ node, sessionId, useRoster, actions, renderMessageImages, openFile, openSkill }) { + const roster = useRoster((value) => value); + const data = node.data ?? {}; +- const { text, attachments, rest } = contentParts(data.content); ++ const { text: text2, attachments, rest } = contentParts(data.content); + const referenceLabels = data.referenceLabels ?? []; + const references = openFile && openSkill ? { openFile, openSkill } : void 0; + const steering = node.kind === "steering"; +@@ -19720,7 +19837,7 @@ + const person = incoming && author.authorKind === "human" && !author.ownerName; + const faceMine = Boolean(room2?.origin === "face-to-face" && !incoming); + const myName = faceMine ? facePeople(room2, roster).find((entry) => entry.mine)?.name || cleanPeerName(roster.self?.displayName, "") || author?.name || "" : ""; +- return (0, import_react46.createElement)( ++ return (0, import_react47.createElement)( + "div", + { + className: incoming ? "bt-umsg bt-umsg-other" : "bt-umsg", +@@ -19730,22 +19847,22 @@ + "data-author-kind": author?.authorKind, + "data-author-name": author?.name + }, +- person ? (0, import_react46.createElement)("span", { className: "bt-lead", title: author.name }, (0, import_react46.createElement)(PersonMark, { name: author.name })) : null, +- faceMine ? (0, import_react46.createElement)("span", { className: "bt-lead", title: myName || void 0 }, (0, import_react46.createElement)(PersonMark, { name: myName || void 0 })) : null, +- (0, import_react46.createElement)( ++ person ? (0, import_react47.createElement)("span", { className: "bt-lead", title: author.name }, (0, import_react47.createElement)(PersonMark, { name: author.name })) : null, ++ faceMine ? (0, import_react47.createElement)("span", { className: "bt-lead", title: myName || void 0 }, (0, import_react47.createElement)(PersonMark, { name: myName || void 0 })) : null, ++ (0, import_react47.createElement)( + "div", + { className: "bt-ustack" }, +- faceMine && myName ? (0, import_react46.createElement)("span", { className: "bt-uauthor" }, myName) : author && (incoming || multiHuman) ? (0, import_react46.createElement)("span", { className: "bt-uauthor" }, author.name, author.ownerName ? (0, import_react46.createElement)("span", { className: "bt-chip" }, t("Friend's Bot")) : null) : null, +- attachments.length > 0 ? (0, import_react46.createElement)(Attachments, { attachments, renderMessageImages }) : null, +- text !== "" || rest.length > 0 ? (0, import_react46.createElement)( ++ faceMine && myName ? (0, import_react47.createElement)("span", { className: "bt-uauthor" }, myName) : author && (incoming || multiHuman) ? (0, import_react47.createElement)("span", { className: "bt-uauthor" }, author.name, author.ownerName ? (0, import_react47.createElement)("span", { className: "bt-chip" }, t("Friend's Bot")) : null) : null, ++ attachments.length > 0 ? (0, import_react47.createElement)(Attachments, { attachments, renderMessageImages }) : null, ++ text2 !== "" || rest.length > 0 ? (0, import_react47.createElement)( + "div", + { className: "bt-ubody", title: clock3(data.time) }, +- (0, import_react46.createElement)(UserText, { text, bots: mentionableBots(roster, sessionId, { fromFriend: Boolean(incoming && author.ownerName) }), referenceLabels, skillNames: data.skillNames ?? [], references, actions }), +- rest.map((block, index) => ui.JsonBlock ? (0, import_react46.createElement)(ui.JsonBlock, { key: index, label: t("Content"), payload: block }) : null) ++ (0, import_react47.createElement)(UserText, { text: text2, bots: mentionableBots(roster, sessionId, { fromFriend: Boolean(incoming && author.ownerName) }), referenceLabels, skillNames: data.skillNames ?? [], references, actions }), ++ rest.map((block, index) => ui.JsonBlock ? (0, import_react47.createElement)(ui.JsonBlock, { key: index, label: t("Content"), payload: block }) : null) + ) : null, +- referenceLabels.length > 0 ? (0, import_react46.createElement)("div", { className: "bt-urefs" }, t("Referenced {labels}", { labels: referenceLabels.join(t(", ")) })) : null ++ referenceLabels.length > 0 ? (0, import_react47.createElement)("div", { className: "bt-urefs" }, t("Referenced {labels}", { labels: referenceLabels.join(t(", ")) })) : null + ), +- text === "" ? null : (0, import_react46.createElement)("div", { className: "bt-utools" }, (0, import_react46.createElement)(CopyButton, { text })) ++ text2 === "" ? null : (0, import_react47.createElement)("div", { className: "bt-utools" }, (0, import_react47.createElement)(CopyButton, { text: text2 })) + ); + } + var USER_MESSAGE_CSS = ` +@@ -19843,33 +19960,33 @@ + } + + // src/client/stats-dock.js +-var import_react47 = require("react"); ++var import_react48 = require("react"); + var STATS_DOCK = "conversation.composer.dock"; + var STATS_ID = "stats"; + var STATS_IDS = [STATS_ID, "activity"]; + var noun = (count, one, many) => `${count} ${Number(count) === 1 ? one : many}`; + function pluralStats(t2) { + return (key, params) => { +- const text = t2(key, params); +- if (key !== "stats.counts" || !params) return text; ++ const text2 = t2(key, params); ++ if (key !== "stats.counts" || !params) return text2; + const { turns, steps } = params; +- return text === `${turns} turns ${steps} steps` ? `${noun(turns, "turn", "turns")} ${noun(steps, "step", "steps")}` : text; ++ return text2 === `${turns} turns ${steps} steps` ? `${noun(turns, "turn", "turns")} ${noun(steps, "step", "steps")}` : text2; + }; + } + function statsDock(entriesOf, id = STATS_ID) { + const shell = () => (entriesOf(STATS_DOCK) ?? []).find((entry) => entry?.component !== StatsDock && entry?.options?.id === id); + function StatsDock(props) { +- const t2 = (0, import_react47.useMemo)(() => typeof props.t === "function" ? pluralStats(props.t) : props.t, [props.t]); ++ const t2 = (0, import_react48.useMemo)(() => typeof props.t === "function" ? pluralStats(props.t) : props.t, [props.t]); + const entry = shell(); + if (!entry || typeof props.usePerformanceUsage !== "function") return null; +- return (0, import_react47.createElement)(entry.component, { ...props, t: t2 }); ++ return (0, import_react48.createElement)(entry.component, { ...props, t: t2 }); + } + return { component: StatsDock, inject: (...args) => shell()?.inject?.(...args) ?? {} }; + } + var statsDocks = (entriesOf) => STATS_IDS.map((id) => ({ id, ...statsDock(entriesOf, id) })); + + // src/client/index.js +-var import_react48 = require("react"); ++var import_react49 = require("react"); + var inject = ["sessions", "uiWorkspace", "uiConversation", "slots", "theme", "layout"]; + function apply(ctx) { + const flybot = createClientApi2(); +@@ -20459,7 +20576,7 @@ + } + return { ...state, reactions }; + }), +- quote: (sessionId, text) => ui2.set((state) => ({ ...state, quote: { sessionId, text, token: Date.now() } })), ++ quote: (sessionId, text2) => ui2.set((state) => ({ ...state, quote: { sessionId, text: text2, token: Date.now() } })), + clearQuote: (token) => ui2.set((state) => state.quote?.token === token ? { ...state, quote: null } : state), + clearUnread: (id) => setUnread(id, false), + setFlags: (id, flags) => after(call("set-flags", { id, ...flags })), +@@ -20487,7 +20604,7 @@ + setPrefs: (prefs) => after(call("set-prefs", prefs)), + setMain: (id, main = true, options = {}) => after(call("set-main", { id, main, ...options })), + updateRoom: (id, patch) => after(call("update-room", { id, ...patch })), +- send: (sessionId, text) => after(call("send", { sessionId: partFor(sessionId), text })), ++ send: (sessionId, text2) => after(call("send", { sessionId: partFor(sessionId), text: text2 })), + answerQuestion: (sessionId, questionId, answer) => after(call("answer-question", { sessionId: partFor(sessionId), questionId, answer })), + answerTaskInput: (id, questionId, answer) => after(call("task-answer-input", { id, questionId, answer })), + // The Done list is the acceptance surface (#401): a result is accepted, or sent back with a note +@@ -20514,7 +20631,7 @@ + connectorBlock: (id, openId) => after(call("connector-block", { id, openId })), + connectorDisconnect: (id) => after(call("connector-disconnect", { id })), + connectorRetry: (id) => after(call("connector-retry", { id })), +- connectorSend: (id, text) => after(call("connector-send", { id, text })), ++ connectorSend: (id, text2) => after(call("connector-send", { id, text: text2 })), + saveCapability: (capability) => after(call("capability-save", capability)), + deleteCapability: (name) => after(call("capability-delete", { name })), + approveCapability: (name) => after(call("capability-approve", { name })), +@@ -20537,7 +20654,7 @@ + usage: () => call("usage", { timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone }), + // `summarize` starts a summary when the entries changed; `regenerate` always does. + memoryView: (botId, flags = {}) => call("memory-view", { botId, ...flags }), +- memoryAsk: (botId, text) => call("memory-ask", { botId, text }), ++ memoryAsk: (botId, text2) => call("memory-ask", { botId, text: text2 }), + memoryForget: (botId, entry) => call("memory-forget", { botId, scope: entry.scope, topic: entry.topic, text: entry.text }), + diagnostics: (payload) => call("diagnostics", payload), + diagnosticsLog: () => call("diagnostics-log"), +@@ -20548,7 +20665,7 @@ + ui2.set((state) => ({ ...state, toast: { text: error?.message ?? String(error), token: Date.now() } })); + }, + // A short confirmation (e.g. "Invite link copied") in the same toast, without the error styling. +- notify: (text) => ui2.set((state) => ({ ...state, toast: { text: String(text), tone: "info", token: Date.now() } })), ++ notify: (text2) => ui2.set((state) => ({ ...state, toast: { text: String(text2), tone: "info", token: Date.now() } })), + dismissToast: (token) => ui2.set((state) => state.toast && (token === void 0 || state.toast.token === token) ? { ...state, toast: null } : state) + }; + if (browsers) { +@@ -20766,6 +20883,7 @@ + installCompiledStyles(USER_MESSAGE_CSS, "user-messages"), + installCompiledStyles(MOBILE_CSS, "mobile"), + installCompiledStyles(PANE_MIN_WIDTH_CSS, "panes"), ++ installCompiledStyles(PROJECT_BOARD_CSS, "project-board"), + typeof document !== "undefined" ? watchShellLevel(document) : null, + browsers ? installCompiledStyles(BROWSER_CSS, "browser") : null, + installTheme(), +@@ -20783,7 +20901,8 @@ + shadow("shell.overlay", { id: "bot", order: 50, inject: face }, Overlays), + shadow("shell.overlay", { id: "bot-signin", order: 55, inject: face }, DeepSeekSignIn), + shadow("shell.overlay", { id: "bot-toast", order: 60, inject: face }, ErrorToast), +- shadow("shell.overlay", { id: "flybot-approvals", order: 58, inject: face }, FlyBotApprovals) ++ shadow("shell.overlay", { id: "flybot-approvals", order: 58, inject: face }, FlyBotApprovals), ++ shadow("shell.overlay", { id: "project-board", order: 57 }, ProjectBoardOverlay) + ]; + const installConversation = () => [ + installCompiledStyles(CONVERSATION_CSS, "conversation"), +@@ -20836,7 +20955,7 @@ + ]; + ctx.effect(() => { + const style = installStyles(TURN_FAILURES_CSS, "turn-failures"); +- const off = registry().register("flybot.session.dock", { id: "flybot-turn-failures", order: 20, component: (props) => (0, import_react48.createElement)(TurnFailures, { ...props, work }) }); ++ const off = registry().register("flybot.session.dock", { id: "flybot-turn-failures", order: 20, component: (props) => (0, import_react49.createElement)(TurnFailures, { ...props, work }) }); + return () => { + off(); + style.remove(); +diff --git a/src/client/index.js b/src/client/index.js +--- a/src/client/index.js ++++ b/src/client/index.js +@@ -36,6 +36,7 @@ + import { KANBAN_CSS } from './kanban.js' + import { MOBILE_CSS } from './mobile.js' + import { PANE_MIN_WIDTH_CSS, watchShellLevel } from './panes.js' ++import { PROJECT_BOARD_CSS, ProjectBoardOverlay } from './project-board.js' + import { STORAGE, migrateStorage } from './storage.js' + import { friendLineNews } from './friend-lines.js' + import { groupsFor, readMode, surfaceGroups, writeMode } from './surface.js' +@@ -887,6 +888,7 @@ + installCompiledStyles(USER_MESSAGE_CSS, 'user-messages'), + installCompiledStyles(MOBILE_CSS, 'mobile'), + installCompiledStyles(PANE_MIN_WIDTH_CSS, 'panes'), ++ installCompiledStyles(PROJECT_BOARD_CSS, 'project-board'), + typeof document !== 'undefined' ? watchShellLevel(document) : null, + browsers ? installCompiledStyles(BROWSER_CSS, 'browser') : null, + installTheme(), +@@ -905,6 +907,7 @@ + shadow('shell.overlay', { id: 'bot-signin', order: 55, inject: face }, DeepSeekSignIn), + shadow('shell.overlay', { id: 'bot-toast', order: 60, inject: face }, ErrorToast), + shadow('shell.overlay', { id: 'flybot-approvals', order: 58, inject: face }, FlyBotApprovals), ++ shadow('shell.overlay', { id: 'project-board', order: 57 }, ProjectBoardOverlay), + ] + const installConversation = () => [ + installCompiledStyles(CONVERSATION_CSS, 'conversation'), +diff --git a/src/host/channel.js b/src/host/channel.js +--- a/src/host/channel.js ++++ b/src/host/channel.js +@@ -673,7 +673,7 @@ + case 'project-of': + case 'project-board': + await rt.load() +- return ok(await rt.projectEndpoint(endpoint, body)) ++ return ok(await rt.projectEndpoint(endpoint, body, actor)) + case 'share-brief': + await rt.load() + await rt.setBriefShare(String(body.id ?? ''), String(body.botId ?? ''), body.share === true, actor) +diff --git a/src/host/store.js b/src/host/store.js +--- a/src/host/store.js ++++ b/src/host/store.js +@@ -420,16 +420,42 @@ + if (project !== null) project.updatedAt = Math.max(project.updatedAt, Date.now()) + return project + } +- const projectEndpoint = async (endpoint, body = {}) => { ++ // Who may act on a Project (#1710). The record names its owner member; an install with no ++ // session falls back to the first member, exactly like `requesterOf` in tasks.js. The host ++ // decides this, never the page: a caller that is not the owner may read and nothing else. ++ const projectActorId = actor => projectText(actor?.memberId, 120) || Object.keys(state.members ?? {})[0] || null ++ const projectCanManage = (project, actor) => { ++ if (project === null || typeof project !== 'object') return false ++ const owner = projectText(project.ownerUserId, 120) ++ if (owner === '') return true ++ const actorId = projectActorId(actor) ++ return actorId !== null && actorId === owner ++ } ++ const requireProjectOwner = (project, actor) => { ++ if (project === null) return ++ if (projectCanManage(project, actor)) return ++ throw projectRefusal('Only the owner of this Project may change it', 'bot/forbidden') ++ } ++ ++ const projectEndpoint = async (endpoint, body = {}, actor = null) => { + switch (endpoint) { + case 'projects': return { projects: listProjects() } + case 'project-create': { +- const project = createProject(body) ++ // The caller is stamped as the owner; a member id from the body cannot mint a ++ // Project someone else manages. ++ const project = createProject({ ...body, ownerUserId: projectActorId(actor) ?? undefined }) + await save() + return { project } + } +- case 'project-read': return { project: readProject(projectText(body.id, 200)) } ++ case 'project-read': { ++ const project = readProject(projectText(body.id, 200)) ++ return { project, canManage: projectCanManage(project, actor) } ++ } + case 'project-bind': { ++ const target = body.projectId === undefined || body.projectId === null ++ ? projectOfMember(projectText(body.kind, 20), projectText(body.id, 200)) ++ : requireProject(projectText(body.projectId, 200)) ++ requireProjectOwner(target, actor) + const project = bindProject(body) + await save() + return { project } +diff --git a/src/host/authz.js b/src/host/authz.js +--- a/src/host/authz.js ++++ b/src/host/authz.js +@@ -65,7 +65,7 @@ + 'workflow-save', + // The Project record and its bindings (#1691) are team state: a signed-in actor may create, + // read and bind, like the task channel. +- 'projects', 'project-create', 'project-read', 'project-bind', 'project-of', ++ 'projects', 'project-create', 'project-read', 'project-bind', 'project-of', 'project-board', + ]) + + const deny = (code, message) => ({ ok: false, code, message }) diff --git a/patches/ds-bot/files/src/client/project-board.js b/patches/ds-bot/files/src/client/project-board.js new file mode 100644 index 000000000..192f6fc19 --- /dev/null +++ b/patches/ds-bot/files/src/client/project-board.js @@ -0,0 +1,117 @@ +// The Project board view (#1710): one Project, the rows its read model already aggregated +// (host `project-board`), grouped by where the work lives. The rows are the tasks themselves — +// `row.id` is the underlying task id — so opening one opens that task and nothing is copied here. +import { createElement as h, useCallback, useEffect, useState } from 'react' +import { call } from './api.js' +import { t } from './i18n.js' + +export const PROJECT_BOARD_CSS = ` +.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-row .bt-project-ref{margin-left:auto;font-size:11px;opacity:.6} +.bt-project-empty{padding:22px 4px;opacity:.7} +.bt-project-actions{display:flex;gap:8px;margin:0 0 14px} +` + +// Sources in the order the board reads them; a source with no rows is left out so three empty +// groups never crowd out the one that has work. +export const PROJECT_SOURCES = ['bot', 'group', 'workflow'] +export const SOURCE_LABELS = { bot: 'Bots', group: 'Groups', workflow: 'Workflows' } + +const text = value => (typeof value === 'string' ? value.trim() : '') + +// Pure: no fetch, no React, no store. `rows` is the host's read model, `canManage` comes from +// `project-read` and only decides what the caller may *ask* for — the host refuses regardless. +export function projectBoardView({ project, rows, canManage } = {}) { + const list = Array.isArray(rows) ? rows.filter(row => row !== null && typeof row === 'object' && text(row.id) !== '') : [] + const groups = PROJECT_SOURCES + .map(source => ({ + source, + label: SOURCE_LABELS[source], + rows: list + .filter(row => row.source === source) + .map(row => ({ + 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, + })), + })) + .filter(group => group.rows.length > 0) + return { + projectId: text(project?.id) || null, + title: text(project?.name) || 'Project', + canManage: canManage === true, + groups, + count: list.length, + empty: list.length === 0, + emptyText: 'Nothing under this Project yet.', + } +} + +function ProjectBoardRows({ view, onOpen }) { + // An empty Project is a state, not an error: the view says so and renders no groups. + if (view.empty) return h('div', { className: 'bt-project-empty' }, t(view.emptyText)) + return view.groups.map(group => h('section', { key: group.source, className: 'bt-project-group', 'data-source': group.source }, + h('h4', null, t(group.label)), + group.rows.map(row => h('div', { + key: row.key, + className: 'bt-project-row', + 'data-task-id': row.id, + 'data-column': row.column, + role: 'button', + tabIndex: 0, + onClick: () => onOpen?.(row.id), + onKeyDown: event => { if (event.key === 'Enter' || event.key === ' ') onOpen?.(row.id) }, + }, + h('span', { className: 'bt-project-column' }, t(row.column)), + h('span', { className: 'bt-project-title' }, row.assignee ? `@${row.assignee}` : '—'), + row.runId === null ? null : h('span', { className: 'bt-project-ref' }, row.nodeId === null ? row.runId : `${row.runId} · ${row.nodeId}`))))) +} + +export function ProjectBoardPane({ project, rows, canManage, onOpen }) { + const view = projectBoardView({ project, rows, canManage }) + return h('div', { className: 'bt-project-board', 'data-can-manage': view.canManage ? 'true' : 'false' }, + h('h3', null, view.title), + // Management affordances are drawn only for the owner; the refusal itself is the host's. + view.canManage ? h('div', { className: 'bt-project-actions' }, h('button', { type: 'button' }, t('Project settings')), h('button', { type: 'button' }, t('Members'))) : null, + h(ProjectBoardRows, { view, onOpen })) +} + +// The pane entry point (#1710): `#project=` opens one Project's board, `#project` the first. +// Reading is a POST through the same host channel every other view uses. +export function ProjectBoardOverlay() { + const [state, setState] = useState(null) + const close = useCallback(() => { + if (typeof window !== 'undefined') window.location.hash = '' + setState(null) + }, []) + useEffect(() => { + if (typeof window === 'undefined') return + if (!window.location.hash.startsWith('#project')) return + const wanted = window.location.hash.slice('#project'.length).replace(/^=/, '') + let live = true + void (async () => { + try { + const listing = await call('projects') + const projects = listing?.projects ?? [] + const project = wanted === '' ? projects[0] : projects.find(item => item.id === wanted) + if (project === undefined) { if (live) setState({ project: null, rows: [], canManage: false }); return } + const [read, board] = await Promise.all([call('project-read', { id: project.id }), call('project-board', { id: project.id })]) + if (live) setState({ project: read?.project ?? project, rows: board?.tasks ?? [], canManage: read?.canManage === true }) + } catch { if (live) setState({ project: null, rows: [], canManage: false }) } + })() + return () => { live = false } + }, []) + if (state === null) return null + return h('div', { className: 'bt-overlay bt-project-overlay', role: 'dialog', 'aria-label': t('Project board') }, + h('button', { type: 'button', className: 'bt-project-close', onClick: close }, t('Close')), + state.project === null + ? h('div', { className: 'bt-project-empty' }, t('No Project here.')) + : h(ProjectBoardPane, { project: state.project, rows: state.rows, canManage: state.canManage, onOpen: id => { window.location.hash = `#task=${id}` } })) +} diff --git a/patches/ds-bot/files/test/project-board-view.test.js b/patches/ds-bot/files/test/project-board-view.test.js new file mode 100644 index 000000000..f42af09b2 --- /dev/null +++ b/patches/ds-bot/files/test/project-board-view.test.js @@ -0,0 +1,107 @@ +// The Project board view (#1710): one view over the read model Stage 2.2 shipped. It groups the +// rows by where the work lives, carries the run/node a workflow row came from, and keeps the row +// id equal to the underlying task id, so opening a row opens that task and no row is a copy. +import assert from 'node:assert/strict' +import { test } from 'node:test' +import { fileURLToPath } from 'node:url' +import { runInNewContext } from 'node:vm' +import { build } from 'esbuild' + +const bundle = await build({ + entryPoints: [fileURLToPath(new URL('../src/client/project-board.js', import.meta.url))], + bundle: true, format: 'cjs', platform: 'node', write: false, + external: ['react', 'react-dom'], +}) +const module = { exports: {} } +runInNewContext(bundle.outputFiles[0].text, { + module, exports: module.exports, setTimeout, clearTimeout, setInterval, clearInterval, Date, Promise, console, + require: (name) => { + const platform = { + react: { + createElement: (type, props, ...children) => ({ type, props: props ?? {}, children: children.flat(Infinity) }), + useCallback: fn => fn, + useEffect: () => {}, + useState: initial => [initial, () => {}], + }, + } + if (!(name in platform)) throw new Error(`no platform module ${name}`) + return platform[name] + }, +}) +const { ProjectBoardPane, projectBoardView } = module.exports + +const rows = [ + { id: 'task-b1', source: 'bot', column: 'in-progress', assignee: 'Dev4', updatedAt: 3 }, + { id: 'task-g1', source: 'group', column: 'done', assignee: 'Chief', updatedAt: 2 }, + { id: 'task-w1', source: 'workflow', column: 'not-started', assignee: null, runId: 'run-7', nodeId: 'build', updatedAt: 1 }, +] + +const all = (node, out = []) => { + if (Array.isArray(node)) { for (const item of node) all(item, out); return out } + if (node === null || typeof node !== 'object') return out + // The fake createElement does not render components; the test asks them for their tree. + if (typeof node.type === 'function') return all(node.type(node.props), out) + out.push(node) + all(node.children, out) + return out +} +// Arrays minted inside the vm have another realm's prototype, so compare plain copies. +const plain = value => JSON.parse(JSON.stringify(value)) +const textOf = node => (typeof node === 'string' ? node : node && typeof node === 'object' ? (node.children ?? []).map(textOf).join('') : '') + +test('the board groups rows from all three sources and keeps each task id', () => { + const view = plain(projectBoardView({ project: { id: 'project-1', name: 'FlyBot' }, rows, canManage: true })) + assert.equal(view.projectId, 'project-1') + assert.equal(view.title, 'FlyBot') + assert.equal(view.canManage, true) + assert.equal(view.empty, false) + assert.equal(view.count, 3) + assert.deepEqual(view.groups.map(group => group.source), ['bot', 'group', 'workflow']) + assert.deepEqual(view.groups.map(group => group.rows.length), [1, 1, 1]) + const ids = view.groups.flatMap(group => group.rows.map(row => row.id)) + assert.deepEqual(ids, ['task-b1', 'task-g1', 'task-w1'], 'the row id is the task id') + assert.deepEqual(view.groups.flatMap(group => group.rows.map(row => row.key)), ['task:task-b1', 'task:task-g1', 'task:task-w1']) +}) + +test('a workflow row carries its run and node; other rows carry none', () => { + const view = plain(projectBoardView({ project: { id: 'p', name: 'P' }, rows, canManage: false })) + const [botRow] = view.groups[0].rows + const workflowRow = view.groups[2].rows[0] + assert.equal(botRow.runId, null) + assert.equal(botRow.nodeId, null) + assert.equal(workflowRow.runId, 'run-7') + assert.equal(workflowRow.nodeId, 'build') + assert.equal(workflowRow.assignee, null) + assert.equal(botRow.column, 'in-progress') +}) + +test('a Project with nothing under it is an empty state, not an error', () => { + const view = plain(projectBoardView({ project: { id: 'p', name: 'P' }, rows: [], canManage: false })) + assert.equal(view.empty, true) + assert.deepEqual(view.groups, []) + assert.equal(view.count, 0) + const missing = plain(projectBoardView({})) + assert.equal(missing.empty, true) + assert.equal(missing.projectId, null) + assert.equal(missing.canManage, false, 'no canManage answer is read as read-only') +}) + +test('the pane draws the grouped rows and only the owner sees the management controls', () => { + const owner = all(ProjectBoardPane({ project: { id: 'p', name: 'FlyBot' }, rows, canManage: true })) + const member = all(ProjectBoardPane({ project: { id: 'p', name: 'FlyBot' }, rows, canManage: false })) + assert.match(textOf(owner[0]), /FlyBot/) + const ownerRows = owner.filter(node => node.props?.['data-task-id'] !== undefined) + assert.deepEqual(ownerRows.map(node => node.props['data-task-id']), ['task-b1', 'task-g1', 'task-w1']) + const workflow = ownerRows[2] + assert.match(textOf(workflow), /run-7 · build/) + assert.equal(owner.some(node => textOf(node) === 'Project settings'), true) + const memberRows = member.filter(node => node.props?.['data-task-id'] !== undefined) + assert.deepEqual(memberRows.map(node => node.props['data-task-id']), ['task-b1', 'task-g1', 'task-w1'], 'a member still reads the board') + assert.equal(member.some(node => textOf(node) === 'Project settings'), false) +}) + +test('an empty Project renders the empty text and no row', () => { + const nodes = all(ProjectBoardPane({ project: { id: 'p', name: 'P' }, rows: [], canManage: false })) + assert.equal(nodes.filter(node => node.props?.['data-task-id'] !== undefined).length, 0) + assert.match(nodes.map(textOf).join(' '), /Nothing under this Project yet/) +}) diff --git a/patches/ds-bot/files/test/projects.test.js b/patches/ds-bot/files/test/projects.test.js index d01f89baf..cdd88ddf0 100644 --- a/patches/ds-bot/files/test/projects.test.js +++ b/patches/ds-bot/files/test/projects.test.js @@ -5,15 +5,31 @@ import assert from 'node:assert/strict' import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' +import { createHash } from 'node:crypto' import { test } from 'node:test' import { apply } from '../index.js' import { fakeContext } from './fake-ctx.js' -const seed = () => ({ +const hashToken = token => createHash('sha256').update(token).digest('hex') + +// #1710: a second member and two signed-in sessions, so an owner and a non-owner caller both +// exist without a second process. +const seed = (sessions = true) => ({ version: 2, revision: 1, mainBotIds: ['a'], - members: { 'owner-1': { id: 'owner-1', createdAt: 1, designatedChiefId: 'a' } }, + members: { + 'owner-1': { id: 'owner-1', createdAt: 1, designatedChiefId: 'a' }, + 'owner-2': { id: 'owner-2', createdAt: 2, designatedChiefId: null }, + }, + ...(sessions + ? { + authSessions: { + 's-a': { id: 's-a', memberId: 'owner-1', tokenHash: hashToken('tok-a'), createdAt: 1 }, + 's-b': { id: 's-b', memberId: 'owner-2', tokenHash: hashToken('tok-b'), createdAt: 2 }, + }, + } + : {}), bots: { a: { id: 'a', name: 'Chief', role: 'Boss', sessionId: 'a', createdAt: 1, ownerId: 'owner-1' }, b: { id: 'b', name: 'Writer', role: 'Write', sessionId: 'b', createdAt: 2, ownerId: 'owner-1' }, @@ -22,8 +38,8 @@ const seed = () => ({ agents: {}, exchanges: [], exchangeTurns: {}, questions: {}, prefs: {}, grants: {}, }) -async function start(home = mkdtempSync(join(tmpdir(), 'ds-bot-projects-'))) { - if (!existsSync(join(home, 'state.json'))) writeFileSync(join(home, 'state.json'), JSON.stringify(seed())) +async function start(home = mkdtempSync(join(tmpdir(), 'ds-bot-projects-')), { sessions = true } = {}) { + if (!existsSync(join(home, 'state.json'))) writeFileSync(join(home, 'state.json'), JSON.stringify(seed(sessions))) const ctx = fakeContext() const agents = new Map() const agentOf = (sessionId) => { @@ -41,7 +57,11 @@ async function start(home = mkdtempSync(join(tmpdir(), 'ds-bot-projects-'))) { const register = ctx.connection.fetch.register ctx.connection.fetch.register = (options) => { route = options; return register(options) } apply(ctx, { home }) - const call = async (endpoint, payload) => (await route.fetch(new Request('http://localhost/api/bot', { method: 'POST', body: JSON.stringify({ endpoint, payload }) }))).json() + const call = async (endpoint, payload, token = 'tok-a') => (await route.fetch(new Request('http://localhost/api/bot', { + method: 'POST', + headers: token ? { 'x-flybot-member-session': token } : {}, + body: JSON.stringify({ endpoint, payload }), + }))).json() const stop = async ({ keepHome = false } = {}) => { await ctx.dispose() if (!keepHome) rmSync(home, { recursive: true, force: true, maxRetries: 5 }) @@ -143,3 +163,42 @@ test('the record and its bindings survive a store re-open', async () => { rmSync(home, { recursive: true, force: true, maxRetries: 5 }) } }) + +// #1710: the owner of a Project may manage it, a member reads the same board read-only, and the +// refusal comes from the host action — not from a control the page happens to hide. +test('the caller becomes the owner, and only the owner may change the Project', async () => { + const { call, stop } = await start() + try { + const project = created(await call('project-create', { name: 'Owned', repository: 'acme/owned', ownerUserId: 'owner-2' }, 'tok-a')) + assert.equal(project.ownerUserId, 'owner-1', 'the caller is stamped as the owner, not the body') + + assert.equal(value(await call('project-read', { id: project.id }, 'tok-a')).canManage, true) + assert.equal(value(await call('project-read', { id: project.id }, 'tok-b')).canManage, false) + + const refused = await call('project-bind', { kind: 'bot', id: 'a', projectId: project.id }, 'tok-b') + assert.equal(refused.ok, false) + assert.equal(refused.error.code, 'bot/forbidden') + assert.equal(value(await call('project-of', { kind: 'bot', id: 'a' })).project, null, 'the refusal wrote nothing') + + assert.equal(created(await call('project-bind', { kind: 'bot', id: 'a', projectId: project.id }, 'tok-a')).id, project.id) + } finally { await stop() } +}) + +test('an install with no signed-in session falls back to its first member as the owner', async () => { + const { call, stop } = await start(undefined, { sessions: false }) + try { + const project = created(await call('project-create', { name: 'Solo', repository: 'acme/solo' })) + assert.equal(project.ownerUserId, 'owner-1', 'the single-member install stamps its member') + assert.equal(value(await call('project-read', { id: project.id })).canManage, true) + } finally { await stop() } +}) + +test('a signed-in member may read the Project board even though it may not manage it', async () => { + const { call, stop } = await start() + try { + const project = created(await call('project-create', { name: 'Board', repository: 'acme/board' }, 'tok-a')) + const board = await call('project-board', { id: project.id }, 'tok-b') + assert.equal(board.ok, true, JSON.stringify(board), 'project-board is a known endpoint once someone signed in') + assert.deepEqual(value(board).tasks, []) + } finally { await stop() } +}) From 9bf317d5a511a211c4c35ba70afd23d0ff67f857 Mon Sep 17 00:00:00 2001 From: Danyang Chen Date: Fri, 30 Oct 2026 00:02:38 +0800 Subject: [PATCH 2/2] Translate the Project board strings and move the patch-count guard to 11 The client translates every literal it calls t() with, so the three new labels were missing from DICTIONARY.zh and test/i18n.test.js failed at the tip. Adding them to ZH, and to the 0001 hunk that carries that file, keeps core/ equal to ds-bot/ + patches/ds-bot/. The stored patch set is 11 files now that 0017-project-board-gating.patch exists, so .github/scripts/ds-bot-patches.test.mjs asserts 11 instead of 10, still exact so a dropped patch fails. --- .github/scripts/ds-bot-patches.test.mjs | 2 +- core/client.js | 5 +- core/src/client/i18n.js | 3 + patches/ds-bot/0001-core-fixes.patch | 11 + .../ds-bot/0017-project-board-gating.patch | 251 +++++++++--------- 5 files changed, 151 insertions(+), 121 deletions(-) diff --git a/.github/scripts/ds-bot-patches.test.mjs b/.github/scripts/ds-bot-patches.test.mjs index b896a6795..7be9c8d1b 100644 --- a/.github/scripts/ds-bot-patches.test.mjs +++ b/.github/scripts/ds-bot-patches.test.mjs @@ -193,7 +193,7 @@ if (gitAvailable) { const root = join(import.meta.dirname, '..', '..') const patchesDir = join(root, 'patches', 'ds-bot') const stored = readdirSync(patchesDir).filter(name => /^\d{4}-.*\.patch$/.test(name)).sort() - assert.equal(stored.length, 10) + assert.equal(stored.length, 11) const viaGit = mkdtempSync(join(tmpdir(), 'patch-git-')) const viaOurs = mkdtempSync(join(tmpdir(), 'patch-ours-')) try { diff --git a/core/client.js b/core/client.js index 990e7720f..a05cf06c7 100644 --- a/core/client.js +++ b/core/client.js @@ -1352,7 +1352,10 @@ var ZH = { "Use this folder": "使用这个文件夹", "Up one level": "上一级", "No folders inside.": "里面没有文件夹。", - "Some folders are not shown.": "还有一些文件夹没有显示。" + "Some folders are not shown.": "还有一些文件夹没有显示。", + "Project settings": "项目设置", + "Project board": "项目看板", + "No Project here.": "这里没有项目。" }; var HOST_ERRORS = [ [/^Model (.+) is not available\. Available: (.*)$/, "模型 $1 不可用。可用的:$2"], diff --git a/core/src/client/i18n.js b/core/src/client/i18n.js index dffa37098..072b62003 100644 --- a/core/src/client/i18n.js +++ b/core/src/client/i18n.js @@ -1306,6 +1306,9 @@ const ZH = { 'Up one level': '上一级', 'No folders inside.': '里面没有文件夹。', 'Some folders are not shown.': '还有一些文件夹没有显示。', + 'Project settings': '项目设置', + 'Project board': '项目看板', + 'No Project here.': '这里没有项目。', } // Host errors stay English, since Bots read them too; they are translated where the diff --git a/patches/ds-bot/0001-core-fixes.patch b/patches/ds-bot/0001-core-fixes.patch index 74cb81e42..2b33ea472 100644 --- a/patches/ds-bot/0001-core-fixes.patch +++ b/patches/ds-bot/0001-core-fixes.patch @@ -1412,6 +1412,17 @@ diff --git a/src/client/i18n.js b/src/client/i18n.js 'Working folder': '工作文件夹', 'Working folder: {folder}': '工作文件夹:{folder}', Folder: '文件夹', +@@ -837,7 +1305,10 @@ + 'Use this folder': '使用这个文件夹', + 'Up one level': '上一级', + 'No folders inside.': '里面没有文件夹。', + 'Some folders are not shown.': '还有一些文件夹没有显示。', ++ 'Project settings': '项目设置', ++ 'Project board': '项目看板', ++ 'No Project here.': '这里没有项目。', + } + + // Host errors stay English, since Bots read them too; they are translated where the @@ -853,8 +853,11 @@ const HOST_ERRORS = [ [/^Only the user, a Main Bot, or the admin of "(.+)" can change it$/, '只有你、主 Bot 或「$1」的管理员可以修改'], [/^A key must have at least (\d+) characters$/, '密钥至少 $1 个字符'], diff --git a/patches/ds-bot/0017-project-board-gating.patch b/patches/ds-bot/0017-project-board-gating.patch index 7d755b15d..d3b9e4fb0 100644 --- a/patches/ds-bot/0017-project-board-gating.patch +++ b/patches/ds-bot/0017-project-board-gating.patch @@ -3,7 +3,19 @@ FlyBot patch 0017-project-board-gating on Fei2411/DeepSeek-Bot@2b10c70 (ds-bot/) diff --git a/client.js b/client.js --- a/client.js +++ b/client.js -@@ -1438,7 +1438,7 @@ +@@ -1352,7 +1352,10 @@ + "Use this folder": "使用这个文件夹", + "Up one level": "上一级", + "No folders inside.": "里面没有文件夹。", +- "Some folders are not shown.": "还有一些文件夹没有显示。" ++ "Some folders are not shown.": "还有一些文件夹没有显示。", ++ "Project settings": "项目设置", ++ "Project board": "项目看板", ++ "No Project here.": "这里没有项目。" + }; + var HOST_ERRORS = [ + [/^Model (.+) is not available\. Available: (.*)$/, "模型 $1 不可用。可用的:$2"], +@@ -1438,7 +1441,7 @@ "saved by a newer FlyBot; update FlyBot": "由更新的 FlyBot 保存;请更新 FlyBot" }; Object.assign(ZH, HOST_ZH); @@ -12,7 +24,7 @@ diff --git a/client.js b/client.js var service = null; var bound = null; function installLocale(locale) { -@@ -1489,23 +1489,23 @@ +@@ -1489,23 +1492,23 @@ } const message = String(error?.message ?? error ?? "error").slice(0, 300); const code = typeof error?.code === "string" ? error.code : void 0; @@ -42,7 +54,7 @@ diff --git a/client.js b/client.js if (entries.length > MAX) entries.splice(0, entries.length - MAX); } var quietly = (where) => (error) => noteClientError(where, error); -@@ -1546,9 +1546,9 @@ +@@ -1546,9 +1549,9 @@ var PASTE_NOTE = "FlyBot copied the diagnostics to the clipboard. Paste them here."; var REST_NOTE = "The recent errors do not fit in the link. FlyBot copied the diagnostics to the clipboard: paste them below this line."; function issueUrl(base, { what = "", diagnostics }) { @@ -55,7 +67,7 @@ diff --git a/client.js b/client.js const full2 = build(diagnostics); if (full2.length <= URL_LIMIT) return { url: full2, inline: true }; const facts = build(`${diagnostics.split("\n\n")[0]} -@@ -1602,8 +1602,8 @@ +@@ -1602,8 +1605,8 @@ // src/shared/remote-label.js function cleanPeerName(name, fallback = "") { @@ -66,7 +78,7 @@ diff --git a/client.js b/client.js } function normRemoteHandle(handle) { return `@${String(handle ?? "").trim().replace(/^@+/, "").toLowerCase()}`; -@@ -1708,10 +1708,10 @@ +@@ -1708,10 +1711,10 @@ const handle = String(record?.handle ?? "").replace(/^@+/, "").trim(); const bare2 = remoteBotName(record, "").replace(/^@+/, "").trim(); const unusable = (value) => { @@ -81,7 +93,7 @@ diff --git a/client.js b/client.js return false; }; if (!unusable(bare2)) return bare2; -@@ -1772,11 +1772,11 @@ +@@ -1772,11 +1775,11 @@ const friends = [...new Set((Array.isArray(details.remotes) ? details.remotes : []).map((remote) => cleanPeerName(remote?.owner || remote?.name, "")).filter(Boolean))]; return { bots, friends }; } @@ -95,7 +107,7 @@ diff --git a/client.js b/client.js return true; } catch { return false; -@@ -1924,8 +1924,8 @@ +@@ -1924,8 +1927,8 @@ } return [...homes.values()]; } @@ -106,7 +118,7 @@ diff --git a/client.js b/client.js const match = value.match(/\n?([\s\S]*?)\n?<\/untrusted-message>/); return match ? match[1] : value; } -@@ -2045,9 +2045,9 @@ +@@ -2045,9 +2048,9 @@ var CHARACTER_COLORS = ["black", "brown", "red", "orange", "yellow", "green", "cyan", "blue", "violet", "magenta", "gray", "aurora"]; var DEFAULT_COLORS = CHARACTER_COLORS.filter((color) => !["black", "gray", "brown"].includes(color) && !GRADIENTS[color]); var isColor = (color) => isHex(color) || Object.hasOwn(INKS, color); @@ -118,7 +130,7 @@ diff --git a/client.js b/client.js return value >>> 0; } function defaultColor(id) { -@@ -2168,8 +2168,8 @@ +@@ -2168,8 +2171,8 @@ return ""; } } @@ -129,7 +141,7 @@ diff --git a/client.js b/client.js var maskBox = (size) => `maskUnits="userSpaceOnUse" x="${-size}" y="${-size}" width="${size * 3}" height="${size * 3}"`; var holes = (def, eyes) => `${eyes}`; var MASK_ID = "%MASK%"; -@@ -2220,30 +2220,30 @@ +@@ -2220,30 +2223,30 @@ var NAME_GOES_ON = /^(?:[a-z0-9_\-/\\]|\.[a-z0-9_-])/i; var ADDRESS_BEFORE = /[a-z0-9_./]/i; var LITERAL = /```[\s\S]*?(?:```|$)|`[^`\n]*`|\[[^\]\n]*\]\([^)\s]*(?:\s+(?:"[^"\n]*"|'[^'\n]*'))?\)/g; @@ -170,7 +182,7 @@ diff --git a/client.js b/client.js const skip = [ ...[...value.matchAll(LITERAL)].map((match) => ({ start: match.index, end: match.index + match[0].length })), ...referenceSpans(value, references) -@@ -2261,8 +2261,8 @@ +@@ -2261,8 +2264,8 @@ } return spans; } @@ -181,7 +193,7 @@ diff --git a/client.js b/client.js const skip = [ ...[...value.matchAll(LITERAL)].map((match) => ({ start: match.index, end: match.index + match[0].length })), ...referenceSpans(value, references) -@@ -2288,16 +2288,16 @@ +@@ -2288,16 +2291,16 @@ var FENCE_ATTR = /\bfrom\s*=\s*(?:"[^"]*"|'[^']*')\s*>/gi; var FENCE_TAIL = /<[^<>]*\buntrusted-(?:message|file)\b[^<>]*$/i; var FENCE_BARE = /(?:\/\s*)?untrusted-(?:message|file)\b\s*>?/gi; @@ -202,7 +214,7 @@ diff --git a/client.js b/client.js for (let previous; previous !== out; ) { previous = out; const open = out.indexOf(" entry.reminder_prompt) : []]; return prompts.filter((prompt) => typeof prompt === "string" && prompt.trim() !== ""); -@@ -2380,13 +2380,13 @@ +@@ -2380,13 +2383,13 @@ return []; } } @@ -267,7 +279,7 @@ diff --git a/client.js b/client.js } var COPY_FENCE = /untrusted-(?:message|file)\b/i; var escapeName = (name) => name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); -@@ -2396,8 +2396,8 @@ +@@ -2396,8 +2399,8 @@ const hop = `(?:${known.map(escapeName).join("|")})`; return new RegExp(`^[ \\t]*${hop}(?:[ \\t]*→[ \\t]*${hop})+[ \\t]*$`).test(line2); }; @@ -278,7 +290,7 @@ diff --git a/client.js b/client.js const known = sortedNames(names); if (known.length === 0) return body; const hop = `(?:${known.map(escapeName).join("|")})`; -@@ -2405,8 +2405,8 @@ +@@ -2405,8 +2408,8 @@ const found = trace.exec(body); return found ? body.slice(found[0].length) : body; } @@ -289,7 +301,7 @@ diff --git a/client.js b/client.js for (let previous; previous !== body; ) { previous = body; body = stripHeader(body); -@@ -2458,14 +2458,14 @@ +@@ -2458,14 +2461,14 @@ const at = (id) => news?.[id]?.seen ? news[id].at ?? 0 : 0; return ids.reduce((best, id) => at(id) > at(best) ? id : best, ids[0] ?? null); } @@ -306,7 +318,7 @@ diff --git a/client.js b/client.js const line2 = raw.trim().replace(/\*\*|__/g, ""); const heading = /^#{1,6}\s+(.+)$/.exec(line2); if (heading) { -@@ -2580,17 +2580,17 @@ +@@ -2580,17 +2583,17 @@ for (let index = outline.length - 1; index >= 0; index -= 1) { const entry = outline[index]; if (hiddenTurns.includes(entry.turn)) continue; @@ -331,7 +343,7 @@ diff --git a/client.js b/client.js } function dropKnownTrace(line2, names) { const known = [...new Set((Array.isArray(names) ? names : []).map((name) => String(name ?? "").trim()).filter((name) => name !== ""))].sort((a, b) => b.length - a.length); -@@ -2604,8 +2604,8 @@ +@@ -2604,8 +2607,8 @@ return /^[\s,,.。::]*$/.test(rest) ? "" : rest; } var botNames = (roster) => [...roster?.bots ?? [], ...roster?.remoteBots ?? [], ...roster?.agents ?? []].map((entry) => entry?.name).filter((name) => typeof name === "string" && name !== ""); @@ -342,7 +354,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) { -@@ -2624,10 +2624,10 @@ +@@ -2624,10 +2627,10 @@ return t("{tokens} tok · Cache hit {hit}%", { tokens: formatTokens(input + output), hit }); } function faceRowTitle(title) { @@ -356,7 +368,7 @@ diff --git a/client.js b/client.js } function modelOf(bot, roster) { const ref = bot.model ?? bot.appliedModel; -@@ -2941,8 +2941,8 @@ +@@ -2941,8 +2944,8 @@ // src/client/room-invite-preview.js var INVITE_PREVIEW_NAME_MAX = 60; @@ -367,7 +379,7 @@ diff --git a/client.js b/client.js for (let previous; previous !== out; ) { previous = out; out = out.replace(/<\/?\s*untrusted-(?:file|message)/gi, ""); -@@ -2955,9 +2955,9 @@ +@@ -2955,9 +2958,9 @@ function inviteMemberCount(value) { if (typeof value === "number") return Number.isFinite(value) ? value : NaN; if (typeof value !== "string") return NaN; @@ -380,7 +392,7 @@ diff --git a/client.js b/client.js return Number.isFinite(count) ? count : NaN; } function invitePreviewOf(seen) { -@@ -2979,29 +2979,29 @@ +@@ -2979,29 +2982,29 @@ const total = Math.max(1, Math.ceil(left / 1e3)); return `${Math.floor(total / 60)}:${String(total % 60).padStart(2, "0")} left to join`; } @@ -420,7 +432,7 @@ diff --git a/client.js b/client.js type: "button", className: "fs-btn fs-btn-sm fs-btn-primary", "data-flybot": child("join"), -@@ -3396,10 +3396,10 @@ +@@ -3396,10 +3399,10 @@ `; // src/shared/failure-copy.js @@ -434,7 +446,7 @@ diff --git a/client.js b/client.js return plain ? plain[1] : ""; }; var KNOWN = { -@@ -3410,13 +3410,13 @@ +@@ -3410,13 +3413,13 @@ InsufficientQuota: "The account is out of quota.", RateLimitExceeded: "Too many requests right now. Try again in a moment." }; @@ -454,7 +466,7 @@ diff --git a/client.js b/client.js if (key !== KNOWN.UnsupportedModel || feature === void 0) return fill2(translate(key), void 0); const withFeature = "This model does not support {feature}. Pick another model in the Bot's settings."; return fill2(translate(withFeature, { feature }), { feature }); -@@ -3631,12 +3631,12 @@ +@@ -3631,12 +3634,12 @@ const dt = Math.min(0.5, Math.max(1e-3, (now - last) / 1e3)); last = now; const steps = Math.ceil(dt * 240); @@ -470,7 +482,7 @@ diff --git a/client.js b/client.js } if (Math.abs(value.x - value.to) < 0.05 && Math.abs(value.v) < 1) { value.x = value.to; -@@ -4914,10 +4914,10 @@ +@@ -4914,10 +4917,10 @@ .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)} `; @@ -483,7 +495,7 @@ diff --git a/client.js b/client.js document.head.appendChild(style); return style; } -@@ -4937,9 +4937,9 @@ +@@ -4937,9 +4940,9 @@ } return names; } @@ -495,7 +507,7 @@ diff --git a/client.js b/client.js if (!compiled.has(fragment)) { const exact = []; for (const name of names) if (name.includes(fragment)) exact.push(`.${name}`); -@@ -4948,7 +4948,7 @@ +@@ -4948,7 +4951,7 @@ return compiled.get(fragment); }); } @@ -504,7 +516,7 @@ diff --git a/client.js b/client.js const style = installStyles("", tag); const cache = /* @__PURE__ */ new WeakMap(); const render = () => { -@@ -4962,7 +4962,7 @@ +@@ -4962,7 +4965,7 @@ } for (const name of list) names.add(name); } @@ -513,7 +525,7 @@ diff --git a/client.js b/client.js if (style.textContent !== next) style.textContent = next; }; render(); -@@ -5059,11 +5059,11 @@ +@@ -5059,11 +5062,11 @@ return ``; } function brandTitle(title) { @@ -530,7 +542,7 @@ diff --git a/client.js b/client.js } function sessionTabLabel(roster, sessionId) { if (!roster || !sessionId) return ""; -@@ -5542,14 +5542,14 @@ +@@ -5542,14 +5545,14 @@ "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 }), @@ -547,7 +559,7 @@ diff --git a/client.js b/client.js ); } function SecretHead({ bot, title, detail, name, onClose }) { -@@ -5627,10 +5627,10 @@ +@@ -5627,10 +5630,10 @@ } function SettledSecret({ bot, request, roster }) { const canceled = request.status === "canceled"; @@ -560,7 +572,7 @@ diff --git a/client.js b/client.js (0, import_react7.createElement)( "div", { className: "bt-q-head" }, -@@ -5638,7 +5638,7 @@ +@@ -5638,7 +5641,7 @@ (0, import_react7.createElement)("div", { className: "bt-q-text" }, (0, import_react7.createElement)( "p", { className: "bt-q-title" }, @@ -569,7 +581,7 @@ diff --git a/client.js b/client.js 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)) -@@ -5683,11 +5683,11 @@ +@@ -5683,11 +5686,11 @@ (0, import_react7.createElement)( "div", { className: "bt-secret-radios", role: "radiogroup", "aria-label": t("Who may use {name}", { name: secret.name }) }, @@ -583,7 +595,7 @@ diff --git a/client.js b/client.js )) ), mode === "some" ? (0, import_react7.createElement)("div", { className: "bt-secret-bots" }, roster.bots.map((bot) => (0, import_react7.createElement)("button", { -@@ -6059,7 +6059,7 @@ +@@ -6059,7 +6062,7 @@ return (0, import_react9.createElement)( "div", { className: "bt-astack", "data-lead": lead ? "" : void 0, onClickCapture: onClick }, @@ -592,7 +604,7 @@ diff --git a/client.js b/client.js const key = msgKey ? `${msgKey}:${index}` : void 0; const picked = key ? reactions?.[index] ?? [] : []; const last = index === texts2.length - 1; -@@ -6074,9 +6074,9 @@ +@@ -6074,9 +6077,9 @@ (0, import_react9.createElement)( "div", { className: "bt-bubble" }, @@ -604,7 +616,7 @@ diff --git a/client.js b/client.js ), picked.length ? (0, import_react9.createElement)("div", { className: "bt-reacts" }, picked.map((emoji) => (0, import_react9.createElement)("button", { key: emoji, -@@ -6091,9 +6091,9 @@ +@@ -6091,9 +6094,9 @@ } var WAITING_OTHERS_NOTICE = "Waiting for the owner's approval"; var REACTIONS = ["👍", "❤️", "😂", "😮", "😢", "🙏"]; @@ -616,7 +628,7 @@ diff --git a/client.js b/client.js if (!match) return false; const who = match[1]; for (const participant of room2.participants ?? []) { -@@ -6124,7 +6124,7 @@ +@@ -6124,7 +6127,7 @@ }; }, [open]); } @@ -625,7 +637,7 @@ diff --git a/client.js b/client.js const [panel, setPanel] = (0, import_react9.useState)(null); const [place, setPlace] = (0, import_react9.useState)(null); const [copied, setCopied] = (0, import_react9.useState)(false); -@@ -6143,13 +6143,13 @@ +@@ -6143,13 +6146,13 @@ setPanel(kind); }; const copy2 = () => { @@ -641,7 +653,7 @@ diff --git a/client.js b/client.js const link = Object.assign(document.createElement("a"), { href: url, download: `${name}-message.md` }); link.click(); setTimeout(() => URL.revokeObjectURL(url), 1e3); -@@ -6162,7 +6162,7 @@ +@@ -6162,7 +6165,7 @@ "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, @@ -650,7 +662,7 @@ diff --git a/client.js b/client.js (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", -@@ -6195,14 +6195,14 @@ +@@ -6195,14 +6198,14 @@ 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 }); @@ -670,7 +682,7 @@ diff --git a/client.js b/client.js return (0, import_react9.createElement)( "details", { className: "bt-think" }, -@@ -6213,7 +6213,7 @@ +@@ -6213,7 +6216,7 @@ t("Thought it through"), peek ? (0, import_react9.createElement)("span", { className: "bt-think-peek" }, peek) : null ), @@ -679,7 +691,7 @@ diff --git a/client.js b/client.js ); } function useFileMentions({ node, openFile, fileMentions, useTurnData }) { -@@ -6234,7 +6234,7 @@ +@@ -6234,7 +6237,7 @@ } function friendLead(bot, texts2) { if (bot?.remote === void 0 || texts2.length === 0) return void 0; @@ -688,7 +700,7 @@ diff --git a/client.js b/client.js 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 }) { -@@ -6247,7 +6247,7 @@ +@@ -6247,7 +6250,7 @@ const closing = useClosingText(node, useTurnData); const hidden = Boolean(roster.roomsById[sessionId]) || isHiddenTurn(roster, sessionId, data.turn); const person = typedBy(roster, sessionId, data.turn); @@ -697,7 +709,7 @@ diff --git a/client.js b/client.js 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; -@@ -6323,11 +6323,11 @@ +@@ -6323,11 +6326,11 @@ }); return (0, import_react9.createElement)("div", { className: "bt-event" }, parts); } @@ -712,7 +724,7 @@ diff --git a/client.js b/client.js if (!line2) return false; return (room2.pendingApprovals ?? []).some((item) => { if (item?.kind !== "face_room_message") return false; -@@ -6337,9 +6337,9 @@ +@@ -6337,9 +6340,9 @@ }); } var HELD_SENDING_MS = 6e4; @@ -724,7 +736,7 @@ diff --git a/client.js b/client.js 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"; -@@ -6347,8 +6347,8 @@ +@@ -6347,8 +6350,8 @@ if (entry.status !== "pending" && entry.status !== "sending") return void 0; return room2.face?.status === "left" || room2.face?.status === "closed" ? "rejected" : "held"; } @@ -735,7 +747,7 @@ diff --git a/client.js b/client.js if (held === "rejected") { return (0, import_react9.createElement)( "div", -@@ -6357,7 +6357,7 @@ +@@ -6357,7 +6360,7 @@ (0, import_react9.createElement)("span", null, t("{name} · Not sent", { name: bot.name })) ); } @@ -744,7 +756,7 @@ diff --git a/client.js b/client.js 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) }, -@@ -6373,7 +6373,7 @@ +@@ -6373,7 +6376,7 @@ "div", { className: "bt-group-msg" }, name, @@ -753,7 +765,7 @@ diff --git a/client.js b/client.js ); } function remoteSpeaker(source, roster, sessionId) { -@@ -6408,7 +6408,7 @@ +@@ -6408,7 +6411,7 @@ return Object.keys(look).length ? look : void 0; } var cleanCarrier = (value) => typeof value === "string" && value.trim() && !/[@()]/.test(value) ? value.trim() : ""; @@ -762,7 +774,7 @@ diff --git a/client.js b/client.js const room2 = roster.roomsById?.[sessionId]; const face = room2?.origin === "face-to-face"; const { bot, member, name, handle, carrier, chatId } = remoteSpeaker(source, roster, sessionId); -@@ -6425,7 +6425,7 @@ +@@ -6425,7 +6428,7 @@ person, carrier ? (0, import_react9.createElement)("span", { className: "bt-remote-handle" }, t("via {name}", { name: carrier })) : null ), @@ -771,7 +783,7 @@ diff --git a/client.js b/client.js files.length ? (0, import_react9.createElement)(RemoteFiles, { files, plugin: member?.plugin ?? "social", botId: roster.byId[sessionId]?.id ?? sessionId }) : null ); } -@@ -6441,7 +6441,7 @@ +@@ -6441,7 +6444,7 @@ 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) ), @@ -780,7 +792,7 @@ diff --git a/client.js b/client.js files.length ? (0, import_react9.createElement)(RemoteFiles, { files, plugin: member?.plugin ?? "social", botId: roster.byId[sessionId]?.id ?? sessionId }) : null ); } -@@ -7513,13 +7513,13 @@ +@@ -7513,13 +7516,13 @@ } var elapsedSince = (now, at) => typeof at === "number" && now - at >= 6e4 ? elapsedLabel(now - at) : ""; function WorkRow({ bot, tool, step, label, at, now, onOpen }) { @@ -797,7 +809,7 @@ diff --git a/client.js b/client.js elapsed ? (0, import_react12.createElement)("span", { className: "bt-mw-row-time" }, `· ${elapsed}`) : null ); } -@@ -7770,11 +7770,11 @@ +@@ -7770,11 +7773,11 @@ 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; @@ -812,7 +824,7 @@ diff --git a/client.js b/client.js if (key === bot.id || key === currentPart(bot)) return bot.id; } return void 0; -@@ -7790,7 +7790,7 @@ +@@ -7790,7 +7793,7 @@ if (entry) options.push(`${workPhrase(entry.tool)}…`); } if (sessions?.byId && card.chat) options.push(previewOf(sessions, partOf(roster, card.chat), [], [], botNames(roster))); @@ -821,7 +833,7 @@ diff --git a/client.js b/client.js } function toolCalls(card, { roster, work }) { const botId = card.bot?.id; -@@ -7945,9 +7945,9 @@ +@@ -7945,9 +7948,9 @@ const part = currentPart(bot); const at = sessions.byId[part]?.updatedAt; if (!fresh(at)) continue; @@ -834,7 +846,7 @@ diff --git a/client.js b/client.js } } const newest = (a, b) => (b.at ?? Infinity) - (a.at ?? Infinity); -@@ -7970,10 +7970,10 @@ +@@ -7970,10 +7973,10 @@ return Promise.resolve(actions.answerQuestion(card.sessionId, card.question.id, { selected, custom })); } if (card?.kind === "task" && card.status === "input") { @@ -848,7 +860,7 @@ diff --git a/client.js b/client.js } if (card?.kind === "task" && card.status === "result") { if (choice === "accept") return Promise.resolve(actions.acceptTaskResult(card.task.id)); -@@ -8382,8 +8382,8 @@ +@@ -8382,8 +8385,8 @@ // src/shared/board-card.js function taskCardKey(id) { @@ -859,7 +871,7 @@ diff --git a/client.js b/client.js } function taskCard(task) { const key = taskCardKey(task?.id); -@@ -8869,11 +8869,11 @@ +@@ -8869,11 +8872,11 @@ } }; const create = async (title) => { @@ -874,7 +886,7 @@ diff --git a/client.js b/client.js setDraft(""); } catch (failure) { setError(failure?.message ?? String(failure)); -@@ -8989,7 +8989,7 @@ +@@ -8989,7 +8992,7 @@ var PinMark = () => (0, import_react16.createElement)("span", { className: "bt-pin", "aria-hidden": true }, (0, import_react16.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(",")) : ""; @@ -883,7 +895,7 @@ diff --git a/client.js b/client.js const [id, tool] = key.split("|"); return { id, tool: tool || void 0 }; }); -@@ -9922,7 +9922,7 @@ +@@ -9922,7 +9925,7 @@ const host = useRunningHost2(visible); const shown = (0, import_react17.useRef)(null); if (visible) shown.current = room2 ? { text: memberAck?.text ?? "", at: memberAck?.at ?? Date.now() } : { text: busyAckText(hostAck), at: hostAck?.at ?? Date.now() }; @@ -892,7 +904,7 @@ diff --git a/client.js b/client.js if (!visible || !host || !shown.current || !bot) return null; return (0, import_react_dom4.createPortal)((0, import_react17.createElement)( "div", -@@ -9931,8 +9931,8 @@ +@@ -9931,8 +9934,8 @@ "data-flybot": "busy-ack", role: "status", "aria-live": "polite", @@ -903,7 +915,7 @@ diff --git a/client.js b/client.js }, (0, import_react17.createElement)(BotMark, { bot, size: 28, state: "thinking", live: true }), (0, import_react17.createElement)( -@@ -9944,7 +9944,7 @@ +@@ -9944,7 +9947,7 @@ (0, import_react17.createElement)( "div", { className: "bt-msg-line" }, @@ -912,7 +924,7 @@ diff --git a/client.js b/client.js "div", { className: "bt-bubble bt-busy-dots", "aria-hidden": "true" }, (0, import_react17.createElement)("span", null), -@@ -10328,10 +10328,10 @@ +@@ -10328,10 +10331,10 @@ let interim = ""; for (let index = event.resultIndex; index < event.results.length; index += 1) { const result = event.results[index]; @@ -926,7 +938,7 @@ diff --git a/client.js b/client.js } if (interim) latest.current(interim, false); }; -@@ -10377,8 +10377,8 @@ +@@ -10377,8 +10380,8 @@ ); } function ComposerVoice({ sessionId, inputActions, actions, useUi }) { @@ -937,7 +949,7 @@ diff --git a/client.js b/client.js }); const quote = useUi((state) => state.quote?.sessionId === sessionId ? state.quote : null); (0, import_react19.useEffect)(() => { -@@ -10746,8 +10746,8 @@ +@@ -10746,8 +10749,8 @@ function waveBytes(samples, rate = 16e3) { const bytes = new Uint8Array(44 + samples.length * 2); const view = new DataView(bytes.buffer); @@ -948,7 +960,7 @@ diff --git a/client.js b/client.js }; ascii(0, "RIFF"); view.setUint32(4, 36 + samples.length * 2, true); -@@ -10768,9 +10768,9 @@ +@@ -10768,9 +10771,9 @@ return bytes; } function base64Of(bytes) { @@ -961,7 +973,7 @@ diff --git a/client.js b/client.js } var captureError = (kind, cause) => Object.assign(new Error(kind), { kind, cause }); async function openCapture() { -@@ -11078,7 +11078,7 @@ +@@ -11078,7 +11081,7 @@ void actions.refreshModels?.(); }).finally(() => setBusy("")); }; @@ -970,7 +982,7 @@ diff --git a/client.js b/client.js const choices = models.length === 0 ? [...setupChoices(actions).map(([label, run]) => (0, import_react20.createElement)("button", { key: label, type: "button", className: "bt-model-chip", onClick: run }, label)), (0, import_react20.createElement)(KeyHelpLink, { key: "key-help" })] : [ ...groupModels(models).map(([provider, list]) => (0, import_react20.createElement)( "div", -@@ -11110,7 +11110,7 @@ +@@ -11110,7 +11113,7 @@ "div", { className: "bt-msg-line" }, (0, import_react20.createElement)(BotMark, { bot, size: 22 }), @@ -979,7 +991,7 @@ diff --git a/client.js b/client.js ) ) ), -@@ -11150,9 +11150,9 @@ +@@ -11150,9 +11153,9 @@ return (0, import_react20.createElement)("a", { className: "bt-model-help", href: KEY_HELP_URL, target: "_blank", rel: "noopener noreferrer" }, t("How to set a DeepSeek API key")); } function missingModelKey(message) { @@ -992,7 +1004,7 @@ diff --git a/client.js b/client.js } function modelKeyChoices(actions, route) { if (/^deepseek/i.test(route)) return setupChoices(actions); -@@ -11163,8 +11163,8 @@ +@@ -11163,8 +11166,8 @@ const code = typeof data.code === "string" && data.code ? data.code : void 0; const raw = String(data.message ?? ""); const shell = (key, fallback) => { @@ -1003,7 +1015,7 @@ diff --git a/client.js b/client.js }; const missing = missingModelKey(raw); const title = code === "ACCOUNT_SIGNED_OUT" ? shell("message.accountStopped", t("This turn failed")) : shell("message.turnError", t("This turn failed")); -@@ -11550,8 +11550,8 @@ +@@ -11550,8 +11553,8 @@ return `${t("Every day at {time}", { time: clock2(task.time) })}${zone}`; case "weekly": { const days = (task.weekdays ?? []).join(); @@ -1014,7 +1026,7 @@ diff --git a/client.js b/client.js } case "cron": return `${t("On the schedule {expression}", { expression: task.expression })}${zone}`; -@@ -12041,10 +12041,10 @@ +@@ -12041,10 +12044,10 @@ // src/client/capability-settings.js var import_react24 = require("react"); var blank = () => ({ name: "", kind: "mcp", description: "", transport: "stdio", command: "", argsText: "", envText: "{}", url: "", headersText: "{}", content: "", items: [] }); @@ -1027,7 +1039,7 @@ diff --git a/client.js b/client.js } catch { throw new Error(t("{label} must be valid JSON.", { label })); } -@@ -12471,11 +12471,11 @@ +@@ -12471,11 +12474,11 @@ ); } function changeLine(change) { @@ -1044,7 +1056,7 @@ diff --git a/client.js b/client.js } function Reply({ answer, dismiss }) { return (0, import_react26.createElement)( -@@ -12546,7 +12546,7 @@ +@@ -12546,7 +12549,7 @@ const [page, setPage] = (0, import_react26.useState)("summary"); const [menu, setMenu] = (0, import_react26.useState)(false); const [menuShown, menuLeaving] = useLinger(menu, MENU_EXIT_MS2); @@ -1053,7 +1065,7 @@ diff --git a/client.js b/client.js const [answer, setAnswer] = (0, import_react26.useState)(null); const menuRef = (0, import_react26.useRef)(null); const dotsRef = (0, import_react26.useRef)(null); -@@ -12598,7 +12598,7 @@ +@@ -12598,7 +12601,7 @@ }; const ask = async (event) => { event.preventDefault(); @@ -1062,7 +1074,7 @@ diff --git a/client.js b/client.js if (asked === "" || answer?.pending) return; setText(""); setAnswer({ asked, pending: true }); -@@ -12664,7 +12664,7 @@ +@@ -12664,7 +12667,7 @@ (0, import_react26.createElement)("input", { ref: inputRef, className: "bt-mem-input", @@ -1071,7 +1083,7 @@ diff --git a/client.js b/client.js maxLength: 2e3, disabled: view === null, placeholder: t("Ask or update"), -@@ -12673,7 +12673,7 @@ +@@ -12673,7 +12676,7 @@ }), (0, import_react26.createElement)( "button", @@ -1080,7 +1092,7 @@ diff --git a/client.js b/client.js busy ? (0, import_react26.createElement)("span", { className: "bt-mem-spin", "aria-hidden": true }) : (0, import_react26.createElement)(ArrowUpIcon) ) ) -@@ -13608,11 +13608,11 @@ +@@ -13608,11 +13611,11 @@ const entry = outline[index2]; if (hidden.includes(entry.turn)) continue; for (const raw of [entry.response, entry.prompt]) { @@ -1095,7 +1107,7 @@ diff --git a/client.js b/client.js break; } } -@@ -13798,10 +13798,10 @@ +@@ -13798,10 +13801,10 @@ if (effort) payload.effort = effort; return payload; } @@ -1108,7 +1120,7 @@ diff --git a/client.js b/client.js if (line2 === "") return; try { await actions.send(id, line2); -@@ -14063,27 +14063,27 @@ +@@ -14063,27 +14066,27 @@ }; const makeGroup = async () => { if (admin === null || picked.length === 0) return; @@ -1141,7 +1153,7 @@ diff --git a/client.js b/client.js actions.openSession(picked[0]); close(); return; -@@ -14094,7 +14094,7 @@ +@@ -14094,7 +14097,7 @@ ...remotes ]; const room2 = await actions.createRoom(members, participants ? { participants } : {}); @@ -1150,7 +1162,7 @@ diff --git a/client.js b/client.js actions.openSession(room2.id); close(); }); -@@ -14308,10 +14308,10 @@ +@@ -14308,10 +14311,10 @@ // src/client/exchange.js var import_react33 = require("react"); var SHEET_EXIT_MS = 220; @@ -1164,7 +1176,7 @@ diff --git a/client.js b/client.js for (let previous; previous !== body; ) { previous = body; const open = body.indexOf(" { if (!note) return void 0; -@@ -14534,9 +14534,9 @@ +@@ -14534,9 +14537,9 @@ setPhase("transcribing"); try { const wave = await current.finish(catalog.maxDurationSeconds); @@ -1200,7 +1212,7 @@ diff --git a/client.js b/client.js else setPhase("listening"); } catch (error) { if (turn.current !== own) return; -@@ -14585,10 +14585,10 @@ +@@ -14585,10 +14588,10 @@ }, [speech, catalog, listen, cycle]); (0, import_react34.useEffect)(() => { if (phase !== "thinking" || running || waiting.current === null) return; @@ -1214,7 +1226,7 @@ diff --git a/client.js b/client.js setSaid(spoken); setHeard(""); if (!window.speechSynthesis || !spoken) { -@@ -15337,8 +15337,8 @@ +@@ -15337,8 +15340,8 @@ return result; } function trimmed2(value) { @@ -1225,7 +1237,7 @@ diff --git a/client.js b/client.js } function compact(count) { for (const [size, unit] of [[1e9, "B"], [1e6, "M"], [1e3, "K"]]) { -@@ -15470,14 +15470,14 @@ +@@ -15470,14 +15473,14 @@ return labels[kind] ?? kind; } function Tabs({ label, value, options, onChange, boxRef }) { @@ -1242,7 +1254,7 @@ diff --git a/client.js b/client.js ); } function Stats({ range, total, previous }) { -@@ -16363,15 +16363,15 @@ +@@ -16363,15 +16366,15 @@ const names = (0, import_react37.useRef)(new Map(APPS)); const asked = (0, import_react37.useRef)(0); (0, import_react37.useEffect)(() => { @@ -1261,7 +1273,7 @@ diff --git a/client.js b/client.js if (ask !== asked.current) return; for (const app of list) names.current.set(app.slug, app.name); setFound(list); -@@ -16605,15 +16605,15 @@ +@@ -16605,15 +16608,15 @@ (0, import_react38.createElement)("path", { d: "M4 4.5h12a1 1 0 0 1 1 1v7a1 1 0 0 1-1 1H9l-3.5 3v-3H4a1 1 0 0 1-1-1v-7a1 1 0 0 1 1-1z" }), (0, import_react38.createElement)("path", { d: "M10 7v2.6M10 11.4h.01" }) ); @@ -1280,7 +1292,7 @@ diff --git a/client.js b/client.js function whenText2(time) { const date = new Date(time); const today = (/* @__PURE__ */ new Date()).toDateString() === date.toDateString(); -@@ -16661,8 +16661,8 @@ +@@ -16661,8 +16664,8 @@ if (!inline && !copied) setPreview(true); })); }; @@ -1291,7 +1303,7 @@ diff --git a/client.js b/client.js setNote(t("Downloaded the log. Keys, tokens, and your home folder are hidden in it.")); })); const clearLog = () => run(actions.diagnosticsClear().then(clearClientErrors).then(load).then(() => setNote(t("Cleared the error log.")))); -@@ -17172,13 +17172,13 @@ +@@ -17172,13 +17175,13 @@ "nav", { ref: nav.box, className: "bt-settings-nav", "aria-label": t("Settings pages") }, (0, import_react40.createElement)("span", { ref: nav.thumb, className: "bt-thumb", "aria-hidden": true }), @@ -1307,7 +1319,7 @@ diff --git a/client.js b/client.js // DeepSeek Harness's own settings are a separate window with its own title and look; FlyBot // does not embed or retitle it (never patch DSH), so the entry says it leaves (#421). (0, import_react40.createElement)("button", { -@@ -17543,16 +17543,16 @@ +@@ -17543,16 +17546,16 @@ (0, import_react42.createElement)("button", { type: "button", className: "bt-bcard-new", style: { animationDelay: `${state.ids.length * 30}ms` }, onClick: () => actions.browserNewTab(sessionId) }, (0, import_react42.createElement)(PlusIcon), t("New tab")) ); } @@ -1327,7 +1339,7 @@ diff --git a/client.js b/client.js long ? (0, import_react42.createElement)("button", { type: "button", className: "bt-more", onClick: () => setOpen((value) => !value) }, open ? t("Show less") : t("Show more")) : null ); } -@@ -17739,12 +17739,12 @@ +@@ -17739,12 +17742,12 @@ return false; }); const share = () => { @@ -1343,7 +1355,7 @@ diff --git a/client.js b/client.js if (!written) { actions.reportError?.(new Error(t("Could not copy the conversation")), "share"); return; -@@ -18120,11 +18120,11 @@ +@@ -18120,11 +18123,11 @@ }, [tabId, landed]); const go = (event) => { event.preventDefault(); @@ -1358,7 +1370,7 @@ diff --git a/client.js b/client.js if (opened) actions.browserFocus(tabId); }); }; -@@ -18668,19 +18668,19 @@ +@@ -18668,19 +18671,19 @@ const auto = String(el.getAttribute("autocomplete") || "").toLowerCase(); return el.type === "password" || /(^|\s)(cc-|one-time-code)/.test(auto) || SECRET_HINT.test(`${el.getAttribute("name") || ""} ${el.id || ""}`); }; @@ -1385,7 +1397,7 @@ diff --git a/client.js b/client.js const styleOf = (el) => (el.ownerDocument.defaultView || window).getComputedStyle(el); const childrenOf = (el) => { if (el.shadowRoot) return el.shadowRoot.childNodes; -@@ -18714,8 +18714,8 @@ +@@ -18714,8 +18717,8 @@ items.push(item); return { item, number: items.length, secret }; }; @@ -1396,7 +1408,7 @@ diff --git a/client.js b/client.js ).slice(0, 80); const fenced = (code, lang) => { const longest = Math.max(2, ...(code.match(/`{3,}/g) || []).map((run) => run.length)); -@@ -18784,12 +18784,12 @@ +@@ -18784,12 +18787,12 @@ for (const cell of row.cells) { place(); if (styleOf(cell).display === "none") continue; @@ -1412,7 +1424,7 @@ diff --git a/client.js b/client.js column += 1; } } -@@ -18854,9 +18854,9 @@ +@@ -18854,9 +18857,9 @@ if (node.nodeType === 3) { if (!ctx.shown) return ""; const data = node.data.replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f]/g, ""); @@ -1425,7 +1437,7 @@ diff --git a/client.js b/client.js } if (node.nodeType !== 1) return ""; const el = node; -@@ -18944,12 +18944,12 @@ +@@ -18944,12 +18947,12 @@ return marked; } const body = document.body; @@ -1442,7 +1454,7 @@ diff --git a/client.js b/client.js stop ||= `the text passed ${limits.chars} characters`; } const page = { -@@ -18959,7 +18959,7 @@ +@@ -18959,7 +18962,7 @@ description: document.querySelector('meta[name="description"]')?.content || "", selection: String(getSelection() || "").slice(0, 4e3), format: "markdown", @@ -1451,7 +1463,7 @@ diff --git a/client.js b/client.js items, stats: { ...stats, ms: Math.round(performance.now() - started) } }; -@@ -18987,15 +18987,15 @@ +@@ -18987,15 +18990,15 @@ var BLOCKED = "Cloud metadata addresses do not open here."; var blockedAddress = (url) => URL.canParse(url) && METADATA_HOST.test(new URL(url).hostname); function browserAddress(input) { @@ -1475,7 +1487,7 @@ diff --git a/client.js b/client.js } function createBotBrowsers({ bridge, createSource: createSource2, storage = globalThis.localStorage, document: doc = globalThis.document }) { const views = /* @__PURE__ */ new Map(); -@@ -19475,8 +19475,8 @@ +@@ -19475,8 +19478,8 @@ const frame = col?.parentElement; if (!frame) return null; const name = frame.className; @@ -1486,7 +1498,7 @@ diff --git a/client.js b/client.js } function levelShell(doc, from) { const page = doc.scrollingElement || doc.documentElement; -@@ -19517,6 +19517,123 @@ +@@ -19517,6 +19520,123 @@ }; } @@ -1610,7 +1622,7 @@ diff --git a/client.js b/client.js // src/client/friend-lines.js function friendLineNews(bots, known, isOpen, selfId) { const next = { ...known }; -@@ -19586,7 +19703,7 @@ +@@ -19586,7 +19706,7 @@ } // src/client/user-message.js @@ -1619,7 +1631,7 @@ diff --git a/client.js b/client.js var ui = __toESM(require("@deepseek-ai/dsh-client-ui-primitives"), 1); var canDrawUserMessages = () => typeof ui.projectUserText === "function"; function contentParts(content) { -@@ -19658,58 +19775,58 @@ +@@ -19658,58 +19778,58 @@ mine: roster?.self?.memberId === authorId }; } @@ -1699,7 +1711,7 @@ diff --git a/client.js b/client.js const referenceLabels = data.referenceLabels ?? []; const references = openFile && openSkill ? { openFile, openSkill } : void 0; const steering = node.kind === "steering"; -@@ -19720,7 +19837,7 @@ +@@ -19720,7 +19840,7 @@ const person = incoming && author.authorKind === "human" && !author.ownerName; const faceMine = Boolean(room2?.origin === "face-to-face" && !incoming); const myName = faceMine ? facePeople(room2, roster).find((entry) => entry.mine)?.name || cleanPeerName(roster.self?.displayName, "") || author?.name || "" : ""; @@ -1708,7 +1720,7 @@ diff --git a/client.js b/client.js "div", { className: incoming ? "bt-umsg bt-umsg-other" : "bt-umsg", -@@ -19730,22 +19847,22 @@ +@@ -19730,22 +19850,22 @@ "data-author-kind": author?.authorKind, "data-author-name": author?.name }, @@ -1741,7 +1753,7 @@ diff --git a/client.js b/client.js ); } var USER_MESSAGE_CSS = ` -@@ -19843,33 +19960,33 @@ +@@ -19843,33 +19963,33 @@ } // src/client/stats-dock.js @@ -1782,7 +1794,7 @@ diff --git a/client.js b/client.js var inject = ["sessions", "uiWorkspace", "uiConversation", "slots", "theme", "layout"]; function apply(ctx) { const flybot = createClientApi2(); -@@ -20459,7 +20576,7 @@ +@@ -20459,7 +20579,7 @@ } return { ...state, reactions }; }), @@ -1791,7 +1803,7 @@ diff --git a/client.js b/client.js clearQuote: (token) => ui2.set((state) => state.quote?.token === token ? { ...state, quote: null } : state), clearUnread: (id) => setUnread(id, false), setFlags: (id, flags) => after(call("set-flags", { id, ...flags })), -@@ -20487,7 +20604,7 @@ +@@ -20487,7 +20607,7 @@ setPrefs: (prefs) => after(call("set-prefs", prefs)), setMain: (id, main = true, options = {}) => after(call("set-main", { id, main, ...options })), updateRoom: (id, patch) => after(call("update-room", { id, ...patch })), @@ -1800,7 +1812,7 @@ diff --git a/client.js b/client.js answerQuestion: (sessionId, questionId, answer) => after(call("answer-question", { sessionId: partFor(sessionId), questionId, answer })), answerTaskInput: (id, questionId, answer) => after(call("task-answer-input", { id, questionId, answer })), // The Done list is the acceptance surface (#401): a result is accepted, or sent back with a note -@@ -20514,7 +20631,7 @@ +@@ -20514,7 +20634,7 @@ connectorBlock: (id, openId) => after(call("connector-block", { id, openId })), connectorDisconnect: (id) => after(call("connector-disconnect", { id })), connectorRetry: (id) => after(call("connector-retry", { id })), @@ -1809,7 +1821,7 @@ diff --git a/client.js b/client.js saveCapability: (capability) => after(call("capability-save", capability)), deleteCapability: (name) => after(call("capability-delete", { name })), approveCapability: (name) => after(call("capability-approve", { name })), -@@ -20537,7 +20654,7 @@ +@@ -20537,7 +20657,7 @@ usage: () => call("usage", { timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone }), // `summarize` starts a summary when the entries changed; `regenerate` always does. memoryView: (botId, flags = {}) => call("memory-view", { botId, ...flags }), @@ -1818,7 +1830,7 @@ diff --git a/client.js b/client.js memoryForget: (botId, entry) => call("memory-forget", { botId, scope: entry.scope, topic: entry.topic, text: entry.text }), diagnostics: (payload) => call("diagnostics", payload), diagnosticsLog: () => call("diagnostics-log"), -@@ -20548,7 +20665,7 @@ +@@ -20548,7 +20668,7 @@ ui2.set((state) => ({ ...state, toast: { text: error?.message ?? String(error), token: Date.now() } })); }, // A short confirmation (e.g. "Invite link copied") in the same toast, without the error styling. @@ -1827,7 +1839,7 @@ diff --git a/client.js b/client.js dismissToast: (token) => ui2.set((state) => state.toast && (token === void 0 || state.toast.token === token) ? { ...state, toast: null } : state) }; if (browsers) { -@@ -20766,6 +20883,7 @@ +@@ -20766,6 +20886,7 @@ installCompiledStyles(USER_MESSAGE_CSS, "user-messages"), installCompiledStyles(MOBILE_CSS, "mobile"), installCompiledStyles(PANE_MIN_WIDTH_CSS, "panes"), @@ -1835,7 +1847,7 @@ diff --git a/client.js b/client.js typeof document !== "undefined" ? watchShellLevel(document) : null, browsers ? installCompiledStyles(BROWSER_CSS, "browser") : null, installTheme(), -@@ -20783,7 +20901,8 @@ +@@ -20783,7 +20904,8 @@ shadow("shell.overlay", { id: "bot", order: 50, inject: face }, Overlays), shadow("shell.overlay", { id: "bot-signin", order: 55, inject: face }, DeepSeekSignIn), shadow("shell.overlay", { id: "bot-toast", order: 60, inject: face }, ErrorToast), @@ -1845,7 +1857,7 @@ diff --git a/client.js b/client.js ]; const installConversation = () => [ installCompiledStyles(CONVERSATION_CSS, "conversation"), -@@ -20836,7 +20955,7 @@ +@@ -20836,7 +20958,7 @@ ]; ctx.effect(() => { const style = installStyles(TURN_FAILURES_CSS, "turn-failures"); @@ -1854,6 +1866,7 @@ diff --git a/client.js b/client.js return () => { off(); style.remove(); + diff --git a/src/client/index.js b/src/client/index.js --- a/src/client/index.js +++ b/src/client/index.js