Merge pull request #74286 from NousResearch/bb/tool-ticker-clip

fix(desktop): let an opened tool row escape the live run's one-line window
This commit is contained in:
brooklyn! 2026-07-29 14:01:51 -05:00 committed by GitHub
commit 3bb239750d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 67 additions and 8 deletions

View file

@ -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<PropsWithChildren<{ endIndex: number; startIndex: number }>> =
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<PropsWithChildren<{ endIndex: number; startIndex: number }>> =
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 (
<div
@ -891,7 +909,7 @@ const ToolRun: FC<PropsWithChildren<{ endIndex: number; startIndex: number }>> =
open={expanded}
summary={summary}
/>
{live && !blocked && <ToolRunTicker>{children}</ToolRunTicker>}
{live && !unfurled && <ToolRunTicker>{children}</ToolRunTicker>}
{expanded && <div className="grid min-w-0 max-w-full gap-(--tool-row-gap)">{children}</div>}
</div>
)

View file

@ -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(<GroupHarness message={betweenSequentialCallsMessage()} />)
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

View file

@ -15,6 +15,7 @@ export const $toolViewMode = atom<ToolViewMode>(
)
export const $toolDisclosureStates = atom<ToolDisclosureStates>(loadToolDisclosureStates())
const disclosureOpenCache = new Map<string, ReadableAtom<boolean | undefined>>()
const anyDisclosureOpenCache = new Map<string, ReadableAtom<boolean>>()
$toolViewMode.subscribe(mode => persistBoolean(TOOL_VIEW_TECHNICAL_STORAGE_KEY, mode === 'technical'))
$toolDisclosureStates.subscribe(persistToolDisclosureStates)
@ -34,6 +35,24 @@ export function $toolDisclosureOpen(id: string): ReadableAtom<boolean | undefine
return cached
}
/**
* Whether any of a set of disclosures is open a run asking about its rows.
*
* Computed rather than reading the whole map so a toggle anywhere in the
* transcript only re-renders the runs whose own answer changed.
*/
export function $anyToolDisclosureOpen(ids: readonly string[]): ReadableAtom<boolean> {
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 {}