Show Assistant context tools as activity rows

This commit is contained in:
rcourtman
2026-06-07 10:02:29 +01:00
parent 7618d46697
commit 1fc36a790b
7 changed files with 73 additions and 308 deletions
@@ -1364,6 +1364,20 @@ runtime cost control, and shared AI transport surfaces.
out` cannot read like assistant output. Cost and context-limit percentages
stay absent until the runtime exposes those values through a governed
contract.
Assistant tool activity is visible transcript activity, not a hidden context
footer. The referenced OpenCode source at fetched `origin/dev` commit
`e82542b8023a8374f29c23b70ec019c8f256354e`
`packages/opencode/src/cli/cmd/run/types.ts` defines append-only
`StreamCommit` rows for assistant, reasoning, tool, and system sources at
lines 284-312, while
`packages/opencode/src/cli/cmd/run/footer.ts` queues commits and only
coalesces consecutive progress chunks for the same part/tool at lines
512-545. Pulse adapts that contract by rendering each Assistant
`pending_tool`, completed `tool`, and `tool_cancel` event as its own
chronological transcript row. Tool inputs, command previews, progress text,
and outputs may remain collapsed inside the row, but context/read/query
tools must not be replaced by a generic grouped footer that makes several
operations appear all at once.
7. Keep AI chat presentation helpers aligned through `frontend-modern/src/components/AI/Chat/` and the shared `frontend-modern/src/utils/textPresentation.ts`
8. Keep assistant drawer context, session, and org-switch reset state aligned through the shared `frontend-modern/src/stores/aiChat.ts` boundary instead of letting `frontend-modern/src/App.tsx`, `frontend-modern/src/AppLayout.tsx`, or feature callers fork their own assistant shell state
That shared drawer ownership also covers passive resource reads while the
@@ -2003,6 +2017,13 @@ runtime cost control, and shared AI transport surfaces.
## Current State
Assistant tool activity now follows an OpenCode-referenced chronological row
model where appropriate for Pulse. Consecutive context/read/query tools render
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.
Assistant provider retries are a first-class visible workflow state, not a
hidden server log. The referenced OpenCode source at fetched `dev` commit
`7ae856a9e97130f664f6f11fa5871a2795de9902` defines retry session status in
@@ -128,6 +128,12 @@ Assistant local stream fixtures are part of the same frontend API contract:
`frontend-modern/src/api/aiChatDevStreamFixture.ts` may short-circuit only
explicit `/fixture ...` prompts in development or test mode, must emit the same
typed stream event sequence as live chat, and must never open a provider request.
Fixtures that emit consecutive context/read/query tool events must keep those
events as ordinary typed `tool_start` / `tool_end` activity and must not encode
obsolete grouped-context wording in fixture answer content. The fixture payload
contract proves the stream reducer and transcript renderer against the same
chronological event order a live provider would produce; UI grouping or footer
summaries are not part of the fixture contract.
Queue verification fixtures must cover both the active hold turn and the queued
drain turn so UX proof can exercise queued follow-up ordering and tool rows
without consuming external model quota.
@@ -307,6 +307,13 @@ describe('AIChatAPI', () => {
success: true,
},
});
expect(onEvent.mock.calls[7][0]).toMatchObject({
type: 'content',
data: {
text: expect.stringContaining('separate visible activity rows'),
},
});
expect(onEvent.mock.calls[7][0].data.text).not.toContain('one compact context activity row');
expect(onEvent.mock.calls[8][0]).toMatchObject({
type: 'done',
data: {
@@ -358,7 +358,7 @@ const buildContextGroupFixtureEvents = (model?: string): AIChatStreamEvent[] =>
{
type: 'content',
data: {
text: 'The context-group fixture gathered the resource identity and recent CPU history as one compact context activity row.',
text: 'The context fixture gathered the resource identity and recent CPU history as separate visible activity rows.',
},
},
{
@@ -10,7 +10,6 @@ import {
onCleanup,
} from 'solid-js';
import CheckIcon from 'lucide-solid/icons/check';
import ChevronRightIcon from 'lucide-solid/icons/chevron-right';
import CircleAlertIcon from 'lucide-solid/icons/circle-alert';
import ClockIcon from 'lucide-solid/icons/clock';
import CopyIcon from 'lucide-solid/icons/copy';
@@ -32,27 +31,16 @@ import {
latestWorkflowStatus,
normalizeWorkflowStatusSequence,
} from './workflowStatusPresentation';
import {
isPlaceholderToolInputSummary,
parseToolInputSummary,
toolValueText,
} from './toolPresentation';
import type {
ChatMessage,
ModelRouteRecoveryOption,
PendingApproval,
PendingQuestion,
PendingTool,
StreamDisplayEvent,
ToolExecution,
WorkflowStatus,
} from './types';
import {
AI_CHAT_ASSISTANT_MESSAGE_LABEL,
AI_CHAT_CONTEXT_USED_LABEL,
} from '@/utils/aiChatPresentation';
import { AI_CHAT_ASSISTANT_MESSAGE_LABEL } from '@/utils/aiChatPresentation';
import { formatAIModelRouteLabel } from '@/utils/aiProviderPresentation';
import { formatIdentifierLabel } from '@/utils/textPresentation';
interface MessageItemProps {
message: ChatMessage;
@@ -99,94 +87,6 @@ const markdownClass =
const TEXT_RENDER_PACE_MS = 24;
const TEXT_RENDER_SNAP = /[\s.,!?;:)\]]/;
type ContextToolStreamEvent =
| (StreamDisplayEvent & { type: 'pending_tool'; pendingTool: PendingTool })
| (StreamDisplayEvent & { type: 'tool'; tool: ToolExecution });
type DisplayStreamItem =
| { kind: 'event'; event: StreamDisplayEvent }
| { kind: 'context_tool_group'; events: ContextToolStreamEvent[]; key: string };
const CONTEXT_TOOL_NAMES = new Set([
'read',
'query',
'fetch_url',
'get_infrastructure_state',
'get_active_alerts',
'get_metrics',
'get_metrics_history',
'get_baselines',
'get_patterns',
'get_disk_health',
'get_storage',
'get_storage_config',
'get_resource_details',
]);
const normalizedContextToolName = (name?: string) => name?.trim().replace(/^pulse_/, '') || '';
const isContextToolName = (name?: string) =>
CONTEXT_TOOL_NAMES.has(normalizedContextToolName(name));
const asContextToolStreamEvent = (event: StreamDisplayEvent): ContextToolStreamEvent | null => {
if (
event.type === 'pending_tool' &&
event.pendingTool &&
isContextToolName(event.pendingTool.name)
) {
return event as ContextToolStreamEvent;
}
if (event.type === 'tool' && event.tool && isContextToolName(event.tool.name)) {
return event as ContextToolStreamEvent;
}
return null;
};
const contextToolEventKey = (event: ContextToolStreamEvent) =>
[
event.type,
event.toolId,
event.type === 'pending_tool' ? event.pendingTool.id : event.tool.name,
event.startedAt,
event.updatedAt,
]
.map((value) => String(value ?? ''))
.join(':');
const groupContextToolStreamItems = (events: StreamDisplayEvent[]): DisplayStreamItem[] => {
const items: DisplayStreamItem[] = [];
let pendingGroup: ContextToolStreamEvent[] = [];
const flushGroup = () => {
if (pendingGroup.length >= 2) {
items.push({
kind: 'context_tool_group',
events: pendingGroup,
key: `context-tool:${pendingGroup.map(contextToolEventKey).join('|')}`,
});
} else {
for (const event of pendingGroup) {
items.push({ kind: 'event', event });
}
}
pendingGroup = [];
};
for (const event of events) {
const contextToolEvent = asContextToolStreamEvent(event);
if (contextToolEvent) {
pendingGroup.push(contextToolEvent);
continue;
}
flushGroup();
items.push({ kind: 'event', event });
}
flushGroup();
return items;
};
const textRenderStep = (size: number) => {
if (size <= 12) return 2;
if (size <= 48) return 4;
@@ -332,88 +232,6 @@ const AssistantMarkdownBlock: Component<{
);
};
const ContextToolActivityGroup: Component<{
events: ContextToolStreamEvent[];
live: boolean;
}> = (props) => {
const [expanded, setExpanded] = createSignal(false);
const active = createMemo(() => props.events.some((event) => event.type === 'pending_tool'));
const count = createMemo(() => props.events.length);
const countLabel = createMemo(() => `${count()} context ${count() === 1 ? 'check' : 'checks'}`);
const statusLabel = createMemo(() => (active() ? 'Gathering context' : 'Context gathered'));
const title = createMemo(() => `${statusLabel()} · ${countLabel()}`);
const toggle = () => setExpanded((value) => !value);
return (
<div
class="my-1 overflow-hidden rounded-md border border-blue-200 bg-blue-50/60 text-[11px] dark:border-blue-900/60 dark:bg-blue-950/20"
data-testid="context-tool-group"
role="group"
aria-label={title()}
>
<button
type="button"
class="flex w-full min-w-0 items-center gap-2 px-2.5 py-2 text-left transition-colors hover:bg-blue-100/60 focus:outline-none focus:ring-2 focus:ring-blue-500/30 focus:ring-inset dark:hover:bg-blue-950/30"
aria-expanded={expanded()}
onClick={toggle}
>
<span
class={`h-1.5 w-1.5 shrink-0 rounded-full ${
active() ? 'animate-pulse bg-blue-500' : 'bg-emerald-500'
}`}
aria-hidden="true"
/>
<span class="shrink-0 text-[10px] font-semibold uppercase tracking-wide text-muted">
{statusLabel()}
</span>
<span class="min-w-0 truncate text-[12px] font-medium text-base-content">
{countLabel()}
</span>
<ChevronRightIcon
class={`ml-auto h-3.5 w-3.5 shrink-0 text-muted transition-transform ${
expanded() ? 'rotate-90' : ''
}`}
aria-hidden="true"
/>
</button>
<Show when={expanded()}>
<div class="border-t border-blue-200/70 px-2 py-1.5 dark:border-blue-900/60">
<For each={props.events}>
{(event) => {
const pendingTool = event.type === 'pending_tool' ? event.pendingTool : undefined;
const tool = event.type === 'tool' ? event.tool : undefined;
return (
<Switch>
<Match when={pendingTool}>
{(pending) => <PendingToolBlock tool={pending()} />}
</Match>
<Match when={tool}>
{(completedTool) => (
<ToolExecutionBlock
startedAt={event.startedAt}
completedAt={event.updatedAt}
live={props.live}
tool={{
name: completedTool().name || 'unknown',
input: completedTool().input || '{}',
rawInput: completedTool().rawInput,
output: completedTool().output || '',
success: completedTool().success ?? true,
}}
/>
)}
</Match>
</Switch>
);
}}
</For>
</div>
</Show>
</div>
);
};
/**
* MessageItem - Renders a single message in the chat.
*
@@ -441,10 +259,6 @@ export const MessageItem: Component<MessageItemProps> = (props) => {
const groupedEvents = createMemo(() =>
groupStreamEventsForDisplay(props.message.streamEvents || []),
);
const displayStreamItems = createMemo(() => groupContextToolStreamItems(groupedEvents()));
const hasContextToolActivityGroup = createMemo(() =>
displayStreamItems().some((item) => item.kind === 'context_tool_group'),
);
const isSelectedModelRouteEvent = (evt: StreamDisplayEvent) =>
evt.type === 'model_switch' && evt.modelEvent === 'selected' && !evt.failedModel?.trim();
const isConcreteStreamActivity = (evt: StreamDisplayEvent) => {
@@ -510,27 +324,6 @@ export const MessageItem: Component<MessageItemProps> = (props) => {
.slice(0, index)
.every((evt) => evt.type === 'thinking');
const contextToolSummaries = createMemo(() => {
const events = props.message.streamEvents || [];
const summaries = new Set<string>();
for (const evt of events) {
if (evt.type === 'tool' && evt.tool?.name) {
const summary = parseToolInputSummary(
toolValueText(evt.tool.input),
evt.tool.name,
evt.tool.rawInput,
);
const label =
summary && !isPlaceholderToolInputSummary(summary)
? summary
: formatIdentifierLabel(evt.tool.name, { stripPrefix: 'pulse_' });
summaries.add(label);
}
}
return Array.from(summaries);
});
const visibleMessageContent = () =>
stripAssistantOutputArtifacts(props.message.content || '').text;
const modelRouteLabel = (route?: string) => {
@@ -765,23 +558,13 @@ export const MessageItem: Component<MessageItemProps> = (props) => {
{/* Stream events - chronological display */}
<Show when={hasRenderableStreamEvents()}>
<For each={displayStreamItems()}>
{(item, index) => {
const contextGroup = item.kind === 'context_tool_group' ? item : undefined;
const evt = item.kind === 'event' ? item.event : undefined;
<For each={groupedEvents()}>
{(evt, index) => {
const contentText = () =>
stripAssistantOutputArtifacts(evt?.content || '').text;
return (
<Switch>
<Match when={contextGroup}>
{(group) => (
<ContextToolActivityGroup
events={group().events}
live={props.message.isStreaming === true}
/>
)}
</Match>
<Match
when={
evt?.type === 'thinking' &&
@@ -1019,30 +802,6 @@ export const MessageItem: Component<MessageItemProps> = (props) => {
<Show when={isStreamingText() && !isWaitingForFirstToken()}>
<span class="inline-block w-1.5 h-4 ml-0.5 align-middle bg-blue-500 dark:bg-blue-400 animate-pulse rounded-full" />
</Show>
<Show
when={
!props.message.isStreaming &&
!hasContextToolActivityGroup() &&
contextToolSummaries().length > 0
}
>
<div class="mt-4 pt-3 border-t border-border-subtle flex flex-wrap gap-2">
<span class="text-[10px] uppercase font-semibold text-muted">
{AI_CHAT_CONTEXT_USED_LABEL}
</span>
<div class="flex flex-wrap gap-1.5">
{contextToolSummaries().map((summary) => (
<span
class="max-w-[18rem] truncate px-1.5 py-0.5 rounded text-[10px] bg-surface-hover text-muted border border-border font-medium"
title={summary}
>
{summary}
</span>
))}
</div>
</div>
</Show>
</div>
</div>
</div>
@@ -1447,7 +1447,7 @@ describe('MessageItem', () => {
});
describe('context tools display', () => {
it('groups consecutive context checks into one expandable transcript row', () => {
it('shows consecutive completed context checks as individual transcript rows', () => {
const events: StreamDisplayEvent[] = [
{
type: 'tool',
@@ -1480,17 +1480,20 @@ describe('MessageItem', () => {
/>
));
expect(screen.getByTestId('context-tool-group')).toHaveTextContent('Context gathered');
expect(screen.getByTestId('context-tool-group')).toHaveTextContent('2 context checks');
expect(screen.queryByTestId('tool-execution-block')).not.toBeInTheDocument();
expect(screen.queryByText('Context used')).not.toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: /Context gathered/i }));
expect(screen.getAllByTestId('tool-execution-block')).toHaveLength(2);
expect(screen.getAllByTestId('tool-execution-block')[0]).toHaveAttribute(
'data-tool-name',
'pulse_read',
);
expect(screen.getAllByTestId('tool-execution-block')[1]).toHaveAttribute(
'data-tool-name',
'pulse_get_metrics',
);
expect(screen.queryByTestId('context-tool-group')).not.toBeInTheDocument();
expect(screen.queryByText('Context used')).not.toBeInTheDocument();
});
it('shows consecutive pending context checks as one active expandable transcript row', () => {
it('shows consecutive pending context checks as individual live transcript rows', () => {
const events: StreamDisplayEvent[] = [
{
type: 'pending_tool',
@@ -1523,13 +1526,16 @@ describe('MessageItem', () => {
/>
));
expect(screen.getByTestId('context-tool-group')).toHaveTextContent('Gathering context');
expect(screen.getByTestId('context-tool-group')).toHaveTextContent('2 context checks');
expect(screen.queryByTestId('pending-tool-block')).not.toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: /Gathering context/i }));
expect(screen.getAllByTestId('pending-tool-block')).toHaveLength(2);
expect(screen.getAllByTestId('pending-tool-block')[0]).toHaveAttribute(
'data-tool-name',
'pulse_get_resource_details',
);
expect(screen.getAllByTestId('pending-tool-block')[1]).toHaveAttribute(
'data-tool-name',
'pulse_get_metrics_history',
);
expect(screen.queryByTestId('context-tool-group')).not.toBeInTheDocument();
});
it('keeps action tools visible as individual transcript rows', () => {
@@ -1569,47 +1575,7 @@ describe('MessageItem', () => {
expect(screen.getAllByTestId('tool-execution-block')).toHaveLength(2);
});
it('shows "Context used" with unique tool summaries when context checks are not grouped', () => {
const events: StreamDisplayEvent[] = [
{
type: 'tool',
tool: {
name: 'pulse_read',
input: '{"action":"exec","target_host":"current_resource","command":"ls /dev | wc -l"}',
output: 'ok',
success: true,
},
},
{ type: 'content', content: 'I checked the device node count.' },
{
type: 'tool',
tool: {
name: 'pulse_get_metrics',
input: '{"action":"history","resource":"vm-101"}',
output: 'ok',
success: true,
},
},
];
render(() => (
<MessageItem
message={makeMessage({
role: 'assistant',
streamEvents: events,
isStreaming: false,
})}
{...makeHandlers()}
/>
));
expect(screen.getByText('Context used')).toBeInTheDocument();
expect(screen.getByText('Inspect devices on current resource')).toBeInTheDocument();
expect(screen.getByText('history')).toBeInTheDocument();
expect(screen.queryByText('read')).not.toBeInTheDocument();
});
it('deduplicates tool summaries in context footer', () => {
it('does not duplicate visible context rows with a context footer', () => {
const events: StreamDisplayEvent[] = [
{
type: 'tool',
@@ -1648,8 +1614,8 @@ describe('MessageItem', () => {
/>
));
expect(screen.getAllByText('Inspect devices on current resource')).toHaveLength(1);
expect(screen.getByText('get metrics')).toBeInTheDocument();
expect(screen.getAllByTestId('tool-execution-block')).toHaveLength(3);
expect(screen.queryByText('Context used')).not.toBeInTheDocument();
});
it('does not show context tools section when streaming', () => {
@@ -1691,7 +1657,7 @@ describe('MessageItem', () => {
expect(screen.queryByText('Context used')).not.toBeInTheDocument();
});
it('formats tool names correctly (strips pulse_ prefix and replaces underscores)', () => {
it('passes prefixed tool names through to visible tool rows', () => {
const events: StreamDisplayEvent[] = [
{
type: 'tool',
@@ -1715,10 +1681,13 @@ describe('MessageItem', () => {
/>
));
expect(screen.getByText('get container status')).toBeInTheDocument();
expect(screen.getByTestId('tool-execution-block')).toHaveAttribute(
'data-tool-name',
'pulse_get_container_status',
);
});
it('handles tool names without pulse_ prefix', () => {
it('passes tool names without pulse_ prefix through to visible tool rows', () => {
const events: StreamDisplayEvent[] = [
{
type: 'tool',
@@ -1737,7 +1706,10 @@ describe('MessageItem', () => {
/>
));
expect(screen.getByText('run command')).toBeInTheDocument();
expect(screen.getByTestId('tool-execution-block')).toHaveAttribute(
'data-tool-name',
'run_command',
);
});
});
@@ -2860,7 +2860,7 @@ class SubsystemLookupTest(unittest.TestCase):
{
"heading": "## Shared Boundaries",
"path": "internal/api/access_control_handlers.go",
"line": 398,
"line": 404,
"heading_line": 117,
}
],