Keep fast Assistant tool activity visible

This commit is contained in:
rcourtman
2026-06-07 13:51:53 +01:00
parent d7f0de0ba2
commit e58df998ef
9 changed files with 170 additions and 15 deletions
@@ -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
@@ -597,6 +597,7 @@ export const MessageItem: Component<MessageItemProps> = (props) => {
startedAt={evt?.startedAt}
completedAt={evt?.updatedAt}
live={props.message.isStreaming}
settleUntil={evt?.settleUntil}
tool={{
name: tool().name || 'unknown',
input: tool().input || '{}',
@@ -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<ToolInputSummaryProps> = (props) => {
const isShellSummary = createMemo(() => props.summary.trim().startsWith('$ '));
const className = createMemo(
@@ -307,26 +307,28 @@ export const ToolExecutionBlock: Component<ToolExecutionBlockProps> = (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));
});
@@ -41,12 +41,14 @@ vi.mock('../ToolExecutionBlock', () => ({
tool: { name: string; input: string; output: string; success: boolean };
startedAt?: number;
completedAt?: number;
settleUntil?: number;
}) => (
<div
data-testid="tool-execution-block"
data-tool-name={props.tool.name}
data-started-at={props.startedAt}
data-completed-at={props.completedAt}
data-settle-until={props.settleUntil}
>
{props.tool.output}
</div>
@@ -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(() => (
<ToolExecutionBlock
tool={makeTool()}
startedAt={9_900}
completedAt={9_940}
settleUntil={10_380}
/>
));
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(() => (
<ToolExecutionBlock
@@ -1,4 +1,4 @@
import { describe, expect, it, vi, beforeEach } from 'vitest';
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest';
import { createEffect, createRoot } from 'solid-js';
// Mock dependencies before importing
@@ -85,6 +85,10 @@ describe('useChat', () => {
});
});
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' }));
@@ -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;
@@ -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);
};
@@ -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