mirror of
https://github.com/davidkaya/aryx.git
synced 2026-08-07 04:08:45 +02:00
feat: redesign activity panel UX with grouped timeline and verb-based labels
Replace the flat, repetitive 'Agent used X' activity list with a structured narrative timeline: - Group consecutive same-tool calls into collapsible rows with context (e.g. 'Viewed 4 files' with file names listed below) - Use verb-based labels with arguments: 'Viewed ChatPane.tsx:148-250', 'Searched for tool-call', 'Edited runTimeline.ts' - Promote report_intent events to phase dividers that segment the timeline into labeled stages of work - Show latest intent text in the collapsed header summary - Use category-specific icons (Eye, Search, Pencil, Terminal, etc.) instead of the universal wrench - Render thinking steps as quoted blocks with 'N more' toggle for consecutive groups All data was already available in RunTimelineEventRecord.toolArguments; this change surfaces it prominently instead of hiding it behind expandable detail panels. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
@@ -0,0 +1,244 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
|
||||
import {
|
||||
buildActivityStream,
|
||||
groupActivityStream,
|
||||
extractLatestIntent,
|
||||
generateActivitySummary,
|
||||
type ActivityStreamItem,
|
||||
} from '@renderer/lib/activityGrouping';
|
||||
import type { RunTimelineEventRecord } from '@shared/domain/runTimeline';
|
||||
import type { ChatMessageRecord } from '@shared/domain/session';
|
||||
|
||||
function createEvent(overrides?: Partial<RunTimelineEventRecord>): RunTimelineEventRecord {
|
||||
return {
|
||||
id: `evt-${Math.random().toString(36).slice(2, 8)}`,
|
||||
kind: 'tool-call',
|
||||
occurredAt: '2026-04-01T12:00:00.000Z',
|
||||
status: 'completed',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function createThinkingMessage(content: string, overrides?: Partial<ChatMessageRecord>): ChatMessageRecord {
|
||||
return {
|
||||
id: `msg-${Math.random().toString(36).slice(2, 8)}`,
|
||||
role: 'assistant',
|
||||
content,
|
||||
createdAt: '2026-04-01T12:00:00.000Z',
|
||||
messageKind: 'thinking',
|
||||
...overrides,
|
||||
} as ChatMessageRecord;
|
||||
}
|
||||
|
||||
/* ── buildActivityStream ───────────────────────────────────── */
|
||||
|
||||
describe('buildActivityStream', () => {
|
||||
test('merges thinking messages and events into chronological order', () => {
|
||||
const msgs = [createThinkingMessage('think1', { createdAt: '2026-04-01T12:00:02.000Z' })];
|
||||
const events = [
|
||||
createEvent({ id: 'e1', occurredAt: '2026-04-01T12:00:01.000Z' }),
|
||||
createEvent({ id: 'e2', occurredAt: '2026-04-01T12:00:03.000Z' }),
|
||||
];
|
||||
const stream = buildActivityStream(msgs, events);
|
||||
expect(stream).toHaveLength(3);
|
||||
expect(stream[0].kind).toBe('timeline-event');
|
||||
expect(stream[1].kind).toBe('thinking-step');
|
||||
expect(stream[2].kind).toBe('timeline-event');
|
||||
});
|
||||
|
||||
test('skips run-started and thinking event kinds', () => {
|
||||
const events = [
|
||||
createEvent({ id: 'e1', kind: 'run-started' }),
|
||||
createEvent({ id: 'e2', kind: 'thinking' }),
|
||||
createEvent({ id: 'e3', kind: 'tool-call', toolName: 'view' }),
|
||||
];
|
||||
const stream = buildActivityStream([], events);
|
||||
expect(stream).toHaveLength(1);
|
||||
expect((stream[0] as { kind: 'timeline-event'; event: RunTimelineEventRecord }).event.id).toBe('e3');
|
||||
});
|
||||
});
|
||||
|
||||
/* ── groupActivityStream ───────────────────────────────────── */
|
||||
|
||||
describe('groupActivityStream', () => {
|
||||
test('groups consecutive same-tool calls', () => {
|
||||
const items: ActivityStreamItem[] = [
|
||||
{ kind: 'timeline-event', event: createEvent({ id: 'e1', toolName: 'view' }) },
|
||||
{ kind: 'timeline-event', event: createEvent({ id: 'e2', toolName: 'view' }) },
|
||||
{ kind: 'timeline-event', event: createEvent({ id: 'e3', toolName: 'view' }) },
|
||||
];
|
||||
const grouped = groupActivityStream(items);
|
||||
expect(grouped).toHaveLength(1);
|
||||
expect(grouped[0].kind).toBe('tool-group');
|
||||
if (grouped[0].kind === 'tool-group') {
|
||||
expect(grouped[0].toolName).toBe('view');
|
||||
expect(grouped[0].events).toHaveLength(3);
|
||||
}
|
||||
});
|
||||
|
||||
test('does not group non-consecutive same-tool calls', () => {
|
||||
const items: ActivityStreamItem[] = [
|
||||
{ kind: 'timeline-event', event: createEvent({ id: 'e1', toolName: 'view' }) },
|
||||
{ kind: 'timeline-event', event: createEvent({ id: 'e2', toolName: 'grep' }) },
|
||||
{ kind: 'timeline-event', event: createEvent({ id: 'e3', toolName: 'view' }) },
|
||||
];
|
||||
const grouped = groupActivityStream(items);
|
||||
expect(grouped).toHaveLength(3);
|
||||
expect(grouped[0].kind).toBe('single-event');
|
||||
expect(grouped[1].kind).toBe('single-event');
|
||||
expect(grouped[2].kind).toBe('single-event');
|
||||
});
|
||||
|
||||
test('converts report_intent to intent-divider', () => {
|
||||
const items: ActivityStreamItem[] = [
|
||||
{
|
||||
kind: 'timeline-event',
|
||||
event: createEvent({
|
||||
id: 'e1',
|
||||
toolName: 'report_intent',
|
||||
toolArguments: { intent: 'Exploring codebase' },
|
||||
}),
|
||||
},
|
||||
];
|
||||
const grouped = groupActivityStream(items);
|
||||
expect(grouped).toHaveLength(1);
|
||||
expect(grouped[0].kind).toBe('intent-divider');
|
||||
if (grouped[0].kind === 'intent-divider') {
|
||||
expect(grouped[0].intentText).toBe('Exploring codebase');
|
||||
}
|
||||
});
|
||||
|
||||
test('skips report_intent with empty intent text', () => {
|
||||
const items: ActivityStreamItem[] = [
|
||||
{
|
||||
kind: 'timeline-event',
|
||||
event: createEvent({
|
||||
id: 'e1',
|
||||
toolName: 'report_intent',
|
||||
toolArguments: { intent: '' },
|
||||
}),
|
||||
},
|
||||
];
|
||||
const grouped = groupActivityStream(items);
|
||||
expect(grouped).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('groups consecutive thinking steps', () => {
|
||||
const items: ActivityStreamItem[] = [
|
||||
{ kind: 'thinking-step', message: createThinkingMessage('thought 1') },
|
||||
{ kind: 'thinking-step', message: createThinkingMessage('thought 2') },
|
||||
{ kind: 'thinking-step', message: createThinkingMessage('thought 3') },
|
||||
];
|
||||
const grouped = groupActivityStream(items);
|
||||
expect(grouped).toHaveLength(1);
|
||||
expect(grouped[0].kind).toBe('thinking-group');
|
||||
if (grouped[0].kind === 'thinking-group') {
|
||||
expect(grouped[0].messages).toHaveLength(3);
|
||||
}
|
||||
});
|
||||
|
||||
test('keeps single thinking step as single-thinking', () => {
|
||||
const items: ActivityStreamItem[] = [
|
||||
{ kind: 'thinking-step', message: createThinkingMessage('solo thought') },
|
||||
];
|
||||
const grouped = groupActivityStream(items);
|
||||
expect(grouped).toHaveLength(1);
|
||||
expect(grouped[0].kind).toBe('single-thinking');
|
||||
});
|
||||
|
||||
test('produces a mixed timeline with all item kinds', () => {
|
||||
const items: ActivityStreamItem[] = [
|
||||
{
|
||||
kind: 'timeline-event',
|
||||
event: createEvent({ id: 'intent', toolName: 'report_intent', toolArguments: { intent: 'Phase 1' } }),
|
||||
},
|
||||
{ kind: 'timeline-event', event: createEvent({ id: 'v1', toolName: 'view' }) },
|
||||
{ kind: 'timeline-event', event: createEvent({ id: 'v2', toolName: 'view' }) },
|
||||
{ kind: 'thinking-step', message: createThinkingMessage('thinking...') },
|
||||
{ kind: 'timeline-event', event: createEvent({ id: 'g1', toolName: 'grep' }) },
|
||||
{
|
||||
kind: 'timeline-event',
|
||||
event: createEvent({ id: 'intent2', toolName: 'report_intent', toolArguments: { intent: 'Phase 2' } }),
|
||||
},
|
||||
{ kind: 'timeline-event', event: createEvent({ id: 'e1', toolName: 'edit' }) },
|
||||
];
|
||||
const grouped = groupActivityStream(items);
|
||||
expect(grouped.map((g) => g.kind)).toEqual([
|
||||
'intent-divider',
|
||||
'tool-group',
|
||||
'single-thinking',
|
||||
'single-event',
|
||||
'intent-divider',
|
||||
'single-event',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
/* ── extractLatestIntent ───────────────────────────────────── */
|
||||
|
||||
describe('extractLatestIntent', () => {
|
||||
test('returns the intent from the last report_intent event', () => {
|
||||
const events = [
|
||||
createEvent({ id: 'e1', toolName: 'report_intent', toolArguments: { intent: 'Phase 1' } }),
|
||||
createEvent({ id: 'e2', toolName: 'view' }),
|
||||
createEvent({ id: 'e3', toolName: 'report_intent', toolArguments: { intent: 'Phase 2' } }),
|
||||
];
|
||||
expect(extractLatestIntent(events)).toBe('Phase 2');
|
||||
});
|
||||
|
||||
test('returns undefined when no report_intent events exist', () => {
|
||||
const events = [
|
||||
createEvent({ id: 'e1', toolName: 'view' }),
|
||||
createEvent({ id: 'e2', toolName: 'grep' }),
|
||||
];
|
||||
expect(extractLatestIntent(events)).toBeUndefined();
|
||||
});
|
||||
|
||||
test('skips empty intent values', () => {
|
||||
const events = [
|
||||
createEvent({ id: 'e1', toolName: 'report_intent', toolArguments: { intent: 'Valid' } }),
|
||||
createEvent({ id: 'e2', toolName: 'report_intent', toolArguments: { intent: ' ' } }),
|
||||
];
|
||||
expect(extractLatestIntent(events)).toBe('Valid');
|
||||
});
|
||||
});
|
||||
|
||||
/* ── generateActivitySummary ───────────────────────────────── */
|
||||
|
||||
describe('generateActivitySummary', () => {
|
||||
test('generates summary from tool mix', () => {
|
||||
const events = [
|
||||
createEvent({ toolName: 'grep' }),
|
||||
createEvent({ toolName: 'grep' }),
|
||||
createEvent({ toolName: 'view' }),
|
||||
createEvent({ toolName: 'view' }),
|
||||
createEvent({ toolName: 'view' }),
|
||||
createEvent({ toolName: 'edit' }),
|
||||
];
|
||||
const result = generateActivitySummary(events);
|
||||
expect(result).toBe('Searched 2 patterns, viewed 3 files, edited 1 file');
|
||||
});
|
||||
|
||||
test('excludes report_intent from counts', () => {
|
||||
const events = [
|
||||
createEvent({ toolName: 'report_intent' }),
|
||||
createEvent({ toolName: 'view' }),
|
||||
];
|
||||
const result = generateActivitySummary(events);
|
||||
expect(result).toBe('Viewed 1 file');
|
||||
});
|
||||
|
||||
test('returns undefined for empty events', () => {
|
||||
expect(generateActivitySummary([])).toBeUndefined();
|
||||
});
|
||||
|
||||
test('falls back to total count for unknown tools', () => {
|
||||
const events = [
|
||||
createEvent({ toolName: 'custom_tool' }),
|
||||
createEvent({ toolName: 'custom_tool' }),
|
||||
];
|
||||
const result = generateActivitySummary(events);
|
||||
expect(result).toBe('2 actions');
|
||||
});
|
||||
});
|
||||
@@ -67,7 +67,7 @@ describe('run timeline formatting', () => {
|
||||
kind: 'tool-call',
|
||||
agentName: 'Writer',
|
||||
toolName: 'file_search',
|
||||
}))).toBe('Writer used file_search');
|
||||
}))).toBe('Used file_search');
|
||||
expect(formatEventLabel(createEvent({
|
||||
kind: 'approval',
|
||||
status: 'running',
|
||||
|
||||
@@ -4,6 +4,9 @@ import {
|
||||
formatToolCallSummary,
|
||||
formatToolArgumentValue,
|
||||
getDisplayableArguments,
|
||||
formatToolCallPrimaryLabel,
|
||||
formatToolGroupLabel,
|
||||
extractToolCallSnippet,
|
||||
} from '@renderer/lib/toolCallSummary';
|
||||
|
||||
describe('formatToolCallSummary', () => {
|
||||
@@ -171,3 +174,106 @@ describe('getDisplayableArguments', () => {
|
||||
expect(result).toEqual([['query', 'INSERT ...']]);
|
||||
});
|
||||
});
|
||||
|
||||
/* ── formatToolCallPrimaryLabel ────────────────────────────── */
|
||||
|
||||
describe('formatToolCallPrimaryLabel', () => {
|
||||
test('produces verb-based label for view with path', () => {
|
||||
expect(formatToolCallPrimaryLabel('view', { path: '/src/components/ChatPane.tsx' }))
|
||||
.toBe('Viewed `ChatPane.tsx`');
|
||||
});
|
||||
|
||||
test('includes view range', () => {
|
||||
expect(formatToolCallPrimaryLabel('view', { path: '/src/index.ts', view_range: [10, 25] }))
|
||||
.toBe('Viewed `index.ts:10-25`');
|
||||
});
|
||||
|
||||
test('produces verb-based label for edit', () => {
|
||||
expect(formatToolCallPrimaryLabel('edit', { path: '/src/utils.ts', old_str: 'foo' }))
|
||||
.toBe('Edited `utils.ts`');
|
||||
});
|
||||
|
||||
test('produces verb-based label for create', () => {
|
||||
expect(formatToolCallPrimaryLabel('create', { path: '/new-file.ts' }))
|
||||
.toBe('Created `new-file.ts`');
|
||||
});
|
||||
|
||||
test('produces verb-based label for grep', () => {
|
||||
expect(formatToolCallPrimaryLabel('grep', { pattern: 'TODO', path: '/src' }))
|
||||
.toBe('Searched for `TODO`');
|
||||
});
|
||||
|
||||
test('produces verb-based label for powershell', () => {
|
||||
expect(formatToolCallPrimaryLabel('powershell', { command: 'npm run build' }))
|
||||
.toBe('Ran `npm run build`');
|
||||
});
|
||||
|
||||
test('produces verb-based label for task', () => {
|
||||
expect(formatToolCallPrimaryLabel('task', { description: 'Explore codebase' }))
|
||||
.toBe('Launched agent: Explore codebase');
|
||||
});
|
||||
|
||||
test('produces verb-based label for web_fetch with hostname', () => {
|
||||
expect(formatToolCallPrimaryLabel('web_fetch', { url: 'https://example.com/page' }))
|
||||
.toBe('Fetched `example.com`');
|
||||
});
|
||||
|
||||
test('produces verb-based label for sql', () => {
|
||||
expect(formatToolCallPrimaryLabel('sql', { description: 'Insert todos', query: 'INSERT ...' }))
|
||||
.toBe('SQL: Insert todos');
|
||||
});
|
||||
|
||||
test('handles GitHub tools', () => {
|
||||
const result = formatToolCallPrimaryLabel('github-mcp-server-search_code', { query: 'auth' });
|
||||
expect(result).toContain('search code');
|
||||
expect(result).toContain('auth');
|
||||
});
|
||||
|
||||
test('falls back to "Used <tool>" for unknown tools', () => {
|
||||
expect(formatToolCallPrimaryLabel('custom_tool', { data: 'test' }))
|
||||
.toBe('Used custom_tool: test');
|
||||
});
|
||||
|
||||
test('returns "Tool call" for undefined toolName', () => {
|
||||
expect(formatToolCallPrimaryLabel(undefined, {})).toBe('Tool call');
|
||||
});
|
||||
});
|
||||
|
||||
/* ── formatToolGroupLabel ─────────────────────────────────── */
|
||||
|
||||
describe('formatToolGroupLabel', () => {
|
||||
test('pluralizes correctly for view', () => {
|
||||
expect(formatToolGroupLabel('view', 1)).toBe('Viewed 1 file');
|
||||
expect(formatToolGroupLabel('view', 4)).toBe('Viewed 4 files');
|
||||
});
|
||||
|
||||
test('pluralizes correctly for grep', () => {
|
||||
expect(formatToolGroupLabel('grep', 1)).toBe('Searched 1 pattern');
|
||||
expect(formatToolGroupLabel('grep', 3)).toBe('Searched 3 patterns');
|
||||
});
|
||||
|
||||
test('uses generic format for unknown tools', () => {
|
||||
expect(formatToolGroupLabel('custom', 2)).toBe('2 custom calls');
|
||||
});
|
||||
});
|
||||
|
||||
/* ── extractToolCallSnippet ───────────────────────────────── */
|
||||
|
||||
describe('extractToolCallSnippet', () => {
|
||||
test('extracts file name for view', () => {
|
||||
expect(extractToolCallSnippet('view', { path: '/src/components/ChatPane.tsx' }))
|
||||
.toBe('ChatPane.tsx');
|
||||
});
|
||||
|
||||
test('extracts pattern for grep', () => {
|
||||
expect(extractToolCallSnippet('grep', { pattern: 'TODO' })).toBe('TODO');
|
||||
});
|
||||
|
||||
test('extracts command for powershell', () => {
|
||||
expect(extractToolCallSnippet('powershell', { command: 'npm test' })).toBe('npm test');
|
||||
});
|
||||
|
||||
test('returns undefined for unknown tool', () => {
|
||||
expect(extractToolCallSnippet('custom', { foo: 'bar' })).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user