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
33 changes: 29 additions & 4 deletions apps/desktop/src/components/assistant-ui/tool/fallback.test.ts
Original file line number Diff line number Diff line change
@@ -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', () => {
Expand All @@ -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)
})
})

Expand Down
30 changes: 25 additions & 5 deletions apps/desktop/src/components/assistant-ui/tool/fallback.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -761,7 +781,7 @@ export const ToolGroupSlot: FC<PropsWithChildren<{ endIndex: number; startIndex:
const hasUnboundable = useAuiState(s =>
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}`)
Expand Down
Loading