diff --git a/changelog.d/419-friend-chat-clutter.fixed.md b/changelog.d/419-friend-chat-clutter.fixed.md new file mode 100644 index 000000000..2c5242eba --- /dev/null +++ b/changelog.d/419-friend-chat-clutter.fixed.md @@ -0,0 +1 @@ +A friend's Bot chat is quieter ([#419](https://github.com/ThinkFlowLab/FlyBot/issues/419)): no `Tool removed: create_child_bot · …` line from plugin tool masks (in any chat), no local turns/steps/tok/s footer (the friend's Bot runs on its owner's home), and the composer keeps `Message ` after a send instead of switching to the coding label. diff --git a/core/client.js b/core/client.js index 91ffda730..62094beaa 100644 --- a/core/client.js +++ b/core/client.js @@ -2282,164 +2282,6 @@ var FLYBOT_CSS = ` .bt-flybot-pointer button { border: 0; border-radius: 999px; padding: 3px 10px; font-size: 12px; cursor: pointer; background: var(--bt-accent, #2563eb); color: #fff; } `; -// src/client/sources.js -function createSource(initial) { - let value = initial; - const listeners2 = /* @__PURE__ */ new Set(); - return { - getSnapshot: () => value, - subscribe: (listener) => { - listeners2.add(listener); - return () => { - listeners2.delete(listener); - }; - }, - set(next) { - const resolved = typeof next === "function" ? next(value) : next; - if (resolved === value) return; - value = resolved; - for (const listener of [...listeners2]) listener(); - } - }; -} -var EMPTY_ROSTER = { revision: -1, mainBotId: null, mainBotIds: [], bots: [], rooms: [], humans: [], self: null, agents: [], exchangeTurns: {}, remoteTypedTurns: {}, questions: {}, secrets: [], capabilities: [], capabilityStatuses: {}, secretRequests: {}, models: [], defaultModel: null, modelsKey: null, prefs: {}, byId: {}, roomsById: {}, agentsById: {}, agentOf: {}, ready: false }; -var isMainOf = (roster, id) => id !== void 0 && (roster.mainBotIds ?? []).includes(id); -function indexRoster(value) { - setLocalBots(value.bots); - const byId = {}; - for (const bot of value.bots) { - for (const id of [...bot.parts ?? [], bot.sessionId ?? bot.id, bot.id]) byId[id] = bot; - } - const roomsById = {}; - for (const room2 of value.rooms) roomsById[room2.id] = room2; - const agentsById = {}; - const agentOf = {}; - for (const agent of value.agents ?? []) { - agentsById[agent.id] = agent; - for (const sessionId of agent.sessions ?? []) agentOf[sessionId] = agent.id; - } - return { ...value, agents: value.agents ?? [], byId, roomsById, agentsById, agentOf, ready: true }; -} -function isAgentSession(roster, cache, sessionId) { - if (sessionId === void 0 || sessionId === null || sessionId === "") return false; - if (roster.ready) return roster.agentOf[sessionId] !== void 0; - return cache.has(sessionId); -} -function countCards(state, sessionId, callIds, delta) { - const own = { ...state[sessionId] }; - for (const callId of callIds) { - const count = (own[callId] ?? 0) + delta; - if (count > 0) own[callId] = count; - else delete own[callId]; - } - return { ...state, [sessionId]: own }; -} - -// src/client/themes.js -var THEME_KEYS = ["bg", "sidebar", "bubble", "card", "text", "user", "userText", "accent"]; -var THEMES = { - deepseek: { - label: "DeepSeek", - light: { bg: "#fcfcfc", sidebar: "#f7f7f7", bubble: "#eeeeee", card: "#ffffff", text: "#141414", user: "#070707", userText: "#fcfcfc", accent: "#4d6bfe" }, - dark: { bg: "#141414", sidebar: "#1b1b1b", bubble: "#262626", card: "#1f1f1f", text: "#f2f2f2", user: "#f2f2f2", userText: "#141414", accent: "#5b7bff" } - }, - mono: { - label: "Mono", - light: { bg: "#ffffff", sidebar: "#f9f9f9", bubble: "#f0f0f0", card: "#ffffff", text: "#0d0d0d", user: "#0d0d0d", userText: "#ffffff", accent: "#0d0d0d" }, - dark: { bg: "#0f0f0f", sidebar: "#171717", bubble: "#242424", card: "#1c1c1c", text: "#f4f4f4", user: "#f4f4f4", userText: "#0f0f0f", accent: "#f4f4f4" } - }, - paper: { - label: "Paper", - light: { bg: "#faf9f5", sidebar: "#f3f0e8", bubble: "#ebe7dc", card: "#ffffff", text: "#1f1e1b", user: "#2b2a26", userText: "#faf9f5", accent: "#d97757" }, - dark: { bg: "#1f1e1b", sidebar: "#262521", bubble: "#34322d", card: "#2a2925", text: "#f3f0e8", user: "#f3f0e8", userText: "#1f1e1b", accent: "#e08a6d" } - }, - moonlight: { - label: "Moonlight", - light: { bg: "#f7f9fc", sidebar: "#eef2f8", bubble: "#e5ebf5", card: "#ffffff", text: "#121826", user: "#1e3a8a", userText: "#ffffff", accent: "#1e86ff" }, - dark: { bg: "#0b1020", sidebar: "#10172a", bubble: "#1a2340", card: "#141c33", text: "#e8edf7", user: "#3b6fd8", userText: "#ffffff", accent: "#4f9dff" } - } -}; -var rgba = (hex, alpha) => `rgba(${[1, 3, 5].map((index) => parseInt(hex.slice(index, index + 2), 16)).join(",")},${alpha})`; -function themeVars(mode, dark, accent = mode.accent) { - return { - "--bt-ink": mode.text, - "--bt-ink-2": rgba(mode.text, 0.6), - "--bt-ink-3": rgba(mode.text, 0.36), - "--bt-bubble": mode.bubble, - "--bt-sidebar": mode.sidebar, - "--bt-main": mode.bg, - "--bt-card": mode.card, - "--bt-line": rgba(mode.text, 0.08), - "--bt-line-2": rgba(mode.text, 0.15), - "--bt-hover": dark ? "rgba(160,160,160,.12)" : "rgba(119,119,119,.09)", - "--bt-active": dark ? "rgba(160,160,160,.2)" : "rgba(119,119,119,.17)", - "--bt-accent": accent, - "--bt-accent-ink": luminance(accent) > 0.45 ? "#141414" : "#ffffff", - "--bt-user": mode.user, - "--bt-user-ink": mode.userText, - "--bt-tag-bg": rgba(mode.text, dark ? 0.09 : 0.06), - "--bt-tag-ink": rgba(mode.text, 0.58) - }; -} -var declarations = (vars) => Object.entries(vars).map(([name, value]) => `${name}:${value}`).join(";"); -var themeCss = (theme, accent) => `html body{${declarations(themeVars(theme.light, false, accent ?? theme.light.accent))}} -html body[data-ds-dark-theme]{${declarations(themeVars(theme.dark, true, accent ?? theme.dark.accent))}} -${theme.css ?? ""}`; -var shellTokens = ({ light, dark }) => ({ - "--dsw-alias-bg-base": { light: light.bg, dark: dark.bg }, - "--dsw-specific-sidebar-fill": { light: light.sidebar, dark: dark.sidebar }, - "--dsw-specific-input-major": { light: light.bg, dark: dark.sidebar }, - "--dsw-alias-label-primary": { light: light.text, dark: dark.text }, - "--dsw-alias-label-secondary": { light: rgba(light.text, 0.74), dark: rgba(dark.text, 0.74) }, - "--dsw-alias-label-tertiary": { light: rgba(light.text, 0.6), dark: rgba(dark.text, 0.6) } -}); -var customThemes = {}; -var registry3 = createSource(0); -var isPoint = (point) => Array.isArray(point) && point.length === 2 && point.every(Number.isFinite); -function registerShape(id, shape) { - if (typeof id !== "string" || !/^[a-z][\w-]{0,31}$/.test(id)) throw new Error("Shape ids are lowercase words"); - if (typeof shape?.body !== "function" || typeof shape.body("#000") !== "string") throw new Error("A shape needs body(paint) returning SVG markup"); - const at = shape.eyes?.at; - if (!Array.isArray(at) || at.length !== 2 || !at.every(isPoint)) throw new Error("A shape needs eyes.at: two [x, y] points"); - const size = Number.isFinite(shape.size) && shape.size > 0 ? shape.size : 100; - const pick2 = (key, fallback) => Number.isFinite(shape.eyes[key]) ? shape.eyes[key] : fallback; - const eyes = at.map(([x, y]) => [x, y, pick2("w", size * 0.1), pick2("h", size * 0.23), pick2("tilt", -27)]); - const previous = SHAPES[id]; - SHAPES[id] = { - label: String(shape.label ?? id), - size, - body: shape.body, - eyes, - ...typeof shape.wrap === "string" ? { wrap: shape.wrap } : {} - }; - const refresh = () => { - markupCache.clear(); - maskCache.delete(id); - registry3.set((value) => value + 1); - }; - refresh(); - return () => { - if (previous) SHAPES[id] = previous; - else delete SHAPES[id]; - refresh(); - }; -} -function registerTheme(id, theme) { - if (typeof id !== "string" || !/^[a-z][\w-]{0,31}$/.test(id)) throw new Error("Theme ids are lowercase words"); - for (const mode of ["light", "dark"]) { - for (const key of THEME_KEYS) if (!isHex(theme?.[mode]?.[key])) throw new Error(`Theme ${mode}.${key} must be a #rrggbb color`); - } - const previous = customThemes[id]; - customThemes[id] = { label: String(theme.label ?? id), light: theme.light, dark: theme.dark, css: typeof theme.css === "string" ? theme.css : "" }; - registry3.set((value) => value + 1); - return () => { - if (previous) customThemes[id] = previous; - else delete customThemes[id]; - registry3.set((value) => value + 1); - }; -} -var allThemes = () => ({ ...THEMES, ...customThemes }); - // src/shared/mentions.js var ALL_WORDS = ["all", "everyone", "所有人", "全体"]; var NAME_GOES_ON = /^(?:[a-z0-9_\-/\\]|\.[a-z0-9_-])/i; @@ -2619,6 +2461,11 @@ function isHiddenTurn(roster, sessionId, turn) { return (roster.exchangeTurns[sessionId] ?? []).includes(turn); } var toolName = (root) => root.name || root.call?.name; +var pluginTools = /* @__PURE__ */ new Set(); +function setPluginTools(names) { + pluginTools = new Set((Array.isArray(names) ? names : []).filter((name) => typeof name === "string")); +} +var isPluginTool = (name) => typeof name === "string" && pluginTools.has(name); var historyLoading = (snapshot) => snapshot?.openState === "cold" || snapshot?.openState === "loading"; function toolArgs(root) { if (typeof root.args?.text === "function") { @@ -2719,6 +2566,165 @@ function usageOf(list, ids) { return t("{tokens} tok · Cache hit {hit}%", { tokens: formatTokens(input + output), hit }); } +// src/client/sources.js +function createSource(initial) { + let value = initial; + const listeners2 = /* @__PURE__ */ new Set(); + return { + getSnapshot: () => value, + subscribe: (listener) => { + listeners2.add(listener); + return () => { + listeners2.delete(listener); + }; + }, + set(next) { + const resolved = typeof next === "function" ? next(value) : next; + if (resolved === value) return; + value = resolved; + for (const listener of [...listeners2]) listener(); + } + }; +} +var EMPTY_ROSTER = { revision: -1, mainBotId: null, mainBotIds: [], bots: [], rooms: [], humans: [], self: null, agents: [], exchangeTurns: {}, remoteTypedTurns: {}, questions: {}, secrets: [], capabilities: [], capabilityStatuses: {}, secretRequests: {}, models: [], defaultModel: null, modelsKey: null, prefs: {}, byId: {}, roomsById: {}, agentsById: {}, agentOf: {}, ready: false }; +var isMainOf = (roster, id) => id !== void 0 && (roster.mainBotIds ?? []).includes(id); +function indexRoster(value) { + setLocalBots(value.bots); + setPluginTools(value.pluginTools); + const byId = {}; + for (const bot of value.bots) { + for (const id of [...bot.parts ?? [], bot.sessionId ?? bot.id, bot.id]) byId[id] = bot; + } + const roomsById = {}; + for (const room2 of value.rooms) roomsById[room2.id] = room2; + const agentsById = {}; + const agentOf = {}; + for (const agent of value.agents ?? []) { + agentsById[agent.id] = agent; + for (const sessionId of agent.sessions ?? []) agentOf[sessionId] = agent.id; + } + return { ...value, agents: value.agents ?? [], byId, roomsById, agentsById, agentOf, ready: true }; +} +function isAgentSession(roster, cache, sessionId) { + if (sessionId === void 0 || sessionId === null || sessionId === "") return false; + if (roster.ready) return roster.agentOf[sessionId] !== void 0; + return cache.has(sessionId); +} +function countCards(state, sessionId, callIds, delta) { + const own = { ...state[sessionId] }; + for (const callId of callIds) { + const count = (own[callId] ?? 0) + delta; + if (count > 0) own[callId] = count; + else delete own[callId]; + } + return { ...state, [sessionId]: own }; +} + +// src/client/themes.js +var THEME_KEYS = ["bg", "sidebar", "bubble", "card", "text", "user", "userText", "accent"]; +var THEMES = { + deepseek: { + label: "DeepSeek", + light: { bg: "#fcfcfc", sidebar: "#f7f7f7", bubble: "#eeeeee", card: "#ffffff", text: "#141414", user: "#070707", userText: "#fcfcfc", accent: "#4d6bfe" }, + dark: { bg: "#141414", sidebar: "#1b1b1b", bubble: "#262626", card: "#1f1f1f", text: "#f2f2f2", user: "#f2f2f2", userText: "#141414", accent: "#5b7bff" } + }, + mono: { + label: "Mono", + light: { bg: "#ffffff", sidebar: "#f9f9f9", bubble: "#f0f0f0", card: "#ffffff", text: "#0d0d0d", user: "#0d0d0d", userText: "#ffffff", accent: "#0d0d0d" }, + dark: { bg: "#0f0f0f", sidebar: "#171717", bubble: "#242424", card: "#1c1c1c", text: "#f4f4f4", user: "#f4f4f4", userText: "#0f0f0f", accent: "#f4f4f4" } + }, + paper: { + label: "Paper", + light: { bg: "#faf9f5", sidebar: "#f3f0e8", bubble: "#ebe7dc", card: "#ffffff", text: "#1f1e1b", user: "#2b2a26", userText: "#faf9f5", accent: "#d97757" }, + dark: { bg: "#1f1e1b", sidebar: "#262521", bubble: "#34322d", card: "#2a2925", text: "#f3f0e8", user: "#f3f0e8", userText: "#1f1e1b", accent: "#e08a6d" } + }, + moonlight: { + label: "Moonlight", + light: { bg: "#f7f9fc", sidebar: "#eef2f8", bubble: "#e5ebf5", card: "#ffffff", text: "#121826", user: "#1e3a8a", userText: "#ffffff", accent: "#1e86ff" }, + dark: { bg: "#0b1020", sidebar: "#10172a", bubble: "#1a2340", card: "#141c33", text: "#e8edf7", user: "#3b6fd8", userText: "#ffffff", accent: "#4f9dff" } + } +}; +var rgba = (hex, alpha) => `rgba(${[1, 3, 5].map((index) => parseInt(hex.slice(index, index + 2), 16)).join(",")},${alpha})`; +function themeVars(mode, dark, accent = mode.accent) { + return { + "--bt-ink": mode.text, + "--bt-ink-2": rgba(mode.text, 0.6), + "--bt-ink-3": rgba(mode.text, 0.36), + "--bt-bubble": mode.bubble, + "--bt-sidebar": mode.sidebar, + "--bt-main": mode.bg, + "--bt-card": mode.card, + "--bt-line": rgba(mode.text, 0.08), + "--bt-line-2": rgba(mode.text, 0.15), + "--bt-hover": dark ? "rgba(160,160,160,.12)" : "rgba(119,119,119,.09)", + "--bt-active": dark ? "rgba(160,160,160,.2)" : "rgba(119,119,119,.17)", + "--bt-accent": accent, + "--bt-accent-ink": luminance(accent) > 0.45 ? "#141414" : "#ffffff", + "--bt-user": mode.user, + "--bt-user-ink": mode.userText, + "--bt-tag-bg": rgba(mode.text, dark ? 0.09 : 0.06), + "--bt-tag-ink": rgba(mode.text, 0.58) + }; +} +var declarations = (vars) => Object.entries(vars).map(([name, value]) => `${name}:${value}`).join(";"); +var themeCss = (theme, accent) => `html body{${declarations(themeVars(theme.light, false, accent ?? theme.light.accent))}} +html body[data-ds-dark-theme]{${declarations(themeVars(theme.dark, true, accent ?? theme.dark.accent))}} +${theme.css ?? ""}`; +var shellTokens = ({ light, dark }) => ({ + "--dsw-alias-bg-base": { light: light.bg, dark: dark.bg }, + "--dsw-specific-sidebar-fill": { light: light.sidebar, dark: dark.sidebar }, + "--dsw-specific-input-major": { light: light.bg, dark: dark.sidebar }, + "--dsw-alias-label-primary": { light: light.text, dark: dark.text }, + "--dsw-alias-label-secondary": { light: rgba(light.text, 0.74), dark: rgba(dark.text, 0.74) }, + "--dsw-alias-label-tertiary": { light: rgba(light.text, 0.6), dark: rgba(dark.text, 0.6) } +}); +var customThemes = {}; +var registry3 = createSource(0); +var isPoint = (point) => Array.isArray(point) && point.length === 2 && point.every(Number.isFinite); +function registerShape(id, shape) { + if (typeof id !== "string" || !/^[a-z][\w-]{0,31}$/.test(id)) throw new Error("Shape ids are lowercase words"); + if (typeof shape?.body !== "function" || typeof shape.body("#000") !== "string") throw new Error("A shape needs body(paint) returning SVG markup"); + const at = shape.eyes?.at; + if (!Array.isArray(at) || at.length !== 2 || !at.every(isPoint)) throw new Error("A shape needs eyes.at: two [x, y] points"); + const size = Number.isFinite(shape.size) && shape.size > 0 ? shape.size : 100; + const pick2 = (key, fallback) => Number.isFinite(shape.eyes[key]) ? shape.eyes[key] : fallback; + const eyes = at.map(([x, y]) => [x, y, pick2("w", size * 0.1), pick2("h", size * 0.23), pick2("tilt", -27)]); + const previous = SHAPES[id]; + SHAPES[id] = { + label: String(shape.label ?? id), + size, + body: shape.body, + eyes, + ...typeof shape.wrap === "string" ? { wrap: shape.wrap } : {} + }; + const refresh = () => { + markupCache.clear(); + maskCache.delete(id); + registry3.set((value) => value + 1); + }; + refresh(); + return () => { + if (previous) SHAPES[id] = previous; + else delete SHAPES[id]; + refresh(); + }; +} +function registerTheme(id, theme) { + if (typeof id !== "string" || !/^[a-z][\w-]{0,31}$/.test(id)) throw new Error("Theme ids are lowercase words"); + for (const mode of ["light", "dark"]) { + for (const key of THEME_KEYS) if (!isHex(theme?.[mode]?.[key])) throw new Error(`Theme ${mode}.${key} must be a #rrggbb color`); + } + const previous = customThemes[id]; + customThemes[id] = { label: String(theme.label ?? id), light: theme.light, dark: theme.dark, css: typeof theme.css === "string" ? theme.css : "" }; + registry3.set((value) => value + 1); + return () => { + if (previous) customThemes[id] = previous; + else delete customThemes[id]; + registry3.set((value) => value + 1); + }; +} +var allThemes = () => ({ ...THEMES, ...customThemes }); + // src/client/motion.js var import_react3 = require("react"); var reducedMotion = () => window.matchMedia?.("(prefers-reduced-motion: reduce)").matches === true; @@ -3826,6 +3832,8 @@ body[data-bt-remote-chat=empty][data-content-phase=hero] [class*="_scrollBody"]: [data-composer-card] [data-composer-input]{min-height:24px;padding:2px 0!important} [data-composer-card] [data-composer-placeholder]{left:0!important;top:2px!important} [data-composer-placeholder]{font-size:0!important;line-height:0!important} +/* A friend's Bot chat: no local run stats (they are relay turns, always 0; #419). */ +body[data-bt-friend-chat] [data-composer-stats]{display:none!important} [data-composer-placeholder]::after{content:var(--bt-placeholder,"Message");display:block;font-size:14px;line-height:24px;color:color-mix(in srgb,var(--bt-ink) 36%,transparent)} /* The team's "+" (ComposerPlus) takes the seat of the shell's command-menu button. */ [data-composer-card] button[class*="_add"]{display:none!important} @@ -5497,13 +5505,14 @@ function Nothing() { } var HIDDEN_REMOVED_TOOLS = /* @__PURE__ */ new Set(["ask_user_question"]); var isConnectorTool = (name) => typeof name === "string" && /^mcp__(github|composio)__/.test(name); -var isHiddenNotice = (block) => block?.type === "tool-removal" && (HIDDEN_REMOVED_TOOLS.has(block.toolName) || isConnectorTool(block.toolName)) || block?.type === "tool-addition" && isConnectorTool(block.toolName); +var isHiddenNotice = (block) => block?.type === "tool-removal" && (HIDDEN_REMOVED_TOOLS.has(block.toolName) || isConnectorTool(block.toolName) || isPluginTool(block.toolName)) || block?.type === "tool-addition" && (isConnectorTool(block.toolName) || isPluginTool(block.toolName)); function isHiddenToolRemoval(node) { const content = node?.data?.content; if (!Array.isArray(content) || content.length === 0) return false; return content.every(isHiddenNotice); } -function ContextCell({ node }) { +function ContextCell({ node, useRoster }) { + useRoster?.((roster) => (roster.pluginTools ?? []).join("\n")); if (isHiddenToolRemoval(node)) return null; const content = Array.isArray(node?.data?.content) ? node.data.content : []; const parts = []; @@ -7001,18 +7010,35 @@ function HeaderPill({ sessionId, useRoster, useSessions, useSessionStatus, useSe document.body.style.removeProperty("--bt-remote-empty"); }; }, [bot?.remote, blank3, remoteEmptyHint]); + (0, import_react10.useEffect)(() => { + if (!bot?.remote) return void 0; + document.body.dataset.btFriendChat = ""; + return () => { + delete document.body.dataset.btFriendChat; + }; + }, [bot?.remote]); (0, import_react10.useEffect)(() => { document.body.style.setProperty("--bt-placeholder", JSON.stringify(composerLabel)); const input = document.querySelector("[data-composer-input]"); const priorLabel = input?.getAttribute("aria-label") ?? null; const apply2 = () => { const el = document.querySelector("[data-composer-input]"); - if (el) el.setAttribute("aria-label", composerLabel); + if (el && el.getAttribute("aria-label") !== composerLabel) el.setAttribute("aria-label", composerLabel); }; apply2(); const timer = setTimeout(apply2, 0); + let frame = 0; + const watcher = typeof MutationObserver === "function" ? new MutationObserver(() => { + if (!frame) frame = requestAnimationFrame(() => { + frame = 0; + apply2(); + }); + }) : null; + watcher?.observe(document.body, { subtree: true, childList: true, attributes: true, attributeFilter: ["aria-label"] }); return () => { clearTimeout(timer); + watcher?.disconnect(); + if (frame) cancelAnimationFrame(frame); const el = document.querySelector("[data-composer-input]"); if (!el) return; if (priorLabel == null) el.removeAttribute("aria-label"); @@ -15612,6 +15638,10 @@ function apply(ctx) { const live2 = {}; if (value.connectors !== void 0 && JSON.stringify(value.connectors) !== JSON.stringify(current.connectors ?? [])) live2.connectors = value.connectors; if (value.capabilityStatuses !== void 0 && JSON.stringify(value.capabilityStatuses) !== JSON.stringify(current.capabilityStatuses ?? {})) live2.capabilityStatuses = value.capabilityStatuses; + if (value.pluginTools !== void 0 && JSON.stringify(value.pluginTools) !== JSON.stringify(current.pluginTools ?? [])) { + setPluginTools(value.pluginTools); + live2.pluginTools = value.pluginTools; + } if (Object.keys(live2).length > 0 && current.ready) roster.set({ ...current, ...live2 }); if (value.unchanged || value.revision === current.revision && value.modelsKey === current.modelsKey) return; if (Object.keys(seenAt).length === 0) leaveVisits([...value.bots, ...value.rooms].map((entry) => entry.id)); @@ -16318,7 +16348,7 @@ function apply(ctx) { shadow("conversation.chat.node", { key: "turn-error", priority: -10, locale: "chat", inject: face }, TurnErrorCell), // DSH's context row is "Tool removed: ask_user_question" after Bot sessions // restrict that tool; hide it, keep other tool-change notices (#140). - shadow("conversation.chat.node", { key: "context", priority: -10, locale: "chat" }, ContextCell), + shadow("conversation.chat.node", { key: "context", priority: -10, locale: "chat", inject: face }, ContextCell), ...canDrawUserMessages() ? ["user", "steering"].map((key) => shadow("conversation.chat.node", { key, priority: -10, locale: "chat", inject: face }, UserMessageCell)) : [], shadow("conversation.input.permission", { priority: -10 }, Nothing), shadow("conversation.input.plan", { priority: -10 }, Nothing), diff --git a/core/src/client/cells.js b/core/src/client/cells.js index a012690c9..4fc0aa114 100644 --- a/core/src/client/cells.js +++ b/core/src/client/cells.js @@ -2,7 +2,7 @@ import { createElement as h, Fragment, useEffect, useLayoutEffect, useMemo, useR import { createPortal } from 'react-dom' import { MarkdownText } from '@deepseek-ai/dsh-client-ui-primitives' import { inkText } from './inks.js' -import { currentPart, linkMentions, mentionTarget, dayLabel, contentText, stripHeader, stripTrace, isHiddenTurn, typedBy, isRelayContext, relaysBefore, relayPart, commRunOf, toolName, toolArgs, reminderPrompts } from './text.js' +import { currentPart, linkMentions, mentionTarget, dayLabel, contentText, stripHeader, stripTrace, isHiddenTurn, typedBy, isRelayContext, relaysBefore, relayPart, commRunOf, toolName, toolArgs, reminderPrompts, isPluginTool } from './text.js' import { SmileIcon, ReplyIcon, DotsIcon, CheckIcon, DownloadIcon, CopyIcon, MemoryIcon } from './icons.js' import { colorOf, lookBotForRemote, toolState, BotMark, PersonMark, RoomAvatar } from './mark.js' import { AnsweredCell, SettledQuestion, PendingQuestion } from './questions.js' @@ -561,8 +561,10 @@ export function Nothing() { return null } export const HIDDEN_REMOVED_TOOLS = new Set(['ask_user_question']) // Connector tools come and go with a Bot's connector switch (#335), not with anything the Bot did. const isConnectorTool = name => typeof name === 'string' && /^mcp__(github|composio)__/.test(name) -const isHiddenNotice = block => (block?.type === 'tool-removal' && (HIDDEN_REMOVED_TOOLS.has(block.toolName) || isConnectorTool(block.toolName))) - || (block?.type === 'tool-addition' && isConnectorTool(block.toolName)) +// Plugin tools a per-Bot mask hides (tree's create_child_bot, list_children, …) come and go with the +// Bot's place, never with anything a person did, so their rows stay out of every chat (#419). +const isHiddenNotice = block => (block?.type === 'tool-removal' && (HIDDEN_REMOVED_TOOLS.has(block.toolName) || isConnectorTool(block.toolName) || isPluginTool(block.toolName))) + || (block?.type === 'tool-addition' && (isConnectorTool(block.toolName) || isPluginTool(block.toolName))) export function isHiddenToolRemoval(node) { const content = node?.data?.content @@ -570,7 +572,9 @@ export function isHiddenToolRemoval(node) { return content.every(isHiddenNotice) } -export function ContextCell({ node }) { +export function ContextCell({ node, useRoster }) { + // Plugin tool names can arrive after this row has drawn (a plugin reload): follow them (#419). + useRoster?.(roster => (roster.pluginTools ?? []).join('\n')) if (isHiddenToolRemoval(node)) return null const content = Array.isArray(node?.data?.content) ? node.data.content : [] const parts = [] diff --git a/core/src/client/header.js b/core/src/client/header.js index a4234644f..1bce84d23 100644 --- a/core/src/client/header.js +++ b/core/src/client/header.js @@ -94,18 +94,34 @@ export function HeaderPill({ sessionId, useRoster, useSessions, useSessionStatus document.body.style.removeProperty('--bt-remote-empty') } }, [bot?.remote, blank, remoteEmptyHint]) + // A friend's Bot runs on its owner's home: the local run stats (turns, steps, tok/s, cache) + // are relay turns and always 0 here, so CSS hides the shell's stats footer in that chat (#419). + useEffect(() => { + if (!bot?.remote) return undefined + document.body.dataset.btFriendChat = '' + return () => { delete document.body.dataset.btFriendChat } + }, [bot?.remote]) useEffect(() => { document.body.style.setProperty('--bt-placeholder', JSON.stringify(composerLabel)) const input = document.querySelector('[data-composer-input]') const priorLabel = input?.getAttribute('aria-label') ?? null const apply = () => { const el = document.querySelector('[data-composer-input]') - if (el) el.setAttribute('aria-label', composerLabel) + if (el && el.getAttribute('aria-label') !== composerLabel) el.setAttribute('aria-label', composerLabel) } apply() const timer = setTimeout(apply, 0) + // The shell re-renders its composer after a send and puts its own coding label back + // ("Message or run a task, / commands, …"); keep "Message " on it (#419). + let frame = 0 + const watcher = typeof MutationObserver === 'function' + ? new MutationObserver(() => { if (!frame) frame = requestAnimationFrame(() => { frame = 0; apply() }) }) + : null + watcher?.observe(document.body, { subtree: true, childList: true, attributes: true, attributeFilter: ['aria-label'] }) return () => { clearTimeout(timer) + watcher?.disconnect() + if (frame) cancelAnimationFrame(frame) const el = document.querySelector('[data-composer-input]') if (!el) return if (priorLabel == null) el.removeAttribute('aria-label') diff --git a/core/src/client/index.js b/core/src/client/index.js index 762346701..2b3791cf7 100644 --- a/core/src/client/index.js +++ b/core/src/client/index.js @@ -4,7 +4,7 @@ import { isHex, inkText, paintCss } from './inks.js' import { DEFAULT_MASK, SHAPES, markupCache, maskOf } from './characters.js' import { countCards, createSource, EMPTY_ROSTER, indexRoster, isAgentSession } from './sources.js' import { themeCss, shellTokens, registry, registerShape, registerTheme, allThemes } from './themes.js' -import { MENTION_ORIGIN, currentPart, landingPart } from './text.js' +import { MENTION_ORIGIN, currentPart, landingPart, setPluginTools } from './text.js' import { SHELL_CSS, CONVERSATION_CSS, installStyles, installCompiledStyles } from './styles.js' import { installBrandChrome, installTabTitle } from './brand.js' import { lookOf } from './mark.js' @@ -158,6 +158,8 @@ export function apply(ctx) { const live = {} if (value.connectors !== undefined && JSON.stringify(value.connectors) !== JSON.stringify(current.connectors ?? [])) live.connectors = value.connectors if (value.capabilityStatuses !== undefined && JSON.stringify(value.capabilityStatuses) !== JSON.stringify(current.capabilityStatuses ?? {})) live.capabilityStatuses = value.capabilityStatuses + // Plugin tool names come with a plugin reload, not a revision; context rows re-read them (#419). + if (value.pluginTools !== undefined && JSON.stringify(value.pluginTools) !== JSON.stringify(current.pluginTools ?? [])) { setPluginTools(value.pluginTools); live.pluginTools = value.pluginTools } if (Object.keys(live).length > 0 && current.ready) roster.set({ ...current, ...live }) if (value.unchanged || (value.revision === current.revision && value.modelsKey === current.modelsKey)) return // On a browser with no visit record yet, everything already there counts as seen. @@ -822,7 +824,7 @@ export function apply(ctx) { shadow('conversation.chat.node', { key: 'turn-error', priority: -10, locale: 'chat', inject: face }, TurnErrorCell), // DSH's context row is "Tool removed: ask_user_question" after Bot sessions // restrict that tool; hide it, keep other tool-change notices (#140). - shadow('conversation.chat.node', { key: 'context', priority: -10, locale: 'chat' }, ContextCell), + shadow('conversation.chat.node', { key: 'context', priority: -10, locale: 'chat', inject: face }, ContextCell), ...(canDrawUserMessages() ? ['user', 'steering'].map(key => shadow('conversation.chat.node', { key, priority: -10, locale: 'chat', inject: face }, UserMessageCell)) : []), shadow('conversation.input.permission', { priority: -10 }, Nothing), shadow('conversation.input.plan', { priority: -10 }, Nothing), diff --git a/core/src/client/sources.js b/core/src/client/sources.js index fefa11862..508f4f616 100644 --- a/core/src/client/sources.js +++ b/core/src/client/sources.js @@ -1,4 +1,5 @@ import { setLocalBots } from './characters.js' +import { setPluginTools } from './text.js' // ------------------------------------------------------------------------- // Small observable sources for the inject `hooks` compartment. @@ -27,6 +28,7 @@ export const isMainOf = (roster, id) => id !== undefined && (roster.mainBotIds ? // (running, unread, turns) is read from the current part, team state from the Bot id. export function indexRoster(value) { setLocalBots(value.bots) + setPluginTools(value.pluginTools) const byId = {} for (const bot of value.bots) { for (const id of [...(bot.parts ?? []), bot.sessionId ?? bot.id, bot.id]) byId[id] = bot diff --git a/core/src/client/styles.js b/core/src/client/styles.js index 8a108aa36..e0c9e8768 100644 --- a/core/src/client/styles.js +++ b/core/src/client/styles.js @@ -915,6 +915,8 @@ body[data-bt-remote-chat=empty][data-content-phase=hero] [class*="_scrollBody"]: [data-composer-card] [data-composer-input]{min-height:24px;padding:2px 0!important} [data-composer-card] [data-composer-placeholder]{left:0!important;top:2px!important} [data-composer-placeholder]{font-size:0!important;line-height:0!important} +/* A friend's Bot chat: no local run stats (they are relay turns, always 0; #419). */ +body[data-bt-friend-chat] [data-composer-stats]{display:none!important} [data-composer-placeholder]::after{content:var(--bt-placeholder,"Message");display:block;font-size:14px;line-height:24px;color:color-mix(in srgb,var(--bt-ink) 36%,transparent)} /* The team's "+" (ComposerPlus) takes the seat of the shell's command-menu button. */ [data-composer-card] button[class*="_add"]{display:none!important} diff --git a/core/src/client/text.js b/core/src/client/text.js index 3aa206e1d..bee0599a5 100644 --- a/core/src/client/text.js +++ b/core/src/client/text.js @@ -148,6 +148,13 @@ export function isHiddenTurn(roster, sessionId, turn) { export const toolName = root => root.name || root.call?.name +// Plugin tools a per-Bot mask adds or removes (the roster's `pluginTools`, #419). +let pluginTools = new Set() +export function setPluginTools(names) { + pluginTools = new Set((Array.isArray(names) ? names : []).filter(name => typeof name === 'string')) +} +export const isPluginTool = name => typeof name === 'string' && pluginTools.has(name) + // While a Session's history loads, a card asked in it may yet be drawn at its call, // so the docks hold it back until then. export const historyLoading = snapshot => snapshot?.openState === 'cold' || snapshot?.openState === 'loading' diff --git a/core/src/host/channel.js b/core/src/host/channel.js index c0bf727bd..322828aeb 100644 --- a/core/src/host/channel.js +++ b/core/src/host/channel.js @@ -116,7 +116,9 @@ export function install(rt) { connectors: rt.connectorsView(), capabilityStatuses: rt.capabilityStatusesView(), } - return rt.ownedLive(full, actor) + // Plugin tool names change with a plugin reload, not with the team revision, so they ride every + // answer, unchanged or not, and the client re-reads its hidden-row set (#419). + return { ...rt.ownedLive(full, actor), pluginTools: rt.pluginToolNames?.() ?? [] } } // What a host that only reads the team may still answer. // Updates change the dsh profile, not the team, so a reading host may run them too. diff --git a/core/src/host/hooks.js b/core/src/host/hooks.js index 6aebf149f..ee759877c 100644 --- a/core/src/host/hooks.js +++ b/core/src/host/hooks.js @@ -649,6 +649,9 @@ export function install(rt) { // Bot that joins (or leaves) a plugin's scope gets (or loses) the tool next turn. const gatedTools = new Map() const masks = new WeakMap() + // The names of plugin tools a mask may take away (tree's create_child_bot, …): the client hides + // their "Tool added" / "Tool removed" context rows, which say nothing a person needs (#419). + rt.pluginToolNames = () => [...gatedTools.keys()].sort() function hidePluginTools(agent) { if (agent === undefined || typeof agent.ctx?.tools?.restrict !== 'function') return const bot = rt.botOf(rt.selfOf(agent.session?.id ?? agent.id)) diff --git a/core/test/hidden-tool-removal.test.js b/core/test/hidden-tool-removal.test.js index 9db5528fb..c4ebe58c9 100644 --- a/core/test/hidden-tool-removal.test.js +++ b/core/test/hidden-tool-removal.test.js @@ -9,7 +9,7 @@ import { build } from 'esbuild' // "Tool removed: ask_user_question" hide can be checked without a browser. const { outputFiles } = await build({ stdin: { - contents: "export { ContextCell, isHiddenToolRemoval, HIDDEN_REMOVED_TOOLS, ToolCell } from './src/client/cells.js'", + contents: "export { ContextCell, isHiddenToolRemoval, HIDDEN_REMOVED_TOOLS, ToolCell } from './src/client/cells.js'; export { indexRoster } from './src/client/sources.js'", resolveDir: fileURLToPath(new URL('..', import.meta.url)), }, bundle: true, format: 'cjs', platform: 'browser', write: false, logLevel: 'silent', @@ -35,7 +35,7 @@ runInNewContext(outputFiles[0].text, { throw new Error(`Unexpected platform module ${name}`) }, }) -const { ContextCell, isHiddenToolRemoval, HIDDEN_REMOVED_TOOLS, ToolCell } = module.exports +const { ContextCell, isHiddenToolRemoval, HIDDEN_REMOVED_TOOLS, ToolCell, indexRoster } = module.exports const expand = (node) => { if (node === null || node === undefined || typeof node === 'boolean') return null @@ -110,3 +110,25 @@ test('#335: connector tools coming and going with a Bot\'s connector switch leav const mixed = { data: { content: [{ type: 'tool-addition', toolName: 'mcp__composio__COMPOSIO_SEARCH_TOOLS' }, { type: 'tool-addition', toolName: 'web_search' }] } } assert.equal(text(expand(ContextCell({ node: mixed }))), 'Tool added: web_search') }) + +test('#419: plugin tools a per-Bot mask takes away (tree) leave no Tool removed / Tool added line in any chat', () => { + const block = (type, toolName) => ({ data: { content: [{ type, toolName }] } }) + indexRoster({ bots: [], rooms: [], pluginTools: [] }) + assert.match(text(expand(ContextCell({ node: block('tool-removal', 'create_child_bot') }))), /Tool removed: create_child_bot/) + indexRoster({ bots: [], rooms: [], pluginTools: ['create_child_bot', 'list_children'] }) + const both = { data: { content: [{ type: 'tool-removal', toolName: 'create_child_bot' }, { type: 'tool-removal', toolName: 'list_children' }] } } + assert.equal(isHiddenToolRemoval(both), true) + assert.equal(ContextCell({ node: both }), null) + assert.equal(ContextCell({ node: block('tool-addition', 'list_children') }), null) + // Other tools still show. + assert.match(text(expand(ContextCell({ node: block('tool-removal', 'bash') }))), /Tool removed: bash/) + // The row follows the roster's plugin tool names, so names that arrive after it drew still hide it. + const selected = [] + ContextCell({ node: block('tool-removal', 'bash'), useRoster: select => { selected.push(select({ pluginTools: ['a', 'b'] })); return selected.at(-1) } }) + assert.deepEqual(selected, ['a\nb']) + const index = readFileSync(new URL('../src/client/index.js', import.meta.url), 'utf8') + assert.match(index, /key: 'context', priority: -10, locale: 'chat', inject: face \}, ContextCell/) + assert.match(index, /setPluginTools\(value\.pluginTools\); live\.pluginTools = value\.pluginTools/) + indexRoster({ bots: [], rooms: [] }) + assert.match(text(expand(ContextCell({ node: block('tool-removal', 'create_child_bot') }))), /Tool removed: create_child_bot/) +}) diff --git a/core/test/looks.test.js b/core/test/looks.test.js index 27e92e01f..db5081540 100644 --- a/core/test/looks.test.js +++ b/core/test/looks.test.js @@ -62,6 +62,11 @@ test('the whole Settings dialog is Settings; one Bot opens as Edit Bot or Bot se assert.match(text('sidebar.js'), /const settings = wide \? h\('button'/) assert.doesNotMatch(text('sidebar.js'), /wide && bot\.remote \? h\('button'/) assert.match(text('header.js'), /setAttribute\('aria-label', composerLabel\)/) + // #419: the composer keeps "Message " after the shell re-renders it; a friend's Bot chat hides the stats footer. + assert.match(text('header.js'), /new MutationObserver\(/) + assert.match(text('header.js'), /attributeFilter: \['aria-label'\]/) + assert.match(text('header.js'), /document\.body\.dataset\.btFriendChat = ''/) + assert.match(text('styles.js'), /body\[data-bt-friend-chat\] \[data-composer-stats\]\{display:none!important\}/) assert.match(text('settings.js'), /filter\(bot => !bot\.remote\)/) // #276: only the user's own Bots are offered as a Main Bot, in both selects. assert.match(text('settings.js'), /const own = roster\.bots\.filter\(bot => !bot\.remote\)/) diff --git a/core/test/roster.test.js b/core/test/roster.test.js index 7db887e08..6054eebf2 100644 --- a/core/test/roster.test.js +++ b/core/test/roster.test.js @@ -273,6 +273,16 @@ test('the state reports a changed model list without a new revision', async (t) assert.equal((await call('state', { since: first.revision, models: 'stale' })).value.unchanged, undefined) }) +test('#419: plugin tool names ride every state answer, an unchanged one too (a plugin reload makes no revision)', async (t) => { + const { call, stop } = start() + t.after(stop) + const first = (await call('state', {})).value + assert.ok(Array.isArray(first.pluginTools)) + const again = (await call('state', { since: first.revision, models: first.modelsKey })).value + assert.equal(again.unchanged, true) + assert.deepEqual(again.pluginTools, first.pluginTools) +}) + test('a new team starts with a DeepSeek-blue whale as its Main Bot', async (t) => { const { call, stop } = start() t.after(stop) diff --git a/docs/subsystems/core-client.md b/docs/subsystems/core-client.md index e682c9184..0c9ece48f 100644 --- a/docs/subsystems/core-client.md +++ b/docs/subsystems/core-client.md @@ -74,7 +74,7 @@ The overlay (`flybot-slots.js` `FlyBotApprovals`) is the fallback, not a second - A friend's Bot settings surface is not the local editor (#160): no rename, avatar editor, model, folder, instructions, Main Bot, Hide, Duplicate, or Delete. The details gear and context menu say **Bot settings**. The menu is Mark as Unread, Bot settings, and Copy conversation ID. Per-friend **Auto-send** and **Remove this Bot** stay on that page (`flybot.bot.details`); Remove closes the overlay so a stale local editor cannot appear. The Bots settings list is local Bots only. - A friend's Bot details overview is that compact panel: handle plus `FlyBotDetails` (Auto-send and a muted Remove link), no Browser tab, no Usage / model / folder. Opening the gear uses `remote-bot-settings` (`RemoteBotSettingsSheet`): a bottom sheet on narrow viewports and a centered card from 520px up, with the scrim behind the dialog (`z-index` stacking like `bt-settings` / `bt-mem`) so the sheet stays clickable through enter/leave animations. The surrounding DSH settings-shell chrome (back/title) is not redesigned (#166). The sheet takes focus when it opens (`tabIndex -1`, `preventScroll`), closes on Escape from its own `onKeyDown` as well as `useEscape`, and gives focus back without scrolling; in the tree a friend's `.bt-row-wrap` (row plus gear) shrinks like a plain row (`.bt-tree-item>.bt-row-wrap`), so the gear never overflows the sidebar and focusing it cannot scroll it sideways (#274). On every wide row (own Bots too) the gear's focus and click run `keepSidebarStill`, which puts back any sideways scroll a focus left on the row's ancestors or the page, now and after the settings dialog opened (#421). The Settings nav's last entry reads **Harness settings ↗** with a tooltip saying it opens DeepSeek Harness's own settings in a separate window; FlyBot does not embed or retitle that dialog (#421). - Every listed Bot row in wide sidebar mode is a `bt-row-wrap`: the session `bt-row` and a sibling gear (`bt-row-settings`, hover/focus) that opens Edit Bot for an own Bot or Bot settings for a friend's (#166, #276). -- `HeaderPill` sets the composer placeholder CSS var and mirrors the localized **Message {name}** string on `[data-composer-input]`'s `aria-label`; cleanup restores the prior label when the header unmounts or the session changes (#166). +- `HeaderPill` sets the composer placeholder CSS var and mirrors the localized **Message {name}** string on `[data-composer-input]`'s `aria-label`; cleanup restores the prior label when the header unmounts or the session changes (#166). The shell re-renders its composer after a send and puts its own coding label back, so a `MutationObserver` (rAF-throttled, `aria-label` and child changes) keeps **Message {name}** on it while the chat is open (#419). In a friend's Bot chat it also sets `body[data-bt-friend-chat]`, and CSS hides the shell's run-stats footer (`[data-composer-stats]`) there: the friend's Bot runs on its owner's home, so local turns/steps/tok/s are relay turns and always 0 (#419). `ContextCell` hides `Tool added` / `Tool removed` rows for the roster's `pluginTools` (set in `indexRoster` via `setPluginTools`, `text.js`) as it does for connector tools (#419). The poll re-reads `pluginTools` from every `state` answer (a plugin reload makes no revision), and `ContextCell` (injected with `face`) selects them from the roster so a row already drawn re-renders when they arrive. ### Face-room held bubble and overview diff --git a/docs/subsystems/core-host.md b/docs/subsystems/core-host.md index 758fe0c16..b29f249b2 100644 --- a/docs/subsystems/core-host.md +++ b/docs/subsystems/core-host.md @@ -140,6 +140,7 @@ credentials and team mode remain separate work under #55/#198. ### Remote chats and mentions - Channel `view()` puts `pendingCount` on a friend's chat (`bot.remote`) from `rt.listChatApprovals`, the same gold wait badge rooms already use. A local team Bot gets the count of its own held sends when the provider names them (`fromId`, #371); otherwise 0. The social plugin supplies the list via `flybot.chatApprovals.setProvider` ([plugin-runtime](plugin-runtime.md)). Still not team mode. +- Channel `live()` also sends `pluginTools`: the names of plugin tools a per-Bot mask may take away (`rt.pluginToolNames`, from `hidePluginTools`' gated set), so the client hides their `Tool added` / `Tool removed` rows (#419). A plugin reload changes them without a new revision, so they ride every `state` answer, an `unchanged` one too. - A face-to-face room's presented Bot participants carry `label` = `remoteBotTitle`, ` ()`, for remote and local seats alike (`room-membership.js`, #384); `faceAliases` still answers to the older ` · 's Bot` / `'s Chief` labels. - `remoteTurn` (`src/host/remote-chat.js`) finds what the user typed on a friend's remote chat by skipping host-appended `role:'user'` context: forms `snapshot`, `instructions` (the AGENTS.md baseline on a chat's first turn) and `notice`, and the time-context kinds. Match by form, not by a whitelist of senders, or the first message of a new chat is dropped (#66). - `notSentLine` (`src/host/remote-chat.js`) is the one short line for a failed send to a friend's Bot. It names only steps a FlyBot owner can take (#404): a paused chat says to approve a waiting message or remove and re-add the friend, and the fallbacks end in *Try again.*; it never points to the Bot Social console, which a FlyBot owner cannot sign in to. diff --git a/docs/subsystems/plugin-runtime.md b/docs/subsystems/plugin-runtime.md index 943ab1a3c..c1c6d2d4f 100644 --- a/docs/subsystems/plugin-runtime.md +++ b/docs/subsystems/plugin-runtime.md @@ -50,7 +50,7 @@ FlyBot's plugin API surface on top of the patched core: host hooks, client slots 2. Slots/registry must keep the client build green (`npm run build`). 3. Types in `flybot.d.ts` stay in sync with hooks/slots. 4. Plugins report a host-visible failure with `flybot.warn` (Settings → Feedback / `diagnostics.jsonl`). They must not depend on `ctx.logger`: DSH may export none, and `flybot.warn` delegates to `rt.warn`. - A tool whose `visibleTo` says no is taken out of that Bot's list with `agent.ctx.tools.restrict({ deny })` (`hidePluginTools`). DSH's `tools.schemas(agent)` lists only what the Agent sees, so the tools the current mask hides count as known when the mask is recomputed; otherwise every other resolve lifts the mask and the chat shows `Tool added` / `Tool removed` on each turn (#276). + A tool whose `visibleTo` says no is taken out of that Bot's list with `agent.ctx.tools.restrict({ deny })` (`hidePluginTools`). DSH's `tools.schemas(agent)` lists only what the Agent sees, so the tools the current mask hides count as known when the mask is recomputed; otherwise every other resolve lifts the mask and the chat shows `Tool added` / `Tool removed` on each turn (#276). The names of these gated tools go to the client as the roster's `pluginTools` (`rt.pluginToolNames`), and the client's `ContextCell` hides `Tool added` / `Tool removed` rows for them in every chat, so the first mask leaves no line either (#419). 5. Reach (#90): the core's owner policy (`reach.js`) is not a middleware. `hooks.js` runs it before the `bot.send` chain, on approval release and on every deliver that carries a sender or a remote origin; a plugin's `allow` or `bypass` never skips it, and plugins see its denial as `{ action: 'deny', by: 'owner-policy' }`. Plugins read grants through `flybot.bot.hasGrant` / `hasAnyGrant`, `BotView.granted`, `flybot.grants` and the `bot.grants` event. A group's mention receipt (`mentionReceipt`) turns a not-shared refusal (`cannot message their Bots`, #273) into its short invite-or-add hint. 6. `flybot.roomApprovals.setProvider` lets a plugin (social) attach pending outbound holds to a shared-room view. It does not enable team mode. 7. `cleanRemote` is the record boundary for a friend's granted Bot: `name`, `homeName` and `owner` strip `@` (so they cannot look like a mention of another Bot in `nameMentions` chips/previews) and control characters/newlines, then cap at 60 characters. Handles stay on the `handle` field. Optional look fields (`color`, `avatar` object, `avatarUpdatedAt`) pass through when present so remote chats, group lists, and headers can render a peer's chosen look; missing look stays handle-seeded. Those labels then flow into delivery `fromName` (still `cleanName`'d and the body fenced) and into `list_bots` friends lines via the remote-chat roster. Group room logs use local team names, not peer labels. diff --git a/patches/ds-bot/0001-core-fixes.patch b/patches/ds-bot/0001-core-fixes.patch index 2716523d1..ad8360140 100644 --- a/patches/ds-bot/0001-core-fixes.patch +++ b/patches/ds-bot/0001-core-fixes.patch @@ -939,6 +939,20 @@ diff --git a/src/client/text.js b/src/client/text.js export function isHiddenTurn(roster, sessionId, turn) { if (sessionId === undefined || turn === undefined) return false return (roster.exchangeTurns[sessionId] ?? []).includes(turn) +@@ -102,6 +102,13 @@ export function isHiddenTurn(roster, sessionId, turn) { + + export const toolName = root => root.name || root.call?.name + ++// Plugin tools a per-Bot mask adds or removes (the roster's `pluginTools`, #419). ++let pluginTools = new Set() ++export function setPluginTools(names) { ++ pluginTools = new Set((Array.isArray(names) ? names : []).filter(name => typeof name === 'string')) ++} ++export const isPluginTool = name => typeof name === 'string' && pluginTools.has(name) ++ + // While a Session's history loads, a card asked in it may yet be drawn at its call, + // so the docks hold it back until then. + export const historyLoading = snapshot => snapshot?.openState === 'cold' || snapshot?.openState === 'loading' @@ -207,7 +207,8 @@ export function previewOf(list, id, hiddenTurns = []) { const entry = outline[index] if (hiddenTurns.includes(entry.turn)) continue diff --git a/patches/ds-bot/0003-flybot-api-client.patch b/patches/ds-bot/0003-flybot-api-client.patch index 47ff7c4c8..a17c9d46f 100644 --- a/patches/ds-bot/0003-flybot-api-client.patch +++ b/patches/ds-bot/0003-flybot-api-client.patch @@ -226,7 +226,7 @@ diff --git a/src/client/cells.js b/src/client/cells.js import { MarkdownText } from '@deepseek-ai/dsh-client-ui-primitives' import { inkText } from './inks.js' -import { linkMentions, mentionTarget, dayLabel, contentText, stripHeader, isHiddenTurn, isRelayContext, relaysBefore, relayPart, commRunOf, toolName, toolArgs, reminderPrompts } from './text.js' -+import { currentPart, linkMentions, mentionTarget, dayLabel, contentText, stripHeader, stripTrace, isHiddenTurn, typedBy, isRelayContext, relaysBefore, relayPart, commRunOf, toolName, toolArgs, reminderPrompts } from './text.js' ++import { currentPart, linkMentions, mentionTarget, dayLabel, contentText, stripHeader, stripTrace, isHiddenTurn, typedBy, isRelayContext, relaysBefore, relayPart, commRunOf, toolName, toolArgs, reminderPrompts, isPluginTool } from './text.js' import { SmileIcon, ReplyIcon, DotsIcon, CheckIcon, DownloadIcon, CopyIcon, MemoryIcon } from './icons.js' -import { colorOf, toolState, BotMark, RoomAvatar } from './mark.js' +import { colorOf, lookBotForRemote, toolState, BotMark, PersonMark, RoomAvatar } from './mark.js' @@ -454,7 +454,7 @@ diff --git a/src/client/cells.js b/src/client/cells.js const room = roster.roomsById[sessionId] const poster = roster.byId[source.senderSessionId] ?? { id: source.senderSessionId, name: source.senderName ?? t('a Bot'), color: 'gray' } return h(GroupMessage, { bot: poster, text: stripHeader(contentText(data.content)), room, roster, actions, msgKey: `${sessionId}:${data.turn}:post`, useUi }) -@@ -411,6 +411,34 @@ export function TurnProcessCell({ node, sessionId, turnProcess, useRoster, useUi +@@ -411,6 +411,38 @@ export function TurnProcessCell({ node, sessionId, turnProcess, useRoster, useUi export function Nothing() { return null } @@ -464,8 +464,10 @@ diff --git a/src/client/cells.js b/src/client/cells.js +export const HIDDEN_REMOVED_TOOLS = new Set(['ask_user_question']) +// Connector tools come and go with a Bot's connector switch (#335), not with anything the Bot did. +const isConnectorTool = name => typeof name === 'string' && /^mcp__(github|composio)__/.test(name) -+const isHiddenNotice = block => (block?.type === 'tool-removal' && (HIDDEN_REMOVED_TOOLS.has(block.toolName) || isConnectorTool(block.toolName))) -+ || (block?.type === 'tool-addition' && isConnectorTool(block.toolName)) ++// Plugin tools a per-Bot mask hides (tree's create_child_bot, list_children, …) come and go with the ++// Bot's place, never with anything a person did, so their rows stay out of every chat (#419). ++const isHiddenNotice = block => (block?.type === 'tool-removal' && (HIDDEN_REMOVED_TOOLS.has(block.toolName) || isConnectorTool(block.toolName) || isPluginTool(block.toolName))) ++ || (block?.type === 'tool-addition' && (isConnectorTool(block.toolName) || isPluginTool(block.toolName))) + +export function isHiddenToolRemoval(node) { + const content = node?.data?.content @@ -473,7 +475,9 @@ diff --git a/src/client/cells.js b/src/client/cells.js + return content.every(isHiddenNotice) +} + -+export function ContextCell({ node }) { ++export function ContextCell({ node, useRoster }) { ++ // Plugin tool names can arrive after this row has drawn (a plugin reload): follow them (#419). ++ useRoster?.(roster => (roster.pluginTools ?? []).join('\n')) + if (isHiddenToolRemoval(node)) return null + const content = Array.isArray(node?.data?.content) ? node.data.content : [] + const parts = [] @@ -699,7 +703,7 @@ diff --git a/src/client/index.js b/src/client/index.js import { countCards, createSource, EMPTY_ROSTER, indexRoster, isAgentSession } from './sources.js' import { themeCss, shellTokens, registry, registerShape, registerTheme, allThemes } from './themes.js' -import { MENTION_ORIGIN, currentPart } from './text.js' -+import { MENTION_ORIGIN, currentPart, landingPart } from './text.js' ++import { MENTION_ORIGIN, currentPart, landingPart, setPluginTools } from './text.js' import { SHELL_CSS, CONVERSATION_CSS, installStyles, installCompiledStyles } from './styles.js' +import { installBrandChrome, installTabTitle } from './brand.js' import { lookOf } from './mark.js' @@ -762,6 +766,15 @@ diff --git a/src/client/index.js b/src/client/index.js // Bot mode paints the whole team surface; Agent mode withdraws every slot and // style so the shell's own interface returns. `plain` stays false until a Bot-mode // view hosts a plain session, which drops only the conversation replacements. +@@ -145,6 +145,8 @@ export function apply(ctx) { + const live = {} + if (value.connectors !== undefined && JSON.stringify(value.connectors) !== JSON.stringify(current.connectors ?? [])) live.connectors = value.connectors + if (value.capabilityStatuses !== undefined && JSON.stringify(value.capabilityStatuses) !== JSON.stringify(current.capabilityStatuses ?? {})) live.capabilityStatuses = value.capabilityStatuses ++ // Plugin tool names come with a plugin reload, not a revision; context rows re-read them (#419). ++ if (value.pluginTools !== undefined && JSON.stringify(value.pluginTools) !== JSON.stringify(current.pluginTools ?? [])) { setPluginTools(value.pluginTools); live.pluginTools = value.pluginTools } + if (Object.keys(live).length > 0 && current.ready) roster.set({ ...current, ...live }) + if (value.unchanged || (value.revision === current.revision && value.modelsKey === current.modelsKey)) return + // On a browser with no visit record yet, everything already there counts as seen. @@ -325,7 +325,7 @@ export function apply(ctx) { ctx.inject(['shortcuts'], scope => { scope.effect(() => holdShellShortcuts(scope.shortcuts), 'ds-bot: settings shortcut') }) @@ -894,7 +907,7 @@ diff --git a/src/client/index.js b/src/client/index.js + shadow('conversation.chat.node', { key: 'turn-error', priority: -10, locale: 'chat', inject: face }, TurnErrorCell), + // DSH's context row is "Tool removed: ask_user_question" after Bot sessions + // restrict that tool; hide it, keep other tool-change notices (#140). -+ shadow('conversation.chat.node', { key: 'context', priority: -10, locale: 'chat' }, ContextCell), ++ shadow('conversation.chat.node', { key: 'context', priority: -10, locale: 'chat', inject: face }, ContextCell), ...(canDrawUserMessages() ? ['user', 'steering'].map(key => shadow('conversation.chat.node', { key, priority: -10, locale: 'chat', inject: face }, UserMessageCell)) : []), shadow('conversation.input.permission', { priority: -10 }, Nothing), shadow('conversation.input.plan', { priority: -10 }, Nothing), @@ -1805,6 +1818,15 @@ diff --git a/src/client/styles.js b/src/client/styles.js [data-content-phase=hero] [class*="_composerHero"]{align-self:stretch!important;width:auto!important;padding-bottom:0!important} [data-content-phase=hero] [class*="_composerHero"]>[class$="_root"],[data-content-phase=hero] [class*="_heroWorkspaceRow"]{display:none!important} [data-content-phase=hero] [class*="_composerHero"] [class*="_input"]{min-height:0!important} +@@ -874,6 +874,8 @@ header[class*="_header"] .bt-head{flex:1 1 auto;width:100%} + [data-composer-card] [data-composer-input]{min-height:24px;padding:2px 0!important} + [data-composer-card] [data-composer-placeholder]{left:0!important;top:2px!important} + [data-composer-placeholder]{font-size:0!important;line-height:0!important} ++/* A friend's Bot chat: no local run stats (they are relay turns, always 0; #419). */ ++body[data-bt-friend-chat] [data-composer-stats]{display:none!important} + [data-composer-placeholder]::after{content:var(--bt-placeholder,"Message");display:block;font-size:14px;line-height:24px;color:color-mix(in srgb,var(--bt-ink) 36%,transparent)} + /* The team's "+" (ComposerPlus) takes the seat of the shell's command-menu button. */ + [data-composer-card] button[class*="_add"]{display:none!important} @@ -905,6 +905,7 @@ header[class*="_header"] .bt-head{flex:1 1 auto;width:100%} .bt-mic:active,.bt-voice:active{scale:.9} [data-composer-card]:has(button[class*="_primary"]:disabled) .bt-voice{display:inline-grid} @@ -1993,7 +2015,7 @@ diff --git a/test/looks.test.js b/test/looks.test.js import { indexRoster } from '../src/client/sources.js' const ids = Array.from({ length: 3000 }, (_, index) => `session-${index.toString(16).padStart(8, '0')}-look`) -@@ -29,19 +29,98 @@ test('the whale is only a stored look: the browser gives no Bot a look by its pl +@@ -29,19 +29,103 @@ test('the whale is only a stored look: the browser gives no Bot a look by its pl assert.equal(photo.shape, defaultShape('chief')) }) @@ -2034,6 +2056,11 @@ diff --git a/test/looks.test.js b/test/looks.test.js + assert.match(text('sidebar.js'), /const settings = wide \? h\('button'/) + assert.doesNotMatch(text('sidebar.js'), /wide && bot\.remote \? h\('button'/) + assert.match(text('header.js'), /setAttribute\('aria-label', composerLabel\)/) ++ // #419: the composer keeps "Message " after the shell re-renders it; a friend's Bot chat hides the stats footer. ++ assert.match(text('header.js'), /new MutationObserver\(/) ++ assert.match(text('header.js'), /attributeFilter: \['aria-label'\]/) ++ assert.match(text('header.js'), /document\.body\.dataset\.btFriendChat = ''/) ++ assert.match(text('styles.js'), /body\[data-bt-friend-chat\] \[data-composer-stats\]\{display:none!important\}/) + assert.match(text('settings.js'), /filter\(bot => !bot\.remote\)/) + // #276: only the user's own Bots are offered as a Main Bot, in both selects. + assert.match(text('settings.js'), /const own = roster\.bots\.filter\(bot => !bot\.remote\)/) diff --git a/patches/ds-bot/0005-social-seams.patch b/patches/ds-bot/0005-social-seams.patch index 8e5efc8d2..1190d9969 100644 --- a/patches/ds-bot/0005-social-seams.patch +++ b/patches/ds-bot/0005-social-seams.patch @@ -296,7 +296,7 @@ diff --git a/src/host/channel.js b/src/host/channel.js defaultModel: rt.cachedDefault() ?? null, permissions: rt.permissionCatalog?.() ?? null, modelsKey: modelsKey(), -@@ -65,22 +65,47 @@ export function install(rt) { +@@ -65,22 +65,49 @@ export function install(rt) { memory: rt.memoryEnabled !== false, // Set while another dsh host holds the team (store.js): why this one cannot change it. readOnly: rt.readOnly() ?? null, @@ -327,7 +327,9 @@ diff --git a/src/host/channel.js b/src/host/channel.js + connectors: rt.connectorsView(), + capabilityStatuses: rt.capabilityStatusesView(), + } -+ return rt.ownedLive(full, actor) ++ // Plugin tool names change with a plugin reload, not with the team revision, so they ride every ++ // answer, unchanged or not, and the client re-reads its hidden-row set (#419). ++ return { ...rt.ownedLive(full, actor), pluginTools: rt.pluginToolNames?.() ?? [] } + } // What a host that only reads the team may still answer. // Updates change the dsh profile, not the team, so a reading host may run them too. diff --git a/patches/ds-bot/0008-client-bundle.patch b/patches/ds-bot/0008-client-bundle.patch index fc1a1847c..dac324df2 100644 --- a/patches/ds-bot/0008-client-bundle.patch +++ b/patches/ds-bot/0008-client-bundle.patch @@ -896,13 +896,29 @@ 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%"; -@@ -1240,7 +1240,492 @@ function maskOf(shape) { - } - return mask; +@@ -1242,162 +1242,490 @@ function maskOf(shape) { } --var DEFAULT_MASK = maskOf(FALLBACK_SHAPE); -+var DEFAULT_MASK = maskOf(FALLBACK_SHAPE); -+ + var DEFAULT_MASK = maskOf(FALLBACK_SHAPE); + +-// src/client/sources.js +-function createSource(initial) { +- let value = initial; +- const listeners2 = /* @__PURE__ */ new Set(); +- return { +- getSnapshot: () => value, +- subscribe: (listener) => { +- listeners2.add(listener); +- return () => { +- listeners2.delete(listener); +- }; +- }, +- set(next) { +- const resolved = typeof next === "function" ? next(value) : next; +- if (resolved === value) return; +- value = resolved; +- for (const listener of [...listeners2]) listener(); +- } +- }; +// src/client/mark.js +var import_react = require("react"); +var STATES = ["idle", "thinking", "searching", "working", "sending", "orbit", "alert", "done"]; @@ -939,7 +955,21 @@ diff --git a/client.js b/client.js + document.documentElement.removeEventListener("pointerleave", onLeave); + }; + }, [enabled]); -+} + } +-var EMPTY_ROSTER = { revision: -1, mainBotId: null, mainBotIds: [], bots: [], rooms: [], agents: [], exchangeTurns: {}, questions: {}, secrets: [], capabilities: [], capabilityStatuses: {}, secretRequests: {}, models: [], defaultModel: null, modelsKey: null, prefs: {}, byId: {}, roomsById: {}, agentsById: {}, agentOf: {}, ready: false }; +-var isMainOf = (roster, id) => id !== void 0 && (roster.mainBotIds ?? []).includes(id); +-function indexRoster(value) { +- const byId = {}; +- for (const bot of value.bots) { +- for (const id of [...bot.parts ?? [], bot.sessionId ?? bot.id, bot.id]) byId[id] = bot; +- } +- const roomsById = {}; +- for (const room2 of value.rooms) roomsById[room2.id] = room2; +- const agentsById = {}; +- const agentOf = {}; +- for (const agent of value.agents ?? []) { +- agentsById[agent.id] = agent; +- for (const sessionId of agent.sessions ?? []) agentOf[sessionId] = agent.id; +var FIDGETS = ["hop", "sway", "stretch", "look"]; +function BotMark({ bot, look: given, size = 36, state = "idle", live = false, gaze = false, pokeable = false, label }) { + const look = given ?? (bot?.remote ? lookAvoidingLocals(bot) : lookOf(bot)); @@ -972,17 +1002,30 @@ diff --git a/client.js b/client.js + className: "bt-mark-emoji", + style: { fontSize: Math.max(12, Math.round(size * 0.62)), lineHeight: 1 } + }, look.emoji)); -+ } + } +- return { ...value, agents: value.agents ?? [], byId, roomsById, agentsById, agentOf, ready: true }; + const box = shapeOf(look.shape).size; + return (0, import_react.createElement)("span", common, (0, import_react.createElement)("svg", { viewBox: `0 0 ${box} ${box}`, dangerouslySetInnerHTML: { __html: characterMarkup(look.shape, look.color).replaceAll(MASK_ID, maskId.current) } })); -+} + } +-function isAgentSession(roster, cache, sessionId) { +- if (sessionId === void 0 || sessionId === null || sessionId === "") return false; +- if (roster.ready) return roster.agentOf[sessionId] !== void 0; +- return cache.has(sessionId); +function StarIcon({ size = 8 }) { + return (0, import_react.createElement)( + "svg", + { width: size, height: size, viewBox: "0 0 10 10", "aria-hidden": true }, + (0, import_react.createElement)("path", { d: "M5 .6l1.3 2.8 3 .3-2.3 2 .7 3L5 7.2 2.3 8.7l.7-3L.7 3.7l3-.3z", fill: "#fff" }) + ); -+} + } +-function countCards(state, sessionId, callIds, delta) { +- const own = { ...state[sessionId] }; +- for (const callId of callIds) { +- const count = (own[callId] ?? 0) + delta; +- if (count > 0) own[callId] = count; +- else delete own[callId]; +- } +- return { ...state, [sessionId]: own }; +function PersonMark({ size = 22 }) { + return (0, import_react.createElement)( + "span", @@ -994,17 +1037,109 @@ diff --git a/client.js b/client.js + (0, import_react.createElement)("path", { d: "M3.5 21c0-4.7 3.8-7.6 8.5-7.6s8.5 2.9 8.5 7.6z" }) + ) + ); -+} + } +- +-// src/client/themes.js +-var THEME_KEYS = ["bg", "sidebar", "bubble", "card", "text", "user", "userText", "accent"]; +-var THEMES = { +- deepseek: { +- label: "DeepSeek", +- light: { bg: "#fcfcfc", sidebar: "#f7f7f7", bubble: "#eeeeee", card: "#ffffff", text: "#141414", user: "#070707", userText: "#fcfcfc", accent: "#4d6bfe" }, +- dark: { bg: "#141414", sidebar: "#1b1b1b", bubble: "#262626", card: "#1f1f1f", text: "#f2f2f2", user: "#f2f2f2", userText: "#141414", accent: "#5b7bff" } +- }, +- mono: { +- label: "Mono", +- light: { bg: "#ffffff", sidebar: "#f9f9f9", bubble: "#f0f0f0", card: "#ffffff", text: "#0d0d0d", user: "#0d0d0d", userText: "#ffffff", accent: "#0d0d0d" }, +- dark: { bg: "#0f0f0f", sidebar: "#171717", bubble: "#242424", card: "#1c1c1c", text: "#f4f4f4", user: "#f4f4f4", userText: "#0f0f0f", accent: "#f4f4f4" } +- }, +- paper: { +- label: "Paper", +- light: { bg: "#faf9f5", sidebar: "#f3f0e8", bubble: "#ebe7dc", card: "#ffffff", text: "#1f1e1b", user: "#2b2a26", userText: "#faf9f5", accent: "#d97757" }, +- dark: { bg: "#1f1e1b", sidebar: "#262521", bubble: "#34322d", card: "#2a2925", text: "#f3f0e8", user: "#f3f0e8", userText: "#1f1e1b", accent: "#e08a6d" } +- }, +- moonlight: { +- label: "Moonlight", +- light: { bg: "#f7f9fc", sidebar: "#eef2f8", bubble: "#e5ebf5", card: "#ffffff", text: "#121826", user: "#1e3a8a", userText: "#ffffff", accent: "#1e86ff" }, +- dark: { bg: "#0b1020", sidebar: "#10172a", bubble: "#1a2340", card: "#141c33", text: "#e8edf7", user: "#3b6fd8", userText: "#ffffff", accent: "#4f9dff" } +- } +-}; +-var rgba = (hex, alpha) => `rgba(${[1, 3, 5].map((index) => parseInt(hex.slice(index, index + 2), 16)).join(",")},${alpha})`; +-function themeVars(mode, dark, accent = mode.accent) { +- return { +- "--bt-ink": mode.text, +- "--bt-ink-2": rgba(mode.text, 0.6), +- "--bt-ink-3": rgba(mode.text, 0.36), +- "--bt-bubble": mode.bubble, +- "--bt-sidebar": mode.sidebar, +- "--bt-main": mode.bg, +- "--bt-card": mode.card, +- "--bt-line": rgba(mode.text, 0.08), +- "--bt-line-2": rgba(mode.text, 0.15), +- "--bt-hover": dark ? "rgba(160,160,160,.12)" : "rgba(119,119,119,.09)", +- "--bt-active": dark ? "rgba(160,160,160,.2)" : "rgba(119,119,119,.17)", +- "--bt-accent": accent, +- "--bt-accent-ink": luminance(accent) > 0.45 ? "#141414" : "#ffffff", +- "--bt-user": mode.user, +- "--bt-user-ink": mode.userText, +- "--bt-tag-bg": rgba(mode.text, dark ? 0.09 : 0.06), +- "--bt-tag-ink": rgba(mode.text, 0.58) +- }; +function AdminStar() { + return (0, import_react.createElement)("span", { className: "bt-admin-star", role: "img", "aria-label": t("Admin") }, (0, import_react.createElement)(StarIcon, { size: 7 })); -+} + } +-var declarations = (vars) => Object.entries(vars).map(([name, value]) => `${name}:${value}`).join(";"); +-var themeCss = (theme, accent) => `html body{${declarations(themeVars(theme.light, false, accent ?? theme.light.accent))}} +-html body[data-ds-dark-theme]{${declarations(themeVars(theme.dark, true, accent ?? theme.dark.accent))}} +-${theme.css ?? ""}`; +-var shellTokens = ({ light, dark }) => ({ +- "--dsw-alias-bg-base": { light: light.bg, dark: dark.bg }, +- "--dsw-specific-sidebar-fill": { light: light.sidebar, dark: dark.sidebar }, +- "--dsw-specific-input-major": { light: light.bg, dark: dark.sidebar }, +- "--dsw-alias-label-primary": { light: light.text, dark: dark.text }, +- "--dsw-alias-label-secondary": { light: rgba(light.text, 0.74), dark: rgba(dark.text, 0.74) }, +- "--dsw-alias-label-tertiary": { light: rgba(light.text, 0.6), dark: rgba(dark.text, 0.6) } +-}); +-var customThemes = {}; +-var registry = createSource(0); +-var isPoint = (point) => Array.isArray(point) && point.length === 2 && point.every(Number.isFinite); +-function registerShape(id, shape) { +- if (typeof id !== "string" || !/^[a-z][\w-]{0,31}$/.test(id)) throw new Error("Shape ids are lowercase words"); +- if (typeof shape?.body !== "function" || typeof shape.body("#000") !== "string") throw new Error("A shape needs body(paint) returning SVG markup"); +- const at = shape.eyes?.at; +- if (!Array.isArray(at) || at.length !== 2 || !at.every(isPoint)) throw new Error("A shape needs eyes.at: two [x, y] points"); +- const size = Number.isFinite(shape.size) && shape.size > 0 ? shape.size : 100; +- const pick2 = (key, fallback) => Number.isFinite(shape.eyes[key]) ? shape.eyes[key] : fallback; +- const eyes = at.map(([x, y]) => [x, y, pick2("w", size * 0.1), pick2("h", size * 0.23), pick2("tilt", -27)]); +- const previous = SHAPES[id]; +- SHAPES[id] = { +- label: String(shape.label ?? id), +- size, +- body: shape.body, +- eyes, +- ...typeof shape.wrap === "string" ? { wrap: shape.wrap } : {} +- }; +- const refresh = () => { +- markupCache.clear(); +- maskCache.delete(id); +- registry.set((value) => value + 1); +- }; +- refresh(); +- return () => { +- if (previous) SHAPES[id] = previous; +- else delete SHAPES[id]; +- refresh(); +- }; +function MainStar() { + return (0, import_react.createElement)( + "svg", + { viewBox: "0 0 20 20", "aria-hidden": true }, + (0, import_react.createElement)("path", { d: "M10 2.4l2.29 5.04 5.51.63-4.09 3.74 1.11 5.42L10 14.5l-4.82 2.73 1.11-5.42L2.2 8.07l5.51-.63z" }) + ); -+} + } +-function registerTheme(id, theme) { +- if (typeof id !== "string" || !/^[a-z][\w-]{0,31}$/.test(id)) throw new Error("Theme ids are lowercase words"); +- for (const mode of ["light", "dark"]) { +- for (const key of THEME_KEYS) if (!isHex(theme?.[mode]?.[key])) throw new Error(`Theme ${mode}.${key} must be a #rrggbb color`); +function useSettled(state) { + const prev = (0, import_react.useRef)(state); + const [settled, setSettled] = (0, import_react.useState)(false); @@ -1043,7 +1178,14 @@ diff --git a/client.js b/client.js + const size = frame * 5 / 9; + const rest = frame - size; + return [[rest / 2, 0, size], [0, rest, size], [rest, rest, size]]; -+ } + } +- const previous = customThemes[id]; +- customThemes[id] = { label: String(theme.label ?? id), light: theme.light, dark: theme.dark, css: typeof theme.css === "string" ? theme.css : "" }; +- registry.set((value) => value + 1); +- return () => { +- if (previous) customThemes[id] = previous; +- else delete customThemes[id]; +- registry.set((value) => value + 1); + const half = frame / 2; + return [[0, 0, half], [half, 0, half], [0, half, half], [half, half, half]]; +} @@ -1281,7 +1423,7 @@ diff --git a/client.js b/client.js + const inOpenChat = (approval) => { + const chat = chatOf(approval, roster); + return Boolean(chat) && (claimed.chats?.has(chat) === true || claimed.chats?.has(roster?.byId?.[chat]?.sessionId) === true); -+ }; + }; + const away = open.filter((approval) => policies.has(approval.by) && approval.status !== "failed" && !inOpenChat(approval)); + const awayKey = away.map((approval) => approval.id).join(","); + (0, import_react2.useEffect)(() => { @@ -1361,7 +1503,8 @@ diff --git a/client.js b/client.js + }).concat(error ? [(0, import_react2.createElement)("div", { key: "error", className: "bt-flybot-approval-error", role: "alert" }, error)] : []) + ) : null + ); -+} + } +-var allThemes = () => ({ ...THEMES, ...customThemes }); +var fileSize = (n) => n < 1024 ? `${n} B` : n < 1048576 ? `${(n / 1024).toFixed(1)} KB` : `${(n / 1048576).toFixed(1)} MB`; +var FLYBOT_CSS = ` +.bt-flybot-session-dock{width:100%;max-width:min(992px,100%);margin:0 auto;padding:0 0 6px;box-sizing:border-box} @@ -1388,52 +1531,8 @@ diff --git a/client.js b/client.js +.bt-flybot-pointer button { border: 0; border-radius: 999px; padding: 3px 10px; font-size: 12px; cursor: pointer; background: var(--bt-accent, #2563eb); color: #fff; } +`; - // src/client/sources.js - function createSource(initial) { -@@ -1262,9 +1262,10 @@ function createSource(initial) { - } - }; - } --var EMPTY_ROSTER = { revision: -1, mainBotId: null, mainBotIds: [], bots: [], rooms: [], agents: [], exchangeTurns: {}, questions: {}, secrets: [], capabilities: [], capabilityStatuses: {}, secretRequests: {}, models: [], defaultModel: null, modelsKey: null, prefs: {}, byId: {}, roomsById: {}, agentsById: {}, agentOf: {}, ready: false }; -+var EMPTY_ROSTER = { revision: -1, mainBotId: null, mainBotIds: [], bots: [], rooms: [], humans: [], self: null, agents: [], exchangeTurns: {}, remoteTypedTurns: {}, questions: {}, secrets: [], capabilities: [], capabilityStatuses: {}, secretRequests: {}, models: [], defaultModel: null, modelsKey: null, prefs: {}, byId: {}, roomsById: {}, agentsById: {}, agentOf: {}, ready: false }; - var isMainOf = (roster, id) => id !== void 0 && (roster.mainBotIds ?? []).includes(id); - function indexRoster(value) { -+ setLocalBots(value.bots); - const byId = {}; - for (const bot of value.bots) { - for (const id of [...bot.parts ?? [], bot.sessionId ?? bot.id, bot.id]) byId[id] = bot; -@@ -1353,7 +1353,7 @@ var shellTokens = ({ light, dark }) => ({ - "--dsw-alias-label-tertiary": { light: rgba(light.text, 0.6), dark: rgba(dark.text, 0.6) } - }); - var customThemes = {}; --var registry = createSource(0); -+var registry3 = createSource(0); - var isPoint = (point) => Array.isArray(point) && point.length === 2 && point.every(Number.isFinite); - function registerShape(id, shape) { - if (typeof id !== "string" || !/^[a-z][\w-]{0,31}$/.test(id)) throw new Error("Shape ids are lowercase words"); -@@ -1374,7 +1374,7 @@ function registerShape(id, shape) { - const refresh = () => { - markupCache.clear(); - maskCache.delete(id); -- registry.set((value) => value + 1); -+ registry3.set((value) => value + 1); - }; - refresh(); - return () => { -@@ -1390,11 +1390,11 @@ function registerTheme(id, theme) { - } - const previous = customThemes[id]; - customThemes[id] = { label: String(theme.label ?? id), light: theme.light, dark: theme.dark, css: typeof theme.css === "string" ? theme.css : "" }; -- registry.set((value) => value + 1); -+ registry3.set((value) => value + 1); - return () => { - if (previous) customThemes[id] = previous; - else delete customThemes[id]; -- registry.set((value) => value + 1); -+ registry3.set((value) => value + 1); - }; - } - var allThemes = () => ({ ...THEMES, ...customThemes }); + // src/shared/mentions.js + var ALL_WORDS = ["all", "everyone", "所有人", "全体"]; @@ -1445,6 +1445,27 @@ function mentionSpans(text, bots, { references = [] } = {}) { } return spans; @@ -1510,7 +1609,7 @@ diff --git a/client.js b/client.js 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); -@@ -1520,6 +1520,11 @@ function summaryBlocks(text) { +@@ -1520,11 +1520,21 @@ function summaryBlocks(text) { flush(); return blocks; } @@ -1522,7 +1621,17 @@ diff --git a/client.js b/client.js function isHiddenTurn(roster, sessionId, turn) { if (sessionId === void 0 || turn === void 0) return false; return (roster.exchangeTurns[sessionId] ?? []).includes(turn); -@@ -1604,7 +1604,7 @@ function previewOf(list, id, hiddenTurns = []) { + } + var toolName = (root) => root.name || root.call?.name; ++var pluginTools = /* @__PURE__ */ new Set(); ++function setPluginTools(names) { ++ pluginTools = new Set((Array.isArray(names) ? names : []).filter((name) => typeof name === "string")); ++} ++var isPluginTool = (name) => typeof name === "string" && pluginTools.has(name); + var historyLoading = (snapshot) => snapshot?.openState === "cold" || snapshot?.openState === "loading"; + function toolArgs(root) { + if (typeof root.args?.text === "function") { +@@ -1604,29 +1604,188 @@ function previewOf(list, id, hiddenTurns = []) { const entry = outline[index]; if (hiddenTurns.includes(entry.turn)) continue; const text = entry.response || entry.prompt || ""; @@ -1531,8 +1640,195 @@ diff --git a/client.js b/client.js } return ""; } -@@ -1626,7 +1626,7 @@ function usageOf(list, ids) { +-var formatTokens = (count) => count >= 1e6 ? `${(count / 1e6).toFixed(1)}M` : count >= 1e3 ? `${(count / 1e3).toFixed(1)}K` : String(count); +-function usageOf(list, ids) { +- let input = 0; +- let cacheRead = 0; +- let output = 0; +- for (const id of ids) { +- const usage = list.projectionsBySession?.[id]?.values?.tokenUsage; +- if (!usage) continue; +- input += (usage.uncachedInputTokens ?? 0) + (usage.cacheReadTokens ?? 0) + (usage.cacheWriteTokens ?? 0); +- cacheRead += usage.cacheReadTokens ?? 0; +- output += usage.outputTokens ?? 0; ++var formatTokens = (count) => count >= 1e6 ? `${(count / 1e6).toFixed(1)}M` : count >= 1e3 ? `${(count / 1e3).toFixed(1)}K` : String(count); ++function usageOf(list, ids) { ++ let input = 0; ++ let cacheRead = 0; ++ let output = 0; ++ for (const id of ids) { ++ const usage = list.projectionsBySession?.[id]?.values?.tokenUsage; ++ if (!usage) continue; ++ input += (usage.uncachedInputTokens ?? 0) + (usage.cacheReadTokens ?? 0) + (usage.cacheWriteTokens ?? 0); ++ cacheRead += usage.cacheReadTokens ?? 0; ++ output += usage.outputTokens ?? 0; ++ } ++ if (input + output === 0) return ""; ++ const hit = input > 0 ? Math.round(cacheRead / input * 100) : 0; ++ return t("{tokens} tok · Cache hit {hit}%", { tokens: formatTokens(input + output), hit }); ++} ++ ++// src/client/sources.js ++function createSource(initial) { ++ let value = initial; ++ const listeners2 = /* @__PURE__ */ new Set(); ++ return { ++ getSnapshot: () => value, ++ subscribe: (listener) => { ++ listeners2.add(listener); ++ return () => { ++ listeners2.delete(listener); ++ }; ++ }, ++ set(next) { ++ const resolved = typeof next === "function" ? next(value) : next; ++ if (resolved === value) return; ++ value = resolved; ++ for (const listener of [...listeners2]) listener(); ++ } ++ }; ++} ++var EMPTY_ROSTER = { revision: -1, mainBotId: null, mainBotIds: [], bots: [], rooms: [], humans: [], self: null, agents: [], exchangeTurns: {}, remoteTypedTurns: {}, questions: {}, secrets: [], capabilities: [], capabilityStatuses: {}, secretRequests: {}, models: [], defaultModel: null, modelsKey: null, prefs: {}, byId: {}, roomsById: {}, agentsById: {}, agentOf: {}, ready: false }; ++var isMainOf = (roster, id) => id !== void 0 && (roster.mainBotIds ?? []).includes(id); ++function indexRoster(value) { ++ setLocalBots(value.bots); ++ setPluginTools(value.pluginTools); ++ const byId = {}; ++ for (const bot of value.bots) { ++ for (const id of [...bot.parts ?? [], bot.sessionId ?? bot.id, bot.id]) byId[id] = bot; ++ } ++ const roomsById = {}; ++ for (const room2 of value.rooms) roomsById[room2.id] = room2; ++ const agentsById = {}; ++ const agentOf = {}; ++ for (const agent of value.agents ?? []) { ++ agentsById[agent.id] = agent; ++ for (const sessionId of agent.sessions ?? []) agentOf[sessionId] = agent.id; ++ } ++ return { ...value, agents: value.agents ?? [], byId, roomsById, agentsById, agentOf, ready: true }; ++} ++function isAgentSession(roster, cache, sessionId) { ++ if (sessionId === void 0 || sessionId === null || sessionId === "") return false; ++ if (roster.ready) return roster.agentOf[sessionId] !== void 0; ++ return cache.has(sessionId); ++} ++function countCards(state, sessionId, callIds, delta) { ++ const own = { ...state[sessionId] }; ++ for (const callId of callIds) { ++ const count = (own[callId] ?? 0) + delta; ++ if (count > 0) own[callId] = count; ++ else delete own[callId]; ++ } ++ return { ...state, [sessionId]: own }; ++} ++ ++// src/client/themes.js ++var THEME_KEYS = ["bg", "sidebar", "bubble", "card", "text", "user", "userText", "accent"]; ++var THEMES = { ++ deepseek: { ++ label: "DeepSeek", ++ light: { bg: "#fcfcfc", sidebar: "#f7f7f7", bubble: "#eeeeee", card: "#ffffff", text: "#141414", user: "#070707", userText: "#fcfcfc", accent: "#4d6bfe" }, ++ dark: { bg: "#141414", sidebar: "#1b1b1b", bubble: "#262626", card: "#1f1f1f", text: "#f2f2f2", user: "#f2f2f2", userText: "#141414", accent: "#5b7bff" } ++ }, ++ mono: { ++ label: "Mono", ++ light: { bg: "#ffffff", sidebar: "#f9f9f9", bubble: "#f0f0f0", card: "#ffffff", text: "#0d0d0d", user: "#0d0d0d", userText: "#ffffff", accent: "#0d0d0d" }, ++ dark: { bg: "#0f0f0f", sidebar: "#171717", bubble: "#242424", card: "#1c1c1c", text: "#f4f4f4", user: "#f4f4f4", userText: "#0f0f0f", accent: "#f4f4f4" } ++ }, ++ paper: { ++ label: "Paper", ++ light: { bg: "#faf9f5", sidebar: "#f3f0e8", bubble: "#ebe7dc", card: "#ffffff", text: "#1f1e1b", user: "#2b2a26", userText: "#faf9f5", accent: "#d97757" }, ++ dark: { bg: "#1f1e1b", sidebar: "#262521", bubble: "#34322d", card: "#2a2925", text: "#f3f0e8", user: "#f3f0e8", userText: "#1f1e1b", accent: "#e08a6d" } ++ }, ++ moonlight: { ++ label: "Moonlight", ++ light: { bg: "#f7f9fc", sidebar: "#eef2f8", bubble: "#e5ebf5", card: "#ffffff", text: "#121826", user: "#1e3a8a", userText: "#ffffff", accent: "#1e86ff" }, ++ dark: { bg: "#0b1020", sidebar: "#10172a", bubble: "#1a2340", card: "#141c33", text: "#e8edf7", user: "#3b6fd8", userText: "#ffffff", accent: "#4f9dff" } ++ } ++}; ++var rgba = (hex, alpha) => `rgba(${[1, 3, 5].map((index) => parseInt(hex.slice(index, index + 2), 16)).join(",")},${alpha})`; ++function themeVars(mode, dark, accent = mode.accent) { ++ return { ++ "--bt-ink": mode.text, ++ "--bt-ink-2": rgba(mode.text, 0.6), ++ "--bt-ink-3": rgba(mode.text, 0.36), ++ "--bt-bubble": mode.bubble, ++ "--bt-sidebar": mode.sidebar, ++ "--bt-main": mode.bg, ++ "--bt-card": mode.card, ++ "--bt-line": rgba(mode.text, 0.08), ++ "--bt-line-2": rgba(mode.text, 0.15), ++ "--bt-hover": dark ? "rgba(160,160,160,.12)" : "rgba(119,119,119,.09)", ++ "--bt-active": dark ? "rgba(160,160,160,.2)" : "rgba(119,119,119,.17)", ++ "--bt-accent": accent, ++ "--bt-accent-ink": luminance(accent) > 0.45 ? "#141414" : "#ffffff", ++ "--bt-user": mode.user, ++ "--bt-user-ink": mode.userText, ++ "--bt-tag-bg": rgba(mode.text, dark ? 0.09 : 0.06), ++ "--bt-tag-ink": rgba(mode.text, 0.58) ++ }; ++} ++var declarations = (vars) => Object.entries(vars).map(([name, value]) => `${name}:${value}`).join(";"); ++var themeCss = (theme, accent) => `html body{${declarations(themeVars(theme.light, false, accent ?? theme.light.accent))}} ++html body[data-ds-dark-theme]{${declarations(themeVars(theme.dark, true, accent ?? theme.dark.accent))}} ++${theme.css ?? ""}`; ++var shellTokens = ({ light, dark }) => ({ ++ "--dsw-alias-bg-base": { light: light.bg, dark: dark.bg }, ++ "--dsw-specific-sidebar-fill": { light: light.sidebar, dark: dark.sidebar }, ++ "--dsw-specific-input-major": { light: light.bg, dark: dark.sidebar }, ++ "--dsw-alias-label-primary": { light: light.text, dark: dark.text }, ++ "--dsw-alias-label-secondary": { light: rgba(light.text, 0.74), dark: rgba(dark.text, 0.74) }, ++ "--dsw-alias-label-tertiary": { light: rgba(light.text, 0.6), dark: rgba(dark.text, 0.6) } ++}); ++var customThemes = {}; ++var registry3 = createSource(0); ++var isPoint = (point) => Array.isArray(point) && point.length === 2 && point.every(Number.isFinite); ++function registerShape(id, shape) { ++ if (typeof id !== "string" || !/^[a-z][\w-]{0,31}$/.test(id)) throw new Error("Shape ids are lowercase words"); ++ if (typeof shape?.body !== "function" || typeof shape.body("#000") !== "string") throw new Error("A shape needs body(paint) returning SVG markup"); ++ const at = shape.eyes?.at; ++ if (!Array.isArray(at) || at.length !== 2 || !at.every(isPoint)) throw new Error("A shape needs eyes.at: two [x, y] points"); ++ const size = Number.isFinite(shape.size) && shape.size > 0 ? shape.size : 100; ++ const pick2 = (key, fallback) => Number.isFinite(shape.eyes[key]) ? shape.eyes[key] : fallback; ++ const eyes = at.map(([x, y]) => [x, y, pick2("w", size * 0.1), pick2("h", size * 0.23), pick2("tilt", -27)]); ++ const previous = SHAPES[id]; ++ SHAPES[id] = { ++ label: String(shape.label ?? id), ++ size, ++ body: shape.body, ++ eyes, ++ ...typeof shape.wrap === "string" ? { wrap: shape.wrap } : {} ++ }; ++ const refresh = () => { ++ markupCache.clear(); ++ maskCache.delete(id); ++ registry3.set((value) => value + 1); ++ }; ++ refresh(); ++ return () => { ++ if (previous) SHAPES[id] = previous; ++ else delete SHAPES[id]; ++ refresh(); ++ }; ++} ++function registerTheme(id, theme) { ++ if (typeof id !== "string" || !/^[a-z][\w-]{0,31}$/.test(id)) throw new Error("Theme ids are lowercase words"); ++ for (const mode of ["light", "dark"]) { ++ for (const key of THEME_KEYS) if (!isHex(theme?.[mode]?.[key])) throw new Error(`Theme ${mode}.${key} must be a #rrggbb color`); + } +- if (input + output === 0) return ""; +- const hit = input > 0 ? Math.round(cacheRead / input * 100) : 0; +- return t("{tokens} tok · Cache hit {hit}%", { tokens: formatTokens(input + output), hit }); ++ const previous = customThemes[id]; ++ customThemes[id] = { label: String(theme.label ?? id), light: theme.light, dark: theme.dark, css: typeof theme.css === "string" ? theme.css : "" }; ++ registry3.set((value) => value + 1); ++ return () => { ++ if (previous) customThemes[id] = previous; ++ else delete customThemes[id]; ++ registry3.set((value) => value + 1); ++ }; } ++var allThemes = () => ({ ...THEMES, ...customThemes }); // src/client/motion.js -var import_react = require("react"); @@ -1796,6 +2092,15 @@ diff --git a/client.js b/client.js [data-content-phase=hero] [class*="_composerHero"]{align-self:stretch!important;width:auto!important;padding-bottom:0!important} [data-content-phase=hero] [class*="_composerHero"]>[class$="_root"],[data-content-phase=hero] [class*="_heroWorkspaceRow"]{display:none!important} [data-content-phase=hero] [class*="_composerHero"] [class*="_input"]{min-height:0!important} +@@ -2691,6 +2691,8 @@ header[class*="_header"] .bt-head{flex:1 1 auto;width:100%} + [data-composer-card] [data-composer-input]{min-height:24px;padding:2px 0!important} + [data-composer-card] [data-composer-placeholder]{left:0!important;top:2px!important} + [data-composer-placeholder]{font-size:0!important;line-height:0!important} ++/* A friend's Bot chat: no local run stats (they are relay turns, always 0; #419). */ ++body[data-bt-friend-chat] [data-composer-stats]{display:none!important} + [data-composer-placeholder]::after{content:var(--bt-placeholder,"Message");display:block;font-size:14px;line-height:24px;color:color-mix(in srgb,var(--bt-ink) 36%,transparent)} + /* The team's "+" (ComposerPlus) takes the seat of the shell's command-menu button. */ + [data-composer-card] button[class*="_add"]{display:none!important} @@ -2722,6 +2722,7 @@ header[class*="_header"] .bt-head{flex:1 1 auto;width:100%} .bt-mic:active,.bt-voice:active{scale:.9} [data-composer-card]:has(button[class*="_primary"]:disabled) .bt-voice{display:inline-grid} @@ -3467,7 +3772,7 @@ diff --git a/client.js b/client.js actions.noteTurnTime(sessionId, turn, "first", start); }, [sessionId, turn, start]); const visit = useUi((state) => state.visits?.[sessionId]); -@@ -4199,11 +4199,31 @@ function TurnProcessCell({ node, sessionId, turnProcess, useRoster, useUi, useTu +@@ -4199,11 +4199,32 @@ function TurnProcessCell({ node, sessionId, turnProcess, useRoster, useUi, useTu const own = state[sessionId]?.[turn]; return !hidden && typeof visit?.seen === "number" && own?.first !== void 0 && own.first <= visit.seen && own.end !== void 0 && own.end > visit.seen && own.end <= visit.entered; }); @@ -3479,13 +3784,14 @@ diff --git a/client.js b/client.js } +var HIDDEN_REMOVED_TOOLS = /* @__PURE__ */ new Set(["ask_user_question"]); +var isConnectorTool = (name) => typeof name === "string" && /^mcp__(github|composio)__/.test(name); -+var isHiddenNotice = (block) => block?.type === "tool-removal" && (HIDDEN_REMOVED_TOOLS.has(block.toolName) || isConnectorTool(block.toolName)) || block?.type === "tool-addition" && isConnectorTool(block.toolName); ++var isHiddenNotice = (block) => block?.type === "tool-removal" && (HIDDEN_REMOVED_TOOLS.has(block.toolName) || isConnectorTool(block.toolName) || isPluginTool(block.toolName)) || block?.type === "tool-addition" && (isConnectorTool(block.toolName) || isPluginTool(block.toolName)); +function isHiddenToolRemoval(node) { + const content = node?.data?.content; + if (!Array.isArray(content) || content.length === 0) return false; + return content.every(isHiddenNotice); +} -+function ContextCell({ node }) { ++function ContextCell({ node, useRoster }) { ++ useRoster?.((roster) => (roster.pluginTools ?? []).join("\n")); + if (isHiddenToolRemoval(node)) return null; + const content = Array.isArray(node?.data?.content) ? node.data.content : []; + const parts = []; @@ -4765,7 +5071,7 @@ diff --git a/client.js b/client.js if (opening) document.body.style.setProperty("--bt-first-sep", JSON.stringify(opening)); else document.body.style.removeProperty("--bt-first-sep"); return () => document.body.style.removeProperty("--bt-first-sep"); -@@ -5548,19 +5548,50 @@ function HeaderPill({ sessionId, useRoster, useSessions, useSessionStatus, useSe +@@ -5548,19 +5548,67 @@ function HeaderPill({ sessionId, useRoster, useSessions, useSessionStatus, useSe const bot = roster.byId[sessionId]; const room2 = roster.roomsById[sessionId]; const detailsOpen = useUi((state) => state.details === sessionId); @@ -4796,17 +5102,34 @@ diff --git a/client.js b/client.js + }; + }, [bot?.remote, blank3, remoteEmptyHint]); + (0, import_react10.useEffect)(() => { ++ if (!bot?.remote) return void 0; ++ document.body.dataset.btFriendChat = ""; ++ return () => { ++ delete document.body.dataset.btFriendChat; ++ }; ++ }, [bot?.remote]); ++ (0, import_react10.useEffect)(() => { + document.body.style.setProperty("--bt-placeholder", JSON.stringify(composerLabel)); + const input = document.querySelector("[data-composer-input]"); + const priorLabel = input?.getAttribute("aria-label") ?? null; + const apply2 = () => { + const el = document.querySelector("[data-composer-input]"); -+ if (el) el.setAttribute("aria-label", composerLabel); ++ if (el && el.getAttribute("aria-label") !== composerLabel) el.setAttribute("aria-label", composerLabel); + }; + apply2(); + const timer = setTimeout(apply2, 0); ++ let frame = 0; ++ const watcher = typeof MutationObserver === "function" ? new MutationObserver(() => { ++ if (!frame) frame = requestAnimationFrame(() => { ++ frame = 0; ++ apply2(); ++ }); ++ }) : null; ++ watcher?.observe(document.body, { subtree: true, childList: true, attributes: true, attributeFilter: ["aria-label"] }); + return () => { + clearTimeout(timer); ++ watcher?.disconnect(); ++ if (frame) cancelAnimationFrame(frame); + const el = document.querySelector("[data-composer-input]"); + if (!el) return; + if (priorLabel == null) el.removeAttribute("aria-label"); @@ -4918,12 +5241,14 @@ diff --git a/client.js b/client.js ); const toLatest = () => { const scroller = document.querySelector("[data-conversation-scroll]"); -@@ -5704,8 +5704,8 @@ function NewMessagePills({ sessionId, useSessions, useUi, useTurnClock }) { +@@ -5703,9 +5703,9 @@ function NewMessagePills({ sessionId, useSessions, useUi, useTurnClock }) { + else scroller?.scrollTo({ top: scroller.scrollHeight, behavior: "smooth" }); }; const toDivider = () => document.querySelector("[data-conversation-scroll] .bt-new-sep")?.scrollIntoView({ block: "center", behavior: "smooth" }); - return (0, import_react_dom4.createPortal)( +- return (0, import_react_dom4.createPortal)( - (0, import_react9.createElement)( - import_react9.Fragment, ++ return (0, import_react_dom4.createPortal)( + (0, import_react10.createElement)( + import_react10.Fragment, null, @@ -8153,7 +8478,8 @@ diff --git a/client.js b/client.js ), - (0, import_react23.createElement)( + face ? (0, import_react25.createElement)( -+ Card, + Card, +- { title: t("Members · {count}", { count: members.length }) }, + { title: t("Members · {count}", { count: seats.length }) }, + seats.map((seat) => (0, import_react25.createElement)( + "div", @@ -8167,8 +8493,7 @@ diff --git a/client.js b/client.js + )) + ) : null, + face ? null : (0, import_react25.createElement)( - Card, -- { title: t("Members · {count}", { count: members.length }) }, ++ Card, + { title: t("Members · {count}", { count: members.length + humans.length + remotes.length }) }, + humans.map((person) => (0, import_react25.createElement)( + "div", @@ -11467,6 +11792,17 @@ diff --git a/client.js b/client.js const surface = createSource({ mode: readMode(), plain: false }); let agentSessionCache = /* @__PURE__ */ new Set(); try { +@@ -13368,6 +13368,10 @@ function apply(ctx) { + const live2 = {}; + if (value.connectors !== void 0 && JSON.stringify(value.connectors) !== JSON.stringify(current.connectors ?? [])) live2.connectors = value.connectors; + if (value.capabilityStatuses !== void 0 && JSON.stringify(value.capabilityStatuses) !== JSON.stringify(current.capabilityStatuses ?? {})) live2.capabilityStatuses = value.capabilityStatuses; ++ if (value.pluginTools !== void 0 && JSON.stringify(value.pluginTools) !== JSON.stringify(current.pluginTools ?? [])) { ++ setPluginTools(value.pluginTools); ++ live2.pluginTools = value.pluginTools; ++ } + if (Object.keys(live2).length > 0 && current.ready) roster.set({ ...current, ...live2 }); + if (value.unchanged || value.revision === current.revision && value.modelsKey === current.modelsKey) return; + if (Object.keys(seenAt).length === 0) leaveVisits([...value.bots, ...value.rooms].map((entry) => entry.id)); @@ -13501,7 +13501,7 @@ function apply(ctx) { }; render(); @@ -11627,7 +11963,7 @@ diff --git a/client.js b/client.js + shadow("conversation.chat.node", { key: "turn-error", priority: -10, locale: "chat", inject: face }, TurnErrorCell), + // DSH's context row is "Tool removed: ask_user_question" after Bot sessions + // restrict that tool; hide it, keep other tool-change notices (#140). -+ shadow("conversation.chat.node", { key: "context", priority: -10, locale: "chat" }, ContextCell), ++ shadow("conversation.chat.node", { key: "context", priority: -10, locale: "chat", inject: face }, ContextCell), ...canDrawUserMessages() ? ["user", "steering"].map((key) => shadow("conversation.chat.node", { key, priority: -10, locale: "chat", inject: face }, UserMessageCell)) : [], shadow("conversation.input.permission", { priority: -10 }, Nothing), shadow("conversation.input.plan", { priority: -10 }, Nothing), diff --git a/patches/ds-bot/0009-identity-schema.patch b/patches/ds-bot/0009-identity-schema.patch index 229bf66c7..de6ab57dd 100644 --- a/patches/ds-bot/0009-identity-schema.patch +++ b/patches/ds-bot/0009-identity-schema.patch @@ -36,6 +36,23 @@ diff --git a/test/roster.test.js b/test/roster.test.js test('a new name must be one "@" can call; a name saved before the rule still saves', async (t) => { const { call, stop } = start(undefined, { models: ['deepseek-v4-flash'] }) t.after(stop) +@@ -247,6 +247,16 @@ test('the state reports a changed model list without a new revision', async (t) + assert.equal((await call('state', { since: first.revision, models: 'stale' })).value.unchanged, undefined) + }) + ++test('#419: plugin tool names ride every state answer, an unchanged one too (a plugin reload makes no revision)', async (t) => { ++ const { call, stop } = start() ++ t.after(stop) ++ const first = (await call('state', {})).value ++ assert.ok(Array.isArray(first.pluginTools)) ++ const again = (await call('state', { since: first.revision, models: first.modelsKey })).value ++ assert.equal(again.unchanged, true) ++ assert.deepEqual(again.pluginTools, first.pluginTools) ++}) ++ + test('a new team starts with a DeepSeek-blue whale as its Main Bot', async (t) => { + const { call, stop } = start() + t.after(stop) @@ -526,3 +526,34 @@ test('a write that fails at save time archives the Session it made', async (t) = const saved = JSON.parse(readFileSync(join(home, 'state.json'), 'utf8')) assert.equal(Object.values(saved.bots).some(bot => bot.name === 'Phantom'), false) diff --git a/patches/ds-bot/0012-room-membership.patch b/patches/ds-bot/0012-room-membership.patch index d65762b6d..46cc207a3 100644 --- a/patches/ds-bot/0012-room-membership.patch +++ b/patches/ds-bot/0012-room-membership.patch @@ -423,7 +423,7 @@ diff --git a/src/client/header.js b/src/client/header.js // ------------------------------------------------------------------------- // Conversation header -@@ -68,16 +68,49 @@ export function HeaderPill({ sessionId, useRoster, useSessions, useSessionStatus +@@ -68,16 +68,65 @@ export function HeaderPill({ sessionId, useRoster, useSessions, useSessionStatus const bot = roster.byId[sessionId] const room = roster.roomsById[sessionId] const detailsOpen = useUi(state => state.details === sessionId) @@ -455,18 +455,34 @@ diff --git a/src/client/header.js b/src/client/header.js + document.body.style.removeProperty('--bt-remote-empty') + } + }, [bot?.remote, blank, remoteEmptyHint]) ++ // A friend's Bot runs on its owner's home: the local run stats (turns, steps, tok/s, cache) ++ // are relay turns and always 0 here, so CSS hides the shell's stats footer in that chat (#419). ++ useEffect(() => { ++ if (!bot?.remote) return undefined ++ document.body.dataset.btFriendChat = '' ++ return () => { delete document.body.dataset.btFriendChat } ++ }, [bot?.remote]) + useEffect(() => { + document.body.style.setProperty('--bt-placeholder', JSON.stringify(composerLabel)) + const input = document.querySelector('[data-composer-input]') + const priorLabel = input?.getAttribute('aria-label') ?? null + const apply = () => { + const el = document.querySelector('[data-composer-input]') -+ if (el) el.setAttribute('aria-label', composerLabel) ++ if (el && el.getAttribute('aria-label') !== composerLabel) el.setAttribute('aria-label', composerLabel) + } + apply() + const timer = setTimeout(apply, 0) ++ // The shell re-renders its composer after a send and puts its own coding label back ++ // ("Message or run a task, / commands, …"); keep "Message " on it (#419). ++ let frame = 0 ++ const watcher = typeof MutationObserver === 'function' ++ ? new MutationObserver(() => { if (!frame) frame = requestAnimationFrame(() => { frame = 0; apply() }) }) ++ : null ++ watcher?.observe(document.body, { subtree: true, childList: true, attributes: true, attributeFilter: ['aria-label'] }) + return () => { + clearTimeout(timer) ++ watcher?.disconnect() ++ if (frame) cancelAnimationFrame(frame) + const el = document.querySelector('[data-composer-input]') + if (!el) return + if (priorLabel == null) el.removeAttribute('aria-label') @@ -488,8 +504,9 @@ diff --git a/src/client/header.js b/src/client/header.js diff --git a/src/client/sources.js b/src/client/sources.js --- a/src/client/sources.js +++ b/src/client/sources.js -@@ -1,3 +1,5 @@ +@@ -1,3 +1,6 @@ +import { setLocalBots } from './characters.js' ++import { setPluginTools } from './text.js' + // ------------------------------------------------------------------------- // Small observable sources for the inject `hooks` compartment. @@ -503,11 +520,12 @@ diff --git a/src/client/sources.js b/src/client/sources.js // Any Main Bot holds the Main Bot powers; `mainBotId`, the first one, is the sidebar tile. export const isMainOf = (roster, id) => id !== undefined && (roster.mainBotIds ?? []).includes(id) -@@ -24,6 +24,7 @@ export const isMainOf = (roster, id) => id !== undefined && (roster.mainBotIds ? +@@ -24,6 +24,8 @@ export const isMainOf = (roster, id) => id !== undefined && (roster.mainBotIds ? // and `sessionId` its current one. Every part id finds the Bot; Session state // (running, unread, turns) is read from the current part, team state from the Bot id. export function indexRoster(value) { + setLocalBots(value.bots) ++ setPluginTools(value.pluginTools) const byId = {} for (const bot of value.bots) { for (const id of [...(bot.parts ?? []), bot.sessionId ?? bot.id, bot.id]) byId[id] = bot diff --git a/patches/ds-bot/files/src/host/hooks.js b/patches/ds-bot/files/src/host/hooks.js index 6aebf149f..ee759877c 100644 --- a/patches/ds-bot/files/src/host/hooks.js +++ b/patches/ds-bot/files/src/host/hooks.js @@ -649,6 +649,9 @@ export function install(rt) { // Bot that joins (or leaves) a plugin's scope gets (or loses) the tool next turn. const gatedTools = new Map() const masks = new WeakMap() + // The names of plugin tools a mask may take away (tree's create_child_bot, …): the client hides + // their "Tool added" / "Tool removed" context rows, which say nothing a person needs (#419). + rt.pluginToolNames = () => [...gatedTools.keys()].sort() function hidePluginTools(agent) { if (agent === undefined || typeof agent.ctx?.tools?.restrict !== 'function') return const bot = rt.botOf(rt.selfOf(agent.session?.id ?? agent.id)) diff --git a/patches/ds-bot/files/test/hidden-tool-removal.test.js b/patches/ds-bot/files/test/hidden-tool-removal.test.js index 9db5528fb..c4ebe58c9 100644 --- a/patches/ds-bot/files/test/hidden-tool-removal.test.js +++ b/patches/ds-bot/files/test/hidden-tool-removal.test.js @@ -9,7 +9,7 @@ import { build } from 'esbuild' // "Tool removed: ask_user_question" hide can be checked without a browser. const { outputFiles } = await build({ stdin: { - contents: "export { ContextCell, isHiddenToolRemoval, HIDDEN_REMOVED_TOOLS, ToolCell } from './src/client/cells.js'", + contents: "export { ContextCell, isHiddenToolRemoval, HIDDEN_REMOVED_TOOLS, ToolCell } from './src/client/cells.js'; export { indexRoster } from './src/client/sources.js'", resolveDir: fileURLToPath(new URL('..', import.meta.url)), }, bundle: true, format: 'cjs', platform: 'browser', write: false, logLevel: 'silent', @@ -35,7 +35,7 @@ runInNewContext(outputFiles[0].text, { throw new Error(`Unexpected platform module ${name}`) }, }) -const { ContextCell, isHiddenToolRemoval, HIDDEN_REMOVED_TOOLS, ToolCell } = module.exports +const { ContextCell, isHiddenToolRemoval, HIDDEN_REMOVED_TOOLS, ToolCell, indexRoster } = module.exports const expand = (node) => { if (node === null || node === undefined || typeof node === 'boolean') return null @@ -110,3 +110,25 @@ test('#335: connector tools coming and going with a Bot\'s connector switch leav const mixed = { data: { content: [{ type: 'tool-addition', toolName: 'mcp__composio__COMPOSIO_SEARCH_TOOLS' }, { type: 'tool-addition', toolName: 'web_search' }] } } assert.equal(text(expand(ContextCell({ node: mixed }))), 'Tool added: web_search') }) + +test('#419: plugin tools a per-Bot mask takes away (tree) leave no Tool removed / Tool added line in any chat', () => { + const block = (type, toolName) => ({ data: { content: [{ type, toolName }] } }) + indexRoster({ bots: [], rooms: [], pluginTools: [] }) + assert.match(text(expand(ContextCell({ node: block('tool-removal', 'create_child_bot') }))), /Tool removed: create_child_bot/) + indexRoster({ bots: [], rooms: [], pluginTools: ['create_child_bot', 'list_children'] }) + const both = { data: { content: [{ type: 'tool-removal', toolName: 'create_child_bot' }, { type: 'tool-removal', toolName: 'list_children' }] } } + assert.equal(isHiddenToolRemoval(both), true) + assert.equal(ContextCell({ node: both }), null) + assert.equal(ContextCell({ node: block('tool-addition', 'list_children') }), null) + // Other tools still show. + assert.match(text(expand(ContextCell({ node: block('tool-removal', 'bash') }))), /Tool removed: bash/) + // The row follows the roster's plugin tool names, so names that arrive after it drew still hide it. + const selected = [] + ContextCell({ node: block('tool-removal', 'bash'), useRoster: select => { selected.push(select({ pluginTools: ['a', 'b'] })); return selected.at(-1) } }) + assert.deepEqual(selected, ['a\nb']) + const index = readFileSync(new URL('../src/client/index.js', import.meta.url), 'utf8') + assert.match(index, /key: 'context', priority: -10, locale: 'chat', inject: face \}, ContextCell/) + assert.match(index, /setPluginTools\(value\.pluginTools\); live\.pluginTools = value\.pluginTools/) + indexRoster({ bots: [], rooms: [] }) + assert.match(text(expand(ContextCell({ node: block('tool-removal', 'create_child_bot') }))), /Tool removed: create_child_bot/) +})