Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changelog.d/419-friend-chat-clutter.fixed.md
Original file line number Diff line number Diff line change
@@ -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 <Bot>` after a send instead of switching to the coding label.
354 changes: 192 additions & 162 deletions core/client.js

Large diffs are not rendered by default.

12 changes: 8 additions & 4 deletions core/src/client/cells.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -561,16 +561,20 @@ 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)))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Re-render context rows when plugin tools arrive

When pluginTools arrives after a context row has rendered, notably while enabling or reloading a gated plugin, setPluginTools only mutates a module-level Set; this ContextCell has no roster/source subscription, so the roster poll does not cause it to rerender and the already-visible mask row stays on screen until another conversation update happens. Make the tool-name set observable or inject/use the roster in this cell.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 6e84047: ContextCell is now injected with face and selects roster.pluginTools via useRoster, so a row already drawn re-renders when the names change (the poll sets the module set before roster.set). Covered in hidden-tool-removal.test.js.

|| (block?.type === 'tool-addition' && (isConnectorTool(block.toolName) || isPluginTool(block.toolName)))

export function isHiddenToolRemoval(node) {
const content = node?.data?.content
if (!Array.isArray(content) || content.length === 0) return false
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 = []
Expand Down
18 changes: 17 additions & 1 deletion core/src/client/header.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 <name>" 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')
Expand Down
6 changes: 4 additions & 2 deletions core/src/client/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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),
Expand Down
2 changes: 2 additions & 0 deletions core/src/client/sources.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { setLocalBots } from './characters.js'
import { setPluginTools } from './text.js'

// -------------------------------------------------------------------------
// Small observable sources for the inject `hooks` compartment.
Expand Down Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions core/src/client/styles.js
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Expand Down
7 changes: 7 additions & 0 deletions core/src/client/text.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
4 changes: 3 additions & 1 deletion core/src/host/channel.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
3 changes: 3 additions & 0 deletions core/src/host/hooks.js
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
26 changes: 24 additions & 2 deletions core/test/hidden-tool-removal.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -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
Expand Down Expand Up @@ -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/)
})
5 changes: 5 additions & 0 deletions core/test/looks.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 <name>" 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\)/)
Expand Down
10 changes: 10 additions & 0 deletions core/test/roster.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
2 changes: 1 addition & 1 deletion docs/subsystems/core-client.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Loading
Loading