mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-10 18:45:53 +00:00
Keep fast Assistant tool activity visible
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user