diff --git a/apps/desktop/src/components/assistant-ui/tool/fallback.test.ts b/apps/desktop/src/components/assistant-ui/tool/fallback.test.ts index 16855afc73bb..0d844d302212 100644 --- a/apps/desktop/src/components/assistant-ui/tool/fallback.test.ts +++ b/apps/desktop/src/components/assistant-ui/tool/fallback.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest' -import { shouldBoundToolGroup, technicalTrace, UNBOUNDABLE_TOOLS } from './fallback' +import { isUnboundableTool, shouldBoundToolGroup, technicalTrace } from './fallback' +import { isFileEditTool } from './fallback-model' describe('shouldBoundToolGroup', () => { it('bounds long runs of ordinary tool calls', () => { @@ -16,10 +17,34 @@ describe('shouldBoundToolGroup', () => { }) }) -describe('UNBOUNDABLE_TOOLS', () => { +describe('isUnboundableTool', () => { it('exempts clarify forms and generated images from the window', () => { - expect(UNBOUNDABLE_TOOLS.has('clarify')).toBe(true) - expect(UNBOUNDABLE_TOOLS.has('image_generate')).toBe(true) + expect(isUnboundableTool('clarify')).toBe(true) + expect(isUnboundableTool('image_generate')).toBe(true) + }) + + it('exempts tools whose body is a code block the user reads', () => { + expect(isUnboundableTool('execute_code')).toBe(true) + expect(isUnboundableTool('read_file')).toBe(true) + }) + + // The window clips a diff to a ~2-row viewport, so every tool that renders + // one has to be exempt. Derived from isFileEditTool rather than re-listed, + // so a newly supported edit tool can't be exempt in one place and clipped + // in the other. + it('exempts every file-edit tool, so diffs are never clipped', () => { + for (const toolName of ['edit_file', 'patch', 'write_file']) { + expect(isFileEditTool(toolName)).toBe(true) + expect(isUnboundableTool(toolName)).toBe(true) + } + }) + + // Console output is a log tail: the last lines are the ones that matter, + // which is exactly what the bounded window pins. Keeping it boundable is + // what stops the exemption from swallowing the feature whole. + it('still bounds console output and other ordinary rows', () => { + expect(isUnboundableTool('terminal')).toBe(false) + expect(isUnboundableTool('web_search')).toBe(false) }) }) diff --git a/apps/desktop/src/components/assistant-ui/tool/fallback.tsx b/apps/desktop/src/components/assistant-ui/tool/fallback.tsx index 4a8e2574955c..ab58dd056b7b 100644 --- a/apps/desktop/src/components/assistant-ui/tool/fallback.tsx +++ b/apps/desktop/src/components/assistant-ui/tool/fallback.tsx @@ -673,10 +673,30 @@ function TerminalTranscript({ command, exitCode }: TerminalTranscriptProps) { // auto-scrolling window; fewer than this stays a plain inline stack. const TOOL_GROUP_SCROLL_THRESHOLD = 3 -// Tools whose body (an interactive form, a full-size image) must never be -// trapped behind the window's max-height + fade mask. A run holding any of -// them stays a plain, fully-visible stack no matter how long it is. -export const UNBOUNDABLE_TOOLS = new Set(['clarify', 'image_generate']) +// Tools whose body (an interactive form, a full-size image, a syntax- +// highlighted code/diff block) must never be trapped behind the window's +// max-height + fade mask. A run holding any of them stays a plain, fully- +// visible stack no matter how long it is. +// +// A row rendered by ToolEntry carries `data-tool-row`, so once the user +// expands it the `:has([data-tool-row][data-tool-open])` rule in styles.css +// lifts the cap on its own. That escape hatch is why most tools are safe to +// bound. These are the ones it cannot reach: +// +// - `clarify` / `image_generate` render their own components and never emit +// `data-tool-row`, so no amount of expanding frees them. +// - the code tools *do* emit it, but their body is a code block the user +// reads rather than a one-line status — peering at a diff through a +// ~2-row viewport until you think to expand it is the bug. Console output +// (`terminal`) stays boundable: it's a log tail, and the last lines are +// the ones that matter, which is exactly what the window pins. +const CODE_BODY_TOOLS = ['execute_code', 'read_file'] + +const UNBOUNDABLE_TOOLS = new Set(['clarify', 'image_generate', ...CODE_BODY_TOOLS]) + +export function isUnboundableTool(toolName: string): boolean { + return UNBOUNDABLE_TOOLS.has(toolName) || isFileEditTool(toolName) +} export function shouldBoundToolGroup(childCount: number, hasUnboundable: boolean) { return childCount >= TOOL_GROUP_SCROLL_THRESHOLD && !hasUnboundable @@ -761,7 +781,7 @@ export const ToolGroupSlot: FC s.message.parts .slice(Math.max(0, startIndex), endIndex + 1) - .some(part => part.type === 'tool-call' && UNBOUNDABLE_TOOLS.has(part.toolName)) + .some(part => part.type === 'tool-call' && isUnboundableTool(part.toolName)) ) const enterRef = useEnterAnimation(messageRunning, `tool-group:${messageId}:${startIndex}`)