diff --git a/apps/desktop/src/components/assistant-ui/tool/fallback.tsx b/apps/desktop/src/components/assistant-ui/tool/fallback.tsx index a28a4dbf4d7..9447399d1a2 100644 --- a/apps/desktop/src/components/assistant-ui/tool/fallback.tsx +++ b/apps/desktop/src/components/assistant-ui/tool/fallback.tsx @@ -44,7 +44,7 @@ import { recordPreviewArtifact } from '@/store/preview-status' import { sessionApprovalRequest } from '@/store/prompts' import { $toolInlineDiff } from '@/store/tool-diffs' import { $toolRowDismissed, dismissToolRow } from '@/store/tool-dismiss' -import { $toolDisclosureOpen, $toolViewMode, setToolDisclosureOpen } from '@/store/tool-view' +import { $anyToolDisclosureOpen, $toolDisclosureOpen, $toolViewMode, setToolDisclosureOpen } from '@/store/tool-view' import { APPROVAL_TOOLS, PendingToolApproval } from './approval' import { @@ -316,6 +316,17 @@ function useDisclosureOpen(disclosureId: string, fallbackOpen = false): boolean return persistedOpen ?? fallbackOpen } +/** + * A row's disclosure id, scoped to the message it was rendered in. + * + * Shared with the run that wraps the row: a live run has to know when one of + * its own rows has been opened, and both sides have to name it identically or + * the run never hears about it. + */ +function toolEntryDisclosureId(messageId: string, part: ToolPart): string { + return `tool-entry:${messageId}:${toolPartDisclosureId(part)}` +} + function ToolEntry({ part }: ToolEntryProps) { const { t } = useI18n() const copy = t.assistant.tool @@ -336,7 +347,7 @@ function ToolEntry({ part }: ToolEntryProps) { [args, isError, result, toolCallId, toolName] ) - const disclosureId = `tool-entry:${messageId}:${toolPartDisclosureId(stablePart)}` + const disclosureId = toolEntryDisclosureId(messageId, stablePart) const dismissed = useStore($toolRowDismissed(disclosureId)) const isPending = messageRunning && result === undefined // Subscribe to this tool's diff only, so a live patch for one tool doesn't @@ -789,6 +800,8 @@ function ToolRunHeader({ interface ToolRunState { count: number + /** Disclosure id of each row in the run, so the run can tell when one is open. */ + entryIds: readonly string[] key: string live: boolean /** A call still awaiting a result that could be the one blocking on approval. */ @@ -827,6 +840,7 @@ function useToolRun(startIndex: number, endIndex: number): ToolRunState { signature, value: { count: tools.length, + entryIds: tools.map(tool => toolEntryDisclosureId(state.message.id, tool)), key: tools[0]?.toolCallId ?? '', live, pendingApprovalTool: tools.some(tool => tool.result === undefined && APPROVAL_TOOLS.has(tool.toolName)), @@ -859,11 +873,12 @@ const ToolRun: FC> = startIndex }) => { const messageRunning = useAuiState(selectMessageRunning) - const { count, key, live, pendingApprovalTool, summary } = useToolRun(startIndex, endIndex) + const { count, entryIds, key, live, pendingApprovalTool, summary } = useToolRun(startIndex, endIndex) const sessionId = useStore(useSessionView().$runtimeId) const approval = useStore(useMemo(() => sessionApprovalRequest(sessionId), [sessionId])) const disclosureId = `tool-run:${key}` const persistedOpen = useStore($toolDisclosureOpen(disclosureId)) + const rowOpen = useStore(useMemo(() => $anyToolDisclosureOpen(entryIds), [entryIds])) const enterRef = useEnterAnimation(messageRunning, `tool-run:${key}`) // A lone call is already its own one-line summary; heading it with a second @@ -872,11 +887,14 @@ const ToolRun: FC> = return <>{children} } - // An approval is a question the user has to answer, and the ticker only ever - // shows one line — the row carrying the approval bar could tick straight - // past it. Show the whole run until it's answered. + // Two things a one-line window can't hold. An approval is a question the + // user has to answer, and expanded output is one they went looking for — + // both would tick straight past, or be sliced to a single line, as the run + // keeps going. Either one hands the run back its full height until the run + // settles and the row can be reached through the summary instead. const blocked = Boolean(approval) && pendingApprovalTool - const expanded = live ? blocked : (persistedOpen ?? false) + const unfurled = blocked || rowOpen + const expanded = live ? unfurled : (persistedOpen ?? false) return (
> = open={expanded} summary={summary} /> - {live && !blocked && {children}} + {live && !unfurled && {children}} {expanded &&
{children}
}
) diff --git a/apps/desktop/src/components/assistant-ui/tool/tool-group.test.tsx b/apps/desktop/src/components/assistant-ui/tool/tool-group.test.tsx index 65e9ab34d07..39fa91496b1 100644 --- a/apps/desktop/src/components/assistant-ui/tool/tool-group.test.tsx +++ b/apps/desktop/src/components/assistant-ui/tool/tool-group.test.tsx @@ -523,6 +523,28 @@ describe('live tool run', () => { expect(container.querySelector('[data-tool-ticker]')).not.toBeNull() expect(container.querySelector('[data-tool-summary] button[aria-expanded]')).toBeNull() }) + + // The ticker is a one-line window, so a row opened inside it had its output + // sliced to that line and then ticked away by the next call. Opening a row + // is a request to read it: the run gives up the window until it settles. + it('drops the one-line window when a row inside it is opened', async () => { + const { container } = render() + + await screen.findByText('Running 2 commands') + + const row = container.querySelector('[data-tool-ticker] [data-tool-row] button[aria-expanded="false"]') + + expect(row).not.toBeNull() + + fireEvent.click(row as Element) + + await waitFor(() => { + expect(container.querySelector('[data-tool-ticker]')).toBeNull() + }) + + // ...and the row it opened is still on screen to be read. + expect(container.querySelector('[data-tool-row][data-tool-open]')).not.toBeNull() + }) }) // A run whose calls never resolved used to read as live forever, which stranded diff --git a/apps/desktop/src/store/tool-view.ts b/apps/desktop/src/store/tool-view.ts index 1929321651d..914f5804baf 100644 --- a/apps/desktop/src/store/tool-view.ts +++ b/apps/desktop/src/store/tool-view.ts @@ -15,6 +15,7 @@ export const $toolViewMode = atom( ) export const $toolDisclosureStates = atom(loadToolDisclosureStates()) const disclosureOpenCache = new Map>() +const anyDisclosureOpenCache = new Map>() $toolViewMode.subscribe(mode => persistBoolean(TOOL_VIEW_TECHNICAL_STORAGE_KEY, mode === 'technical')) $toolDisclosureStates.subscribe(persistToolDisclosureStates) @@ -34,6 +35,24 @@ export function $toolDisclosureOpen(id: string): ReadableAtom { + const key = ids.join('|') + let cached = anyDisclosureOpenCache.get(key) + + if (!cached) { + cached = computed($toolDisclosureStates, states => ids.some(id => Boolean(states[id]))) + anyDisclosureOpenCache.set(key, cached) + } + + return cached +} + function loadToolDisclosureStates(): ToolDisclosureStates { if (typeof window === 'undefined') { return {}