Keep fast Assistant tool rows visibly running

This commit is contained in:
rcourtman 2026-06-07 21:48:12 +01:00
parent 3bbbee323f
commit ddd9aaddba
5 changed files with 50 additions and 14 deletions

View file

@ -225,7 +225,15 @@ timers when the stream already has a newer workflow/tool state.
exposes a provider action in the same dialog. Pulse adapts that by wiring
the Assistant model chooser's provider action to the governed Assistant &
Patrol provider settings route instead of hiding provider repair behind a
separate slash command or silently switching model routes. Stop is the explicit
separate slash command or silently switching model routes. The same
2026-06-07 activity-visibility pass rechecked OpenCode
`packages/app/src/pages/session/message-timeline.tsx` and
`message-timeline.data.ts`: active turns are represented as session timeline
rows, with busy/retry state kept visible while assistant parts continue to
arrive. Pulse adapts that by keeping fast command/tool completions in a
transient running state long enough for the compact transcript row to paint,
while still collapsing large command output behind the existing tool-details
affordance. Stop is the explicit
interruption path: it must abort the active stream, clear queued follow-ups
and pending tool/approval/question affordances, preserve any partial model
text, return focus to the composer, and render a neutral transcript marker

View file

@ -354,7 +354,7 @@ export const ToolExecutionBlock: Component<ToolExecutionBlockProps> = (props) =>
const hasOutput = createMemo(() => hasReadableToolOutput(outputText()));
const hasDetails = createMemo(() => hasInput() || hasOutput());
createEffect(() => {
if (props.compact || !props.tool.success) {
if (!props.tool.success) {
setSettlingFastCompletion(false);
return;
}
@ -410,16 +410,31 @@ export const ToolExecutionBlock: Component<ToolExecutionBlockProps> = (props) =>
<div
class="my-1 inline-flex max-w-full items-start gap-2 rounded-md border border-border-subtle bg-surface-alt px-2.5 py-1.5 text-[11px] text-muted"
role="status"
aria-label="Assistant completed tool activity"
aria-label={
settlingFastCompletion()
? 'Assistant tool running'
: 'Assistant completed tool activity'
}
title={inputSummary()}
>
<CheckCircleIcon
class="mt-0.5 h-3.5 w-3.5 shrink-0 text-emerald-600 dark:text-emerald-300"
aria-label="completed"
/>
<Show
when={!settlingFastCompletion()}
fallback={
<LoaderCircleIcon
class="mt-0.5 h-3.5 w-3.5 shrink-0 animate-spin text-blue-500 dark:text-blue-400"
aria-label="running"
/>
}
>
<CheckCircleIcon
class="mt-0.5 h-3.5 w-3.5 shrink-0 text-emerald-600 dark:text-emerald-300"
aria-label="completed"
/>
</Show>
<span class="shrink-0 font-mono text-[9px] font-semibold uppercase tracking-wider">
{toolLabel()}
</span>
<span class="shrink-0 text-[10px] font-medium">{statusLabel()}</span>
<span class="min-w-0 truncate text-base-content">{inputSummary()}</span>
<Show when={durationLabel()}>
<span class="shrink-0 text-[10px]" aria-label={`Tool duration ${durationLabel()}`}>

View file

@ -8,6 +8,7 @@ import {
PendingToolsList,
ToolExecutionsList,
} from '../ToolExecutionBlock';
import { ASSISTANT_FAST_TOOL_COMPLETION_SETTLE_MS } from '../streamActivityTiming';
import type { ToolExecution, PendingTool, ToolCancellation } from '../types';
afterEach(() => {
@ -47,7 +48,7 @@ function makeCancellation(overrides?: Partial<ToolCancellation>): ToolCancellati
}
const getToolDetailsTrigger = () => screen.getByTitle('Show tool details');
const FAST_TOOL_SETTLE_TEST_MS = 500;
const FAST_TOOL_SETTLE_TEST_MS = ASSISTANT_FAST_TOOL_COMPLETION_SETTLE_MS + 80;
// ============================================================
// ToolExecutionBlock
@ -161,21 +162,23 @@ describe('ToolExecutionBlock', () => {
tool={makeTool()}
startedAt={9_900}
completedAt={9_940}
settleUntil={10_380}
settleUntil={10_000 + ASSISTANT_FAST_TOOL_COMPLETION_SETTLE_MS - 40}
/>
));
expect(screen.getByLabelText('Assistant tool running')).toBeInTheDocument();
expect(screen.getByText('running')).toBeInTheDocument();
await vi.advanceTimersByTimeAsync(380);
await vi.advanceTimersByTimeAsync(FAST_TOOL_SETTLE_TEST_MS);
expect(screen.queryByLabelText('Assistant tool running')).not.toBeInTheDocument();
expect(screen.getByText('completed')).toBeInTheDocument();
expect(screen.getByLabelText('Tool duration <1s')).toHaveTextContent('<1s');
});
it('renders compact completed activity without the running settle state', () => {
it('briefly presents compact fresh live fast completions as running', async () => {
vi.useFakeTimers();
render(() => (
<ToolExecutionBlock
tool={makeTool({ output: 'up 42 days' })}
@ -186,13 +189,22 @@ describe('ToolExecutionBlock', () => {
/>
));
const runningRow = screen.getByRole('status', { name: 'Assistant tool running' });
expect(runningRow).toHaveTextContent('cmd');
expect(runningRow).toHaveTextContent('running');
expect(runningRow).toHaveTextContent('uptime');
expect(screen.getByLabelText('running')).toBeInTheDocument();
expect(screen.queryByLabelText(/Tool duration/)).not.toBeInTheDocument();
await vi.advanceTimersByTimeAsync(FAST_TOOL_SETTLE_TEST_MS);
const row = screen.getByRole('status', { name: 'Assistant completed tool activity' });
expect(row).toHaveTextContent('cmd');
expect(row).toHaveTextContent('completed');
expect(row).toHaveTextContent('uptime');
expect(row).toHaveTextContent('<1s');
expect(screen.getByLabelText('completed')).toBeInTheDocument();
expect(screen.queryByLabelText('Assistant tool running')).not.toBeInTheDocument();
expect(screen.queryByText('running')).not.toBeInTheDocument();
});
it('does not defer failed fast completions behind a running state', () => {

View file

@ -32,6 +32,7 @@ vi.mock('@/utils/logger', () => ({
}));
import { useChat } from '../hooks/useChat';
import { ASSISTANT_FAST_TOOL_COMPLETION_SETTLE_MS } from '../streamActivityTiming';
import { AIChatAPI, type ChatMention, type StreamEvent } from '@/api/aiChat';
import { notificationStore } from '@/stores/notifications';
@ -2211,7 +2212,7 @@ describe('useChat', () => {
toolId: 'tool-1',
startedAt: 20_000,
updatedAt: 20_040,
settleUntil: 20_420,
settleUntil: 20_000 + ASSISTANT_FAST_TOOL_COMPLETION_SETTLE_MS,
}),
);
expect(assistant.streamEvents?.map((event) => event.type)).toEqual(['tool', 'content']);

View file

@ -1,4 +1,4 @@
export const ASSISTANT_FAST_TOOL_COMPLETION_SETTLE_MS = 420;
export const ASSISTANT_FAST_TOOL_COMPLETION_SETTLE_MS = 900;
export const getAssistantFastToolCompletionSettleUntil = (
startedAt: number | undefined,