From e58df998ef094818ff2bda5075d72a75f1d97adc Mon Sep 17 00:00:00 2001 From: rcourtman Date: Sun, 7 Jun 2026 13:51:53 +0100 Subject: [PATCH] Keep fast Assistant tool activity visible --- .../v6/internal/subsystems/ai-runtime.md | 11 +++ .../src/components/AI/Chat/MessageItem.tsx | 1 + .../components/AI/Chat/ToolExecutionBlock.tsx | 28 ++++--- .../AI/Chat/__tests__/MessageItem.test.tsx | 2 + .../__tests__/ToolExecutionBlock.test.tsx | 23 ++++++ .../AI/Chat/__tests__/useChat.test.ts | 82 ++++++++++++++++++- .../src/components/AI/Chat/hooks/useChat.ts | 17 +++- .../AI/Chat/streamActivityTiming.ts | 20 +++++ .../src/components/AI/Chat/types.ts | 1 + 9 files changed, 170 insertions(+), 15 deletions(-) create mode 100644 frontend-modern/src/components/AI/Chat/streamActivityTiming.ts diff --git a/docs/release-control/v6/internal/subsystems/ai-runtime.md b/docs/release-control/v6/internal/subsystems/ai-runtime.md index 47050da0f..36abb5a78 100644 --- a/docs/release-control/v6/internal/subsystems/ai-runtime.md +++ b/docs/release-control/v6/internal/subsystems/ai-runtime.md @@ -2085,6 +2085,17 @@ as visible transcript rows in arrival order instead of being replaced by a grouped context footer, while command previews, inputs, progress, and large outputs remain contained inside each tool row. This keeps the user-facing stream feeling active without dumping large command output into the default answer. +Fast tool completions must also stay visibly live long enough to be perceived: +the frontend stream reducer stamps sub-420ms successful tool completions with a +transient settle deadline, and the row renders that deadline as a running state +even if the turn's `done` event has already arrived. The referenced OpenCode +source at fetched `dev` commit `e82542b8023a8374f29c23b70ec019c8f256354e` +implements the same user-visible principle in +`packages/opencode/src/cli/cmd/run/session-data.ts` by emitting a `start` commit +for running tools and a later completed/error commit instead of only surfacing a +batched terminal transcript. Pulse adapts that as an in-memory UI settle window +because Pulse transcripts persist completed tool facts, not OpenCode scrollback +commit phases. Assistant provider retries are a first-class visible workflow state, not a hidden server log. The referenced OpenCode source at fetched `dev` commit diff --git a/frontend-modern/src/components/AI/Chat/MessageItem.tsx b/frontend-modern/src/components/AI/Chat/MessageItem.tsx index ca129e349..2d29d3863 100644 --- a/frontend-modern/src/components/AI/Chat/MessageItem.tsx +++ b/frontend-modern/src/components/AI/Chat/MessageItem.tsx @@ -597,6 +597,7 @@ export const MessageItem: Component = (props) => { startedAt={evt?.startedAt} completedAt={evt?.updatedAt} live={props.message.isStreaming} + settleUntil={evt?.settleUntil} tool={{ name: tool().name || 'unknown', input: tool().input || '{}', diff --git a/frontend-modern/src/components/AI/Chat/ToolExecutionBlock.tsx b/frontend-modern/src/components/AI/Chat/ToolExecutionBlock.tsx index a48ff4b85..a2f39622c 100644 --- a/frontend-modern/src/components/AI/Chat/ToolExecutionBlock.tsx +++ b/frontend-modern/src/components/AI/Chat/ToolExecutionBlock.tsx @@ -27,12 +27,14 @@ import { pendingToolActionState, toolValueText, } from './toolPresentation'; +import { getAssistantFastToolCompletionSettleUntil } from './streamActivityTiming'; interface ToolExecutionBlockProps { tool: ToolExecution; startedAt?: number; completedAt?: number; live?: boolean; + settleUntil?: number; } interface ToolInputSummaryProps { @@ -145,8 +147,6 @@ const formatCompletedToolDuration = (startedAt?: number, completedAt?: number): return remainingMinutes ? `${hours}h ${remainingMinutes}m` : `${hours}h`; }; -const FAST_TOOL_COMPLETION_SETTLE_MS = 420; - const ToolInputSummary: Component = (props) => { const isShellSummary = createMemo(() => props.summary.trim().startsWith('$ ')); const className = createMemo( @@ -307,26 +307,28 @@ export const ToolExecutionBlock: Component = (props) => const hasOutput = createMemo(() => hasReadableToolOutput(outputText())); const hasDetails = createMemo(() => hasInput() || hasOutput()); createEffect(() => { - if (!props.live || !props.tool.success || !props.startedAt || !props.completedAt) { + if (!props.tool.success) { setSettlingFastCompletion(false); return; } - const durationMs = props.completedAt - props.startedAt; - if ( - !Number.isFinite(durationMs) || - durationMs < 0 || - durationMs >= FAST_TOOL_COMPLETION_SETTLE_MS - ) { + const now = Date.now(); + const explicitSettleUntil = + Number.isFinite(props.settleUntil) && (props.settleUntil || 0) > now + ? props.settleUntil + : undefined; + const liveSettleUntil = + props.live === true + ? getAssistantFastToolCompletionSettleUntil(props.startedAt, props.completedAt, now) + : undefined; + const settleUntil = explicitSettleUntil || liveSettleUntil; + if (!settleUntil) { setSettlingFastCompletion(false); return; } setSettlingFastCompletion(true); - const timeout = window.setTimeout( - () => setSettlingFastCompletion(false), - FAST_TOOL_COMPLETION_SETTLE_MS - durationMs, - ); + const timeout = window.setTimeout(() => setSettlingFastCompletion(false), settleUntil - now); onCleanup(() => window.clearTimeout(timeout)); }); diff --git a/frontend-modern/src/components/AI/Chat/__tests__/MessageItem.test.tsx b/frontend-modern/src/components/AI/Chat/__tests__/MessageItem.test.tsx index f2ab2ba36..c13cf14c0 100644 --- a/frontend-modern/src/components/AI/Chat/__tests__/MessageItem.test.tsx +++ b/frontend-modern/src/components/AI/Chat/__tests__/MessageItem.test.tsx @@ -41,12 +41,14 @@ vi.mock('../ToolExecutionBlock', () => ({ tool: { name: string; input: string; output: string; success: boolean }; startedAt?: number; completedAt?: number; + settleUntil?: number; }) => (
{props.tool.output}
diff --git a/frontend-modern/src/components/AI/Chat/__tests__/ToolExecutionBlock.test.tsx b/frontend-modern/src/components/AI/Chat/__tests__/ToolExecutionBlock.test.tsx index 63ef0e1f8..7e38bc9a2 100644 --- a/frontend-modern/src/components/AI/Chat/__tests__/ToolExecutionBlock.test.tsx +++ b/frontend-modern/src/components/AI/Chat/__tests__/ToolExecutionBlock.test.tsx @@ -152,6 +152,29 @@ describe('ToolExecutionBlock', () => { expect(screen.getByLabelText('Tool duration <1s')).toHaveTextContent('<1s'); }); + it('briefly presents explicitly settled fast completions after the turn has ended', async () => { + vi.useFakeTimers(); + vi.setSystemTime(10_000); + + render(() => ( + + )); + + expect(screen.getByLabelText('Assistant tool running')).toBeInTheDocument(); + expect(screen.getByText('running')).toBeInTheDocument(); + + await vi.advanceTimersByTimeAsync(380); + + expect(screen.queryByLabelText('Assistant tool running')).not.toBeInTheDocument(); + expect(screen.getByText('completed')).toBeInTheDocument(); + expect(screen.getByLabelText('Tool duration <1s')).toHaveTextContent('<1s'); + }); + it('does not defer failed fast completions behind a running state', () => { render(() => ( { }); }); + afterEach(() => { + vi.useRealTimers(); + }); + // ────────────────────────────────────────────── // Initialization // ────────────────────────────────────────────── @@ -2045,6 +2049,82 @@ describe('useChat', () => { dispose(); }); + it('stamps fast tool completions with a transient settle deadline', async () => { + vi.useFakeTimers(); + vi.setSystemTime(20_000); + const { getFireEvent } = setupWithEventCapture(); + const { value: chat, dispose } = withRoot(() => useChat({ sessionId: 's' })); + + await chat.sendMessage('hi'); + const fire = getFireEvent(); + + fire({ type: 'tool_start', data: { id: 'tool-1', name: 'pulse_read', input: '{}' } }); + vi.setSystemTime(20_040); + fire({ + type: 'tool_end', + data: { + id: 'tool-1', + name: 'pulse_read', + input: '{}', + output: '4358', + success: true, + }, + }); + fire({ type: 'content', data: 'There are 4,358 entries.' }); + fire({ type: 'done', data: {} }); + + const assistant = chat.messages().find((m) => m.role === 'assistant')!; + const toolEvent = assistant.streamEvents?.find((event) => event.type === 'tool'); + expect(assistant.isStreaming).toBe(false); + expect(toolEvent).toEqual( + expect.objectContaining({ + type: 'tool', + toolId: 'tool-1', + startedAt: 20_000, + updatedAt: 20_040, + settleUntil: 20_420, + }), + ); + expect(assistant.streamEvents?.map((event) => event.type)).toEqual(['tool', 'content']); + dispose(); + }); + + it('does not stamp slow tool completions with a settle deadline', async () => { + vi.useFakeTimers(); + vi.setSystemTime(30_000); + const { getFireEvent } = setupWithEventCapture(); + const { value: chat, dispose } = withRoot(() => useChat({ sessionId: 's' })); + + await chat.sendMessage('hi'); + const fire = getFireEvent(); + + fire({ type: 'tool_start', data: { id: 'tool-1', name: 'pulse_read', input: '{}' } }); + vi.setSystemTime(31_000); + fire({ + type: 'tool_end', + data: { + id: 'tool-1', + name: 'pulse_read', + input: '{}', + output: '4358', + success: true, + }, + }); + + const assistant = chat.messages().find((m) => m.role === 'assistant')!; + const toolEvent = assistant.streamEvents?.find((event) => event.type === 'tool'); + expect(toolEvent).toEqual( + expect.objectContaining({ + type: 'tool', + toolId: 'tool-1', + startedAt: 30_000, + updatedAt: 31_000, + }), + ); + expect(toolEvent?.settleUntil).toBeUndefined(); + dispose(); + }); + it('preserves pending tool identity when terminal updates omit name and input', async () => { const { getFireEvent } = setupWithEventCapture(); const { value: chat, dispose } = withRoot(() => useChat({ sessionId: 's' })); diff --git a/frontend-modern/src/components/AI/Chat/hooks/useChat.ts b/frontend-modern/src/components/AI/Chat/hooks/useChat.ts index 77648b7d5..ed04f65a3 100644 --- a/frontend-modern/src/components/AI/Chat/hooks/useChat.ts +++ b/frontend-modern/src/components/AI/Chat/hooks/useChat.ts @@ -19,6 +19,7 @@ import { type AssistantOutputArtifactStreamState, } from '../assistantOutputHygiene'; import { isAssistantExplicitModelRoute } from '../assistantModelRoutes'; +import { getAssistantFastToolCompletionSettleUntil } from '../streamActivityTiming'; import type { ChatMessage, ToolExecution, @@ -1438,12 +1439,21 @@ export function useChat(options: UseChatOptions = {}) { return true; }); const completedAt = Date.now(); + const settleUntil = + newToolCall.success && resolvedPendingTool?.startedAt + ? getAssistantFastToolCompletionSettleUntil( + resolvedPendingTool.startedAt, + completedAt, + completedAt, + ) + : undefined; updatedEvents.push({ type: 'tool', tool: newToolCall, toolId: completedToolId, startedAt: resolvedPendingTool?.startedAt, updatedAt: completedAt, + settleUntil, }); } else { // No approval - replace the pending_tool in place. If the terminal @@ -1457,12 +1467,17 @@ export function useChat(options: UseChatOptions = {}) { evt.type === 'pending_tool' && matchesCompletedTool(evt.toolId, evt.pendingTool?.name) ) { + const startedAt = evt.pendingTool?.startedAt || evt.startedAt; + const settleUntil = newToolCall.success + ? getAssistantFastToolCompletionSettleUntil(startedAt, completedAt, completedAt) + : undefined; updatedEvents[i] = { type: 'tool', tool: newToolCall, toolId: completedToolId, - startedAt: evt.pendingTool?.startedAt || evt.startedAt, + startedAt, updatedAt: completedAt, + settleUntil, }; replacedPendingTool = true; break; diff --git a/frontend-modern/src/components/AI/Chat/streamActivityTiming.ts b/frontend-modern/src/components/AI/Chat/streamActivityTiming.ts new file mode 100644 index 000000000..83e6959a7 --- /dev/null +++ b/frontend-modern/src/components/AI/Chat/streamActivityTiming.ts @@ -0,0 +1,20 @@ +export const ASSISTANT_FAST_TOOL_COMPLETION_SETTLE_MS = 420; + +export const getAssistantFastToolCompletionSettleUntil = ( + startedAt: number | undefined, + completedAt: number | undefined, + now = Date.now(), +): number | undefined => { + if (!startedAt || !completedAt) return undefined; + + const durationMs = completedAt - startedAt; + if ( + !Number.isFinite(durationMs) || + durationMs < 0 || + durationMs >= ASSISTANT_FAST_TOOL_COMPLETION_SETTLE_MS + ) { + return undefined; + } + + return now + (ASSISTANT_FAST_TOOL_COMPLETION_SETTLE_MS - durationMs); +}; diff --git a/frontend-modern/src/components/AI/Chat/types.ts b/frontend-modern/src/components/AI/Chat/types.ts index 841e75636..e227c814f 100644 --- a/frontend-modern/src/components/AI/Chat/types.ts +++ b/frontend-modern/src/components/AI/Chat/types.ts @@ -126,6 +126,7 @@ export interface StreamDisplayEvent { model?: string; failedModel?: string; modelEvent?: 'selected' | 'switch' | 'fallback'; + settleUntil?: number; toolId?: string; // Used to match pending_tool with completed tool approval?: PendingApproval; // For approval_needed events question?: PendingQuestion; // For question events