mirror of
https://github.com/davidkaya/aryx.git
synced 2026-08-10 13:48:44 +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:
@@ -6,8 +6,17 @@ import {
|
|||||||
CheckCircle2,
|
CheckCircle2,
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
|
Database,
|
||||||
|
Eye,
|
||||||
|
ExternalLink,
|
||||||
|
FileSearch,
|
||||||
|
Github,
|
||||||
MessageSquare,
|
MessageSquare,
|
||||||
|
Pencil,
|
||||||
|
Search,
|
||||||
ShieldAlert,
|
ShieldAlert,
|
||||||
|
Terminal,
|
||||||
|
Users,
|
||||||
Wrench,
|
Wrench,
|
||||||
XCircle,
|
XCircle,
|
||||||
Zap,
|
Zap,
|
||||||
@@ -18,21 +27,12 @@ import { FileChangePreview } from '@renderer/components/chat/FileChangePreview';
|
|||||||
import { ToolCallDetailPanel } from '@renderer/components/chat/ToolCallDetailPanel';
|
import { ToolCallDetailPanel } from '@renderer/components/chat/ToolCallDetailPanel';
|
||||||
import { RunChangeSummaryCard } from '@renderer/components/chat/RunChangeSummaryCard';
|
import { RunChangeSummaryCard } from '@renderer/components/chat/RunChangeSummaryCard';
|
||||||
import { formatEventLabel, truncateContent, filterEventsByAgent, summarizeActivity, type ActivitySummary } from '@renderer/lib/runTimelineFormatting';
|
import { formatEventLabel, truncateContent, filterEventsByAgent, summarizeActivity, type ActivitySummary } from '@renderer/lib/runTimelineFormatting';
|
||||||
|
import { formatToolGroupLabel, extractToolCallSnippet, formatToolCallPrimaryLabel } from '@renderer/lib/toolCallSummary';
|
||||||
|
import { buildActivityStream, groupActivityStream, extractLatestIntent, generateActivitySummary, type GroupedActivityItem } from '@renderer/lib/activityGrouping';
|
||||||
import type { ChatMessageRecord } from '@shared/domain/session';
|
import type { ChatMessageRecord } from '@shared/domain/session';
|
||||||
import type { ProjectGitFileReference } from '@shared/domain/project';
|
import type { ProjectGitFileReference } from '@shared/domain/project';
|
||||||
import type { RunTimelineEventRecord, SessionRunRecord } from '@shared/domain/runTimeline';
|
import type { RunTimelineEventRecord, SessionRunRecord } from '@shared/domain/runTimeline';
|
||||||
|
|
||||||
/* ── Types ─────────────────────────────────────────────────── */
|
|
||||||
|
|
||||||
/** A unified activity stream item, merging chat thinking messages
|
|
||||||
* and run timeline events into a single chronological list. */
|
|
||||||
type ActivityStreamItem =
|
|
||||||
| { kind: 'thinking-step'; message: ChatMessageRecord }
|
|
||||||
| { kind: 'timeline-event'; event: RunTimelineEventRecord };
|
|
||||||
|
|
||||||
/** Events to skip in the inline panel (redundant or implicit). */
|
|
||||||
const SKIP_EVENT_KINDS = new Set(['run-started', 'thinking']);
|
|
||||||
|
|
||||||
/* ── Props ─────────────────────────────────────────────────── */
|
/* ── Props ─────────────────────────────────────────────────── */
|
||||||
|
|
||||||
export interface TurnActivityPanelProps {
|
export interface TurnActivityPanelProps {
|
||||||
@@ -41,9 +41,7 @@ export interface TurnActivityPanelProps {
|
|||||||
isActive: boolean;
|
isActive: boolean;
|
||||||
turnStartedAt?: string;
|
turnStartedAt?: string;
|
||||||
sessionId: string;
|
sessionId: string;
|
||||||
/** Agent names in this turn group — used to scope run events in multi-agent runs. */
|
|
||||||
agentNames?: ReadonlySet<string>;
|
agentNames?: ReadonlySet<string>;
|
||||||
/** True when this panel is the last one sharing a given run (controls git summary / discard). */
|
|
||||||
isLastRunPanel?: boolean;
|
isLastRunPanel?: boolean;
|
||||||
onDiscard?: (sessionId: string, runId: string, files?: ProjectGitFileReference[]) => Promise<unknown>;
|
onDiscard?: (sessionId: string, runId: string, files?: ProjectGitFileReference[]) => Promise<unknown>;
|
||||||
onOpenCommitComposer?: () => void;
|
onOpenCommitComposer?: () => void;
|
||||||
@@ -58,35 +56,10 @@ function truncatePreview(text: string, maxLength: number): string {
|
|||||||
return `${cleaned.slice(0, maxLength)}…`;
|
return `${cleaned.slice(0, maxLength)}…`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildActivityStream(
|
|
||||||
thinkingMessages: ChatMessageRecord[],
|
|
||||||
events: readonly RunTimelineEventRecord[],
|
|
||||||
): ActivityStreamItem[] {
|
|
||||||
const items: ActivityStreamItem[] = [];
|
|
||||||
|
|
||||||
for (const msg of thinkingMessages) {
|
|
||||||
items.push({ kind: 'thinking-step', message: msg });
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const event of events) {
|
|
||||||
if (SKIP_EVENT_KINDS.has(event.kind)) continue;
|
|
||||||
items.push({ kind: 'timeline-event', event });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sort chronologically by timestamp
|
|
||||||
items.sort((a, b) => {
|
|
||||||
const tsA = a.kind === 'thinking-step' ? a.message.createdAt : a.event.occurredAt;
|
|
||||||
const tsB = b.kind === 'thinking-step' ? b.message.createdAt : b.event.occurredAt;
|
|
||||||
return new Date(tsA).getTime() - new Date(tsB).getTime();
|
|
||||||
});
|
|
||||||
|
|
||||||
return items;
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatSummaryParts(summary: ActivitySummary): string[] {
|
function formatSummaryParts(summary: ActivitySummary): string[] {
|
||||||
const parts: string[] = [];
|
const parts: string[] = [];
|
||||||
if (summary.toolCalls > 0) {
|
if (summary.toolCalls > 0) {
|
||||||
parts.push(`${summary.toolCalls} tool ${summary.toolCalls === 1 ? 'call' : 'calls'}`);
|
parts.push(`${summary.toolCalls} ${summary.toolCalls === 1 ? 'action' : 'actions'}`);
|
||||||
}
|
}
|
||||||
if (summary.handoffs > 0) {
|
if (summary.handoffs > 0) {
|
||||||
parts.push(`${summary.handoffs} ${summary.handoffs === 1 ? 'handoff' : 'handoffs'}`);
|
parts.push(`${summary.handoffs} ${summary.handoffs === 1 ? 'handoff' : 'handoffs'}`);
|
||||||
@@ -94,20 +67,57 @@ function formatSummaryParts(summary: ActivitySummary): string[] {
|
|||||||
if (summary.approvals > 0) {
|
if (summary.approvals > 0) {
|
||||||
parts.push(`${summary.approvals} ${summary.approvals === 1 ? 'approval' : 'approvals'}`);
|
parts.push(`${summary.approvals} ${summary.approvals === 1 ? 'approval' : 'approvals'}`);
|
||||||
}
|
}
|
||||||
if (summary.thinkingSteps > 0) {
|
|
||||||
parts.push(`${summary.thinkingSteps} thinking ${summary.thinkingSteps === 1 ? 'step' : 'steps'}`);
|
|
||||||
}
|
|
||||||
return parts;
|
return parts;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Event icon ────────────────────────────────────────────── */
|
/* ── Tool-category icon ────────────────────────────────────── */
|
||||||
|
|
||||||
function ActivityEventIcon({ kind, status }: { kind: RunTimelineEventRecord['kind']; status: RunTimelineEventRecord['status'] }) {
|
function ToolCategoryIcon({ toolName, className }: { toolName?: string; className?: string }) {
|
||||||
|
const base = className ?? 'size-3 shrink-0';
|
||||||
|
|
||||||
|
if (!toolName) return <Wrench className={`${base} text-[var(--color-text-muted)]`} />;
|
||||||
|
|
||||||
|
if (toolName.startsWith('github-')) {
|
||||||
|
return <Github className={`${base} text-[var(--color-text-secondary)]`} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (toolName) {
|
||||||
|
case 'view':
|
||||||
|
return <Eye className={`${base} text-[var(--color-accent-sky)]`} />;
|
||||||
|
case 'grep':
|
||||||
|
case 'glob':
|
||||||
|
return <Search className={`${base} text-[var(--color-accent-purple)]`} />;
|
||||||
|
case 'lsp':
|
||||||
|
return <FileSearch className={`${base} text-[var(--color-accent-purple)]`} />;
|
||||||
|
case 'edit':
|
||||||
|
case 'create':
|
||||||
|
return <Pencil className={`${base} text-[var(--color-status-warning)]`} />;
|
||||||
|
case 'powershell':
|
||||||
|
return <Terminal className={`${base} text-[var(--color-text-secondary)]`} />;
|
||||||
|
case 'web_fetch':
|
||||||
|
case 'web_search':
|
||||||
|
return <ExternalLink className={`${base} text-[var(--color-accent-sky)]`} />;
|
||||||
|
case 'sql':
|
||||||
|
return <Database className={`${base} text-[var(--color-accent-purple)]`} />;
|
||||||
|
case 'task':
|
||||||
|
return <Users className={`${base} text-[var(--color-accent-sky)]`} />;
|
||||||
|
default:
|
||||||
|
return <Wrench className={`${base} text-[var(--color-text-muted)]`} />;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Event icon (for non-tool events) ──────────────────────── */
|
||||||
|
|
||||||
|
function ActivityEventIcon({ kind, status, toolName }: {
|
||||||
|
kind: RunTimelineEventRecord['kind'];
|
||||||
|
status: RunTimelineEventRecord['status'];
|
||||||
|
toolName?: string;
|
||||||
|
}) {
|
||||||
const base = 'size-3 shrink-0';
|
const base = 'size-3 shrink-0';
|
||||||
|
|
||||||
switch (kind) {
|
switch (kind) {
|
||||||
case 'tool-call':
|
case 'tool-call':
|
||||||
return <Wrench className={`${base} text-[var(--color-accent-purple)]`} />;
|
return <ToolCategoryIcon toolName={toolName} className={base} />;
|
||||||
case 'approval':
|
case 'approval':
|
||||||
return (
|
return (
|
||||||
<ShieldAlert
|
<ShieldAlert
|
||||||
@@ -135,7 +145,7 @@ function ActivityEventIcon({ kind, status }: { kind: RunTimelineEventRecord['kin
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Activity event row ────────────────────────────────────── */
|
/* ── Single event row ──────────────────────────────────────── */
|
||||||
|
|
||||||
function ActivityTimelineEventRow({ event }: { event: RunTimelineEventRecord }) {
|
function ActivityTimelineEventRow({ event }: { event: RunTimelineEventRecord }) {
|
||||||
const label = formatEventLabel(event);
|
const label = formatEventLabel(event);
|
||||||
@@ -144,7 +154,7 @@ function ActivityTimelineEventRow({ event }: { event: RunTimelineEventRecord })
|
|||||||
return (
|
return (
|
||||||
<div className="turn-activity-row flex gap-2 py-1">
|
<div className="turn-activity-row flex gap-2 py-1">
|
||||||
<div className="mt-0.5 flex shrink-0 items-start">
|
<div className="mt-0.5 flex shrink-0 items-start">
|
||||||
<ActivityEventIcon kind={event.kind} status={event.status} />
|
<ActivityEventIcon kind={event.kind} status={event.status} toolName={event.toolName} />
|
||||||
</div>
|
</div>
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<span className={`text-[12px] font-medium ${isTerminal ? 'text-[var(--color-text-muted)]' : 'text-[var(--color-text-secondary)]'}`}>
|
<span className={`text-[12px] font-medium ${isTerminal ? 'text-[var(--color-text-muted)]' : 'text-[var(--color-text-secondary)]'}`}>
|
||||||
@@ -203,6 +213,106 @@ function ActivityTimelineEventRow({ event }: { event: RunTimelineEventRecord })
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Grouped tool-call row ─────────────────────────────────── */
|
||||||
|
|
||||||
|
function GroupedToolCallRow({ toolName, events }: { toolName: string; events: RunTimelineEventRecord[] }) {
|
||||||
|
const [expanded, setExpanded] = useState(false);
|
||||||
|
const label = formatToolGroupLabel(toolName, events.length);
|
||||||
|
|
||||||
|
const snippets = useMemo(
|
||||||
|
() => events.map((e) => extractToolCallSnippet(toolName, e.toolArguments)).filter(Boolean) as string[],
|
||||||
|
[toolName, events],
|
||||||
|
);
|
||||||
|
|
||||||
|
const hasFileChanges = events.some((e) => e.fileChanges && e.fileChanges.length > 0);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="turn-activity-row py-0.5">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="flex w-full items-start gap-2 py-1 text-left transition-colors hover:bg-[var(--color-surface-2)]/30 rounded px-1 -mx-1"
|
||||||
|
onClick={() => setExpanded((prev) => !prev)}
|
||||||
|
aria-expanded={expanded}
|
||||||
|
>
|
||||||
|
<div className="mt-0.5 flex shrink-0 items-start">
|
||||||
|
<ToolCategoryIcon toolName={toolName} />
|
||||||
|
</div>
|
||||||
|
<span className="min-w-0 flex-1 text-[12px] font-medium text-[var(--color-text-secondary)]">
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
<ChevronRight
|
||||||
|
className={`mt-0.5 size-3 shrink-0 text-[var(--color-text-muted)] transition-transform duration-150 ${
|
||||||
|
expanded ? 'rotate-90' : ''
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Collapsed preview: show snippets inline */}
|
||||||
|
{!expanded && snippets.length > 0 && (
|
||||||
|
<div className="ml-5 flex flex-wrap gap-x-2 gap-y-0.5 pb-0.5">
|
||||||
|
{snippets.slice(0, 6).map((s, i) => (
|
||||||
|
<span key={i} className="truncate font-mono text-[10px] text-[var(--color-text-muted)]">
|
||||||
|
{s}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
{snippets.length > 6 && (
|
||||||
|
<span className="text-[10px] text-[var(--color-text-muted)]">
|
||||||
|
+{snippets.length - 6} more
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Expanded: full per-event rows */}
|
||||||
|
{expanded && (
|
||||||
|
<div className="ml-5 border-l border-[var(--color-border)]/30 pl-2">
|
||||||
|
{events.map((event) => (
|
||||||
|
<div key={event.id} className="py-0.5">
|
||||||
|
<span className="text-[11px] text-[var(--color-text-secondary)]">
|
||||||
|
{formatToolCallPrimaryLabel(event.toolName, event.toolArguments)}
|
||||||
|
</span>
|
||||||
|
<ToolCallDetailPanel toolName={event.toolName} toolArguments={event.toolArguments} />
|
||||||
|
{event.fileChanges && event.fileChanges.length > 0 && (
|
||||||
|
<div className="mt-0.5">
|
||||||
|
<FileChangePreview fileChanges={event.fileChanges} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Aggregate file changes when collapsed */}
|
||||||
|
{!expanded && hasFileChanges && (
|
||||||
|
<div className="ml-5 mt-0.5">
|
||||||
|
{events
|
||||||
|
.filter((e) => e.fileChanges && e.fileChanges.length > 0)
|
||||||
|
.flatMap((e) => e.fileChanges!)
|
||||||
|
.length > 0 && (
|
||||||
|
<FileChangePreview
|
||||||
|
fileChanges={events.flatMap((e) => e.fileChanges ?? [])}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Intent divider ────────────────────────────────────────── */
|
||||||
|
|
||||||
|
function IntentDividerRow({ text }: { text: string }) {
|
||||||
|
return (
|
||||||
|
<div className="turn-activity-row flex items-center gap-2 py-1.5" role="separator">
|
||||||
|
<div className="h-px flex-1 bg-[var(--color-border)]/40" />
|
||||||
|
<span className="shrink-0 text-[10px] font-medium tracking-wide text-[var(--color-text-muted)]">
|
||||||
|
{text}
|
||||||
|
</span>
|
||||||
|
<div className="h-px flex-1 bg-[var(--color-border)]/40" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/* ── Thinking step row ─────────────────────────────────────── */
|
/* ── Thinking step row ─────────────────────────────────────── */
|
||||||
|
|
||||||
function ThinkingStepRow({ message }: { message: ChatMessageRecord }) {
|
function ThinkingStepRow({ message }: { message: ChatMessageRecord }) {
|
||||||
@@ -216,17 +326,61 @@ function ThinkingStepRow({ message }: { message: ChatMessageRecord }) {
|
|||||||
<Brain className="size-3 text-[var(--color-accent-purple)]" />
|
<Brain className="size-3 text-[var(--color-accent-purple)]" />
|
||||||
</div>
|
</div>
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
{message.authorName && (
|
<p className="border-l-2 border-[var(--color-accent-purple)]/20 pl-2 text-[11px] italic leading-snug text-[var(--color-text-muted)]">
|
||||||
<span className="mr-1.5 text-[12px] font-medium text-[var(--color-text-secondary)]">
|
"{preview}"
|
||||||
{message.authorName}
|
</p>
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
<span className="text-[12px] text-[var(--color-text-muted)]">{preview}</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Thinking group (multiple consecutive) ─────────────────── */
|
||||||
|
|
||||||
|
function ThinkingGroupRow({ messages }: { messages: ChatMessageRecord[] }) {
|
||||||
|
const [expanded, setExpanded] = useState(false);
|
||||||
|
|
||||||
|
const visibleMessages = messages.filter((m) => !m.pending || m.content);
|
||||||
|
if (visibleMessages.length === 0) return null;
|
||||||
|
|
||||||
|
const latest = visibleMessages[visibleMessages.length - 1];
|
||||||
|
const preview = truncatePreview(latest.content, 180);
|
||||||
|
const hiddenCount = visibleMessages.length - 1;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="turn-activity-row py-0.5">
|
||||||
|
<div className="flex gap-2 py-1">
|
||||||
|
<div className="mt-0.5 flex shrink-0 items-start">
|
||||||
|
<Brain className="size-3 text-[var(--color-accent-purple)]" />
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="border-l-2 border-[var(--color-accent-purple)]/20 pl-2 text-[11px] italic leading-snug text-[var(--color-text-muted)]">
|
||||||
|
"{preview}"
|
||||||
|
</p>
|
||||||
|
{hiddenCount > 0 && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="mt-0.5 pl-2 text-[10px] text-[var(--color-accent)] hover:underline"
|
||||||
|
onClick={() => setExpanded((prev) => !prev)}
|
||||||
|
>
|
||||||
|
{expanded ? 'Hide' : `${hiddenCount} earlier ${hiddenCount === 1 ? 'thought' : 'thoughts'}`}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{expanded && (
|
||||||
|
<div className="ml-5 space-y-0.5 border-l border-[var(--color-border)]/30 pl-2">
|
||||||
|
{visibleMessages.slice(0, -1).map((msg) => (
|
||||||
|
<p key={msg.id} className="text-[10px] italic leading-snug text-[var(--color-text-muted)]">
|
||||||
|
"{truncatePreview(msg.content, 140)}"
|
||||||
|
</p>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/* ── Active pulse dots ─────────────────────────────────────── */
|
/* ── Active pulse dots ─────────────────────────────────────── */
|
||||||
|
|
||||||
function ActivityPulse() {
|
function ActivityPulse() {
|
||||||
@@ -239,6 +393,23 @@ function ActivityPulse() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Grouped item renderer ─────────────────────────────────── */
|
||||||
|
|
||||||
|
function GroupedItemRow({ item }: { item: GroupedActivityItem }) {
|
||||||
|
switch (item.kind) {
|
||||||
|
case 'intent-divider':
|
||||||
|
return <IntentDividerRow text={item.intentText} />;
|
||||||
|
case 'single-event':
|
||||||
|
return <ActivityTimelineEventRow event={item.event} />;
|
||||||
|
case 'tool-group':
|
||||||
|
return <GroupedToolCallRow toolName={item.toolName} events={item.events} />;
|
||||||
|
case 'single-thinking':
|
||||||
|
return <ThinkingStepRow message={item.message} />;
|
||||||
|
case 'thinking-group':
|
||||||
|
return <ThinkingGroupRow messages={item.messages} />;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* ── Main component ────────────────────────────────────────── */
|
/* ── Main component ────────────────────────────────────────── */
|
||||||
|
|
||||||
export function TurnActivityPanel({
|
export function TurnActivityPanel({
|
||||||
@@ -255,8 +426,6 @@ export function TurnActivityPanel({
|
|||||||
const [expanded, setExpanded] = useState(false);
|
const [expanded, setExpanded] = useState(false);
|
||||||
const wasActiveRef = useRef(isActive);
|
const wasActiveRef = useRef(isActive);
|
||||||
|
|
||||||
// Auto-expand when the turn is active (run exists or thinking arrives).
|
|
||||||
// Auto-collapse once the turn finishes.
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isActive && (thinkingMessages.length > 0 || run)) {
|
if (isActive && (thinkingMessages.length > 0 || run)) {
|
||||||
setExpanded(true);
|
setExpanded(true);
|
||||||
@@ -268,25 +437,20 @@ export function TurnActivityPanel({
|
|||||||
|
|
||||||
const toggle = useCallback(() => setExpanded((prev) => !prev), []);
|
const toggle = useCallback(() => setExpanded((prev) => !prev), []);
|
||||||
|
|
||||||
// When the run is shared across multiple panels (multi-agent sequential),
|
|
||||||
// scope events to only those belonging to this panel's agents.
|
|
||||||
const scopedEvents = useMemo(
|
const scopedEvents = useMemo(
|
||||||
() => filterEventsByAgent(run?.events ?? [], agentNames),
|
() => filterEventsByAgent(run?.events ?? [], agentNames),
|
||||||
[run?.events, agentNames],
|
[run?.events, agentNames],
|
||||||
);
|
);
|
||||||
|
|
||||||
// Derive per-agent timing from the scoped events when agent names are set
|
|
||||||
// (multi-agent run). For single-agent runs, use the run-level start time.
|
|
||||||
const effectiveTurnStartedAt = useMemo(() => {
|
const effectiveTurnStartedAt = useMemo(() => {
|
||||||
if (!agentNames || agentNames.size === 0 || scopedEvents.length === 0) {
|
if (!agentNames || agentNames.size === 0 || scopedEvents.length === 0) {
|
||||||
return turnStartedAt;
|
return turnStartedAt;
|
||||||
}
|
}
|
||||||
// Use the earliest scoped event as the start time for this agent's panel.
|
|
||||||
let earliest = turnStartedAt;
|
let earliest = turnStartedAt;
|
||||||
for (const e of scopedEvents) {
|
for (const e of scopedEvents) {
|
||||||
if (!earliest || e.occurredAt < earliest) {
|
if (!earliest || e.occurredAt < earliest) {
|
||||||
earliest = e.occurredAt;
|
earliest = e.occurredAt;
|
||||||
break; // events are already in insertion order (chronological)
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return earliest;
|
return earliest;
|
||||||
@@ -302,12 +466,18 @@ export function TurnActivityPanel({
|
|||||||
[thinkingMessages, scopedEvents],
|
[thinkingMessages, scopedEvents],
|
||||||
);
|
);
|
||||||
|
|
||||||
const activityStream = useMemo(
|
const groupedItems = useMemo(() => {
|
||||||
() => buildActivityStream(thinkingMessages, scopedEvents),
|
const stream = buildActivityStream(thinkingMessages, scopedEvents);
|
||||||
[thinkingMessages, scopedEvents],
|
return groupActivityStream(stream);
|
||||||
|
}, [thinkingMessages, scopedEvents]);
|
||||||
|
|
||||||
|
// Extract intent text for the header
|
||||||
|
const intentText = useMemo(() => extractLatestIntent(scopedEvents), [scopedEvents]);
|
||||||
|
const fallbackSummary = useMemo(
|
||||||
|
() => !intentText ? generateActivitySummary(scopedEvents) : undefined,
|
||||||
|
[intentText, scopedEvents],
|
||||||
);
|
);
|
||||||
|
|
||||||
// Nothing to show — no thinking messages, no run, and not active
|
|
||||||
if (thinkingMessages.length === 0 && !run) {
|
if (thinkingMessages.length === 0 && !run) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -318,10 +488,8 @@ export function TurnActivityPanel({
|
|||||||
const isFailed = runStatus === 'error';
|
const isFailed = runStatus === 'error';
|
||||||
const isCancelled = runStatus === 'cancelled';
|
const isCancelled = runStatus === 'cancelled';
|
||||||
const isTerminated = isCompleted || isFailed || isCancelled;
|
const isTerminated = isCompleted || isFailed || isCancelled;
|
||||||
// Only show git summary and discard on the last panel for a given run
|
|
||||||
const showGitSummary = run && isTerminated && run.postRunGitSummary && onDiscard && (isLastRunPanel !== false);
|
const showGitSummary = run && isTerminated && run.postRunGitSummary && onDiscard && (isLastRunPanel !== false);
|
||||||
|
|
||||||
// Build the summary label
|
|
||||||
let summaryLabel: string;
|
let summaryLabel: string;
|
||||||
if (isActive) {
|
if (isActive) {
|
||||||
summaryLabel = 'Working';
|
summaryLabel = 'Working';
|
||||||
@@ -335,6 +503,8 @@ export function TurnActivityPanel({
|
|||||||
summaryLabel = 'Completed';
|
summaryLabel = 'Completed';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const headerDetail = intentText ?? fallbackSummary;
|
||||||
|
|
||||||
const statusColorClass = isFailed
|
const statusColorClass = isFailed
|
||||||
? 'text-[var(--color-status-error)]'
|
? 'text-[var(--color-status-error)]'
|
||||||
: isCancelled
|
: isCancelled
|
||||||
@@ -374,9 +544,17 @@ export function TurnActivityPanel({
|
|||||||
<span className={statusColorClass}>{summaryLabel}</span>
|
<span className={statusColorClass}>{summaryLabel}</span>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Intent / generated summary */}
|
||||||
|
{headerDetail && (
|
||||||
|
<span className="min-w-0 truncate text-[11px] text-[var(--color-text-muted)]">
|
||||||
|
{'· '}
|
||||||
|
{intentText ? `"${headerDetail}"` : headerDetail}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Inline counters */}
|
{/* Inline counters */}
|
||||||
{summaryParts.length > 0 && (
|
{summaryParts.length > 0 && (
|
||||||
<span className="font-mono text-[10px] text-[var(--color-text-muted)]">
|
<span className="shrink-0 font-mono text-[10px] text-[var(--color-text-muted)]">
|
||||||
{'· '}
|
{'· '}
|
||||||
{summaryParts.join(' · ')}
|
{summaryParts.join(' · ')}
|
||||||
</span>
|
</span>
|
||||||
@@ -389,16 +567,13 @@ export function TurnActivityPanel({
|
|||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* Expanded activity stream */}
|
{/* Expanded activity stream — grouped */}
|
||||||
{expanded && (
|
{expanded && (
|
||||||
<div className="border-t border-[var(--color-border)]/30 px-3 py-2">
|
<div className="border-t border-[var(--color-border)]/30 px-3 py-2">
|
||||||
<div className="space-y-0.5">
|
<div className="space-y-0.5">
|
||||||
{activityStream.map((item) => {
|
{groupedItems.map((item, index) => (
|
||||||
if (item.kind === 'thinking-step') {
|
<GroupedItemRow key={index} item={item} />
|
||||||
return <ThinkingStepRow key={item.message.id} message={item.message} />;
|
))}
|
||||||
}
|
|
||||||
return <ActivityTimelineEventRow key={item.event.id} event={item.event} />;
|
|
||||||
})}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Post-run git changes */}
|
{/* Post-run git changes */}
|
||||||
|
|||||||
@@ -0,0 +1,171 @@
|
|||||||
|
import type { ChatMessageRecord } from '@shared/domain/session';
|
||||||
|
import type { RunTimelineEventRecord } from '@shared/domain/runTimeline';
|
||||||
|
|
||||||
|
/* ── Input / output types ──────────────────────────────────── */
|
||||||
|
|
||||||
|
/** A flat activity stream item (the existing model). */
|
||||||
|
export type ActivityStreamItem =
|
||||||
|
| { kind: 'thinking-step'; message: ChatMessageRecord }
|
||||||
|
| { kind: 'timeline-event'; event: RunTimelineEventRecord };
|
||||||
|
|
||||||
|
/** A grouped timeline item ready for rendering. */
|
||||||
|
export type GroupedActivityItem =
|
||||||
|
| { kind: 'intent-divider'; intentText: string; event: RunTimelineEventRecord }
|
||||||
|
| { kind: 'single-event'; event: RunTimelineEventRecord }
|
||||||
|
| { kind: 'tool-group'; toolName: string; events: RunTimelineEventRecord[] }
|
||||||
|
| { kind: 'thinking-group'; messages: ChatMessageRecord[] }
|
||||||
|
| { kind: 'single-thinking'; message: ChatMessageRecord };
|
||||||
|
|
||||||
|
/* ── Events to skip in the inline panel ────────────────────── */
|
||||||
|
|
||||||
|
const SKIP_EVENT_KINDS = new Set(['run-started', 'thinking']);
|
||||||
|
|
||||||
|
/* ── Build the flat stream (moved from TurnActivityPanel) ──── */
|
||||||
|
|
||||||
|
export function buildActivityStream(
|
||||||
|
thinkingMessages: ChatMessageRecord[],
|
||||||
|
events: readonly RunTimelineEventRecord[],
|
||||||
|
): ActivityStreamItem[] {
|
||||||
|
const items: ActivityStreamItem[] = [];
|
||||||
|
|
||||||
|
for (const msg of thinkingMessages) {
|
||||||
|
items.push({ kind: 'thinking-step', message: msg });
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const event of events) {
|
||||||
|
if (SKIP_EVENT_KINDS.has(event.kind)) continue;
|
||||||
|
items.push({ kind: 'timeline-event', event });
|
||||||
|
}
|
||||||
|
|
||||||
|
items.sort((a, b) => {
|
||||||
|
const tsA = a.kind === 'thinking-step' ? a.message.createdAt : a.event.occurredAt;
|
||||||
|
const tsB = b.kind === 'thinking-step' ? b.message.createdAt : b.event.occurredAt;
|
||||||
|
return new Date(tsA).getTime() - new Date(tsB).getTime();
|
||||||
|
});
|
||||||
|
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Group the flat stream into displayable chunks ─────────── */
|
||||||
|
|
||||||
|
export function groupActivityStream(items: ActivityStreamItem[]): GroupedActivityItem[] {
|
||||||
|
const result: GroupedActivityItem[] = [];
|
||||||
|
|
||||||
|
let i = 0;
|
||||||
|
while (i < items.length) {
|
||||||
|
const item = items[i];
|
||||||
|
|
||||||
|
// ── Thinking steps: group consecutive runs ──────────────
|
||||||
|
if (item.kind === 'thinking-step') {
|
||||||
|
const group: ChatMessageRecord[] = [item.message];
|
||||||
|
let j = i + 1;
|
||||||
|
while (j < items.length && items[j].kind === 'thinking-step') {
|
||||||
|
group.push((items[j] as { kind: 'thinking-step'; message: ChatMessageRecord }).message);
|
||||||
|
j++;
|
||||||
|
}
|
||||||
|
if (group.length === 1) {
|
||||||
|
result.push({ kind: 'single-thinking', message: group[0] });
|
||||||
|
} else {
|
||||||
|
result.push({ kind: 'thinking-group', messages: group });
|
||||||
|
}
|
||||||
|
i = j;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Timeline events ─────────────────────────────────────
|
||||||
|
const event = item.event;
|
||||||
|
|
||||||
|
// report_intent → phase divider
|
||||||
|
if (event.kind === 'tool-call' && event.toolName === 'report_intent') {
|
||||||
|
const intentText =
|
||||||
|
typeof event.toolArguments?.intent === 'string'
|
||||||
|
? event.toolArguments.intent
|
||||||
|
: '';
|
||||||
|
if (intentText) {
|
||||||
|
result.push({ kind: 'intent-divider', intentText, event });
|
||||||
|
}
|
||||||
|
// Skip silently when intent text is empty
|
||||||
|
i++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tool calls: group consecutive calls of the same tool
|
||||||
|
if (event.kind === 'tool-call' && event.toolName) {
|
||||||
|
const toolName = event.toolName;
|
||||||
|
const group: RunTimelineEventRecord[] = [event];
|
||||||
|
let j = i + 1;
|
||||||
|
while (j < items.length) {
|
||||||
|
const next = items[j];
|
||||||
|
if (next.kind !== 'timeline-event') break;
|
||||||
|
if (next.event.kind !== 'tool-call') break;
|
||||||
|
if (next.event.toolName !== toolName) break;
|
||||||
|
group.push(next.event);
|
||||||
|
j++;
|
||||||
|
}
|
||||||
|
if (group.length === 1) {
|
||||||
|
result.push({ kind: 'single-event', event: group[0] });
|
||||||
|
} else {
|
||||||
|
result.push({ kind: 'tool-group', toolName, events: group });
|
||||||
|
}
|
||||||
|
i = j;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Everything else: single event
|
||||||
|
result.push({ kind: 'single-event', event });
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Extract the latest intent from events ─────────────────── */
|
||||||
|
|
||||||
|
export function extractLatestIntent(events: readonly RunTimelineEventRecord[]): string | undefined {
|
||||||
|
for (let i = events.length - 1; i >= 0; i--) {
|
||||||
|
const e = events[i];
|
||||||
|
if (
|
||||||
|
e.kind === 'tool-call'
|
||||||
|
&& e.toolName === 'report_intent'
|
||||||
|
&& typeof e.toolArguments?.intent === 'string'
|
||||||
|
&& e.toolArguments.intent.trim()
|
||||||
|
) {
|
||||||
|
return e.toolArguments.intent.trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Generate a fallback summary from the tool mix ─────────── */
|
||||||
|
|
||||||
|
export function generateActivitySummary(events: readonly RunTimelineEventRecord[]): string | undefined {
|
||||||
|
const toolCounts = new Map<string, number>();
|
||||||
|
let editCount = 0;
|
||||||
|
let searchCount = 0;
|
||||||
|
let viewCount = 0;
|
||||||
|
|
||||||
|
for (const e of events) {
|
||||||
|
if (e.kind !== 'tool-call' || !e.toolName) continue;
|
||||||
|
if (e.toolName === 'report_intent') continue;
|
||||||
|
toolCounts.set(e.toolName, (toolCounts.get(e.toolName) ?? 0) + 1);
|
||||||
|
|
||||||
|
if (e.toolName === 'edit' || e.toolName === 'create') editCount++;
|
||||||
|
else if (e.toolName === 'grep' || e.toolName === 'glob' || e.toolName === 'lsp') searchCount++;
|
||||||
|
else if (e.toolName === 'view') viewCount++;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parts: string[] = [];
|
||||||
|
if (searchCount > 0) parts.push(`searched ${searchCount} ${searchCount === 1 ? 'pattern' : 'patterns'}`);
|
||||||
|
if (viewCount > 0) parts.push(`viewed ${viewCount} ${viewCount === 1 ? 'file' : 'files'}`);
|
||||||
|
if (editCount > 0) parts.push(`edited ${editCount} ${editCount === 1 ? 'file' : 'files'}`);
|
||||||
|
|
||||||
|
if (parts.length === 0) {
|
||||||
|
const total = Array.from(toolCounts.values()).reduce((a, b) => a + b, 0);
|
||||||
|
if (total > 0) return `${total} ${total === 1 ? 'action' : 'actions'}`;
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Capitalize first part
|
||||||
|
parts[0] = parts[0].charAt(0).toUpperCase() + parts[0].slice(1);
|
||||||
|
return parts.join(', ');
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import type {
|
|||||||
SessionRunStatus,
|
SessionRunStatus,
|
||||||
} from '@shared/domain/runTimeline';
|
} from '@shared/domain/runTimeline';
|
||||||
import { buildMarkdownExcerpt } from '@shared/utils/markdownText';
|
import { buildMarkdownExcerpt } from '@shared/utils/markdownText';
|
||||||
|
import { formatToolCallPrimaryLabel } from '@renderer/lib/toolCallSummary';
|
||||||
|
|
||||||
export function formatRunTimestamp(isoDate: string): string {
|
export function formatRunTimestamp(isoDate: string): string {
|
||||||
try {
|
try {
|
||||||
@@ -78,9 +79,7 @@ export function formatEventLabel(event: RunTimelineEventRecord): string {
|
|||||||
}
|
}
|
||||||
return event.targetAgentName ? `Handoff to ${event.targetAgentName}` : 'Handoff';
|
return event.targetAgentName ? `Handoff to ${event.targetAgentName}` : 'Handoff';
|
||||||
case 'tool-call':
|
case 'tool-call':
|
||||||
return event.toolName
|
return formatToolCallPrimaryLabel(event.toolName, event.toolArguments);
|
||||||
? `${event.agentName ?? 'Agent'} used ${event.toolName}`
|
|
||||||
: `${event.agentName ?? 'Agent'} tool call`;
|
|
||||||
case 'approval':
|
case 'approval':
|
||||||
if (event.status === 'completed') {
|
if (event.status === 'completed') {
|
||||||
return event.approvalTitle ? `${event.approvalTitle} approved` : 'Approval granted';
|
return event.approvalTitle ? `${event.approvalTitle} approved` : 'Approval granted';
|
||||||
|
|||||||
@@ -114,3 +114,180 @@ export function getDisplayableArguments(
|
|||||||
&& value !== '',
|
&& value !== '',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Verb-based primary labels ─────────────────────────────── */
|
||||||
|
|
||||||
|
function shortenPath(rawPath: string): string {
|
||||||
|
const normalized = rawPath.replace(/\\/g, '/');
|
||||||
|
const lastSlash = normalized.lastIndexOf('/');
|
||||||
|
const fileName = lastSlash >= 0 ? normalized.slice(lastSlash + 1) : normalized;
|
||||||
|
return truncateSummary(fileName);
|
||||||
|
}
|
||||||
|
|
||||||
|
function pathWithRange(args: Record<string, unknown>): string | undefined {
|
||||||
|
const path = stringArg(args, 'path');
|
||||||
|
if (!path) return undefined;
|
||||||
|
const name = shortenPath(path);
|
||||||
|
const range = args['view_range'] ?? args['viewRange'];
|
||||||
|
if (Array.isArray(range) && range.length === 2) {
|
||||||
|
return `${name}:${range[0]}-${range[1]}`;
|
||||||
|
}
|
||||||
|
return name;
|
||||||
|
}
|
||||||
|
|
||||||
|
type VerbLabelExtractor = (args: Record<string, unknown>) => string;
|
||||||
|
|
||||||
|
const verbLabels: Record<string, VerbLabelExtractor> = {
|
||||||
|
view: (args) => {
|
||||||
|
const target = pathWithRange(args);
|
||||||
|
return target ? `Viewed \`${target}\`` : 'Viewed a file';
|
||||||
|
},
|
||||||
|
edit: (args) => {
|
||||||
|
const name = stringArg(args, 'path') ? shortenPath(stringArg(args, 'path')!) : undefined;
|
||||||
|
return name ? `Edited \`${name}\`` : 'Edited a file';
|
||||||
|
},
|
||||||
|
create: (args) => {
|
||||||
|
const name = stringArg(args, 'path') ? shortenPath(stringArg(args, 'path')!) : undefined;
|
||||||
|
return name ? `Created \`${name}\`` : 'Created a file';
|
||||||
|
},
|
||||||
|
grep: (args) => {
|
||||||
|
const pattern = stringArg(args, 'pattern');
|
||||||
|
return pattern ? `Searched for \`${truncateSummary(pattern)}\`` : 'Searched files';
|
||||||
|
},
|
||||||
|
glob: (args) => {
|
||||||
|
const pattern = stringArg(args, 'pattern');
|
||||||
|
return pattern ? `Glob \`${truncateSummary(pattern)}\`` : 'Searched for files';
|
||||||
|
},
|
||||||
|
lsp: (args) => {
|
||||||
|
const op = stringArg(args, 'operation');
|
||||||
|
const file = stringArg(args, 'file') ? shortenPath(stringArg(args, 'file')!) : undefined;
|
||||||
|
if (op && file) return `LSP ${op} \`${file}\``;
|
||||||
|
return op ? `LSP ${op}` : 'LSP operation';
|
||||||
|
},
|
||||||
|
powershell: (args) => {
|
||||||
|
const cmd = stringArg(args, 'command');
|
||||||
|
return cmd ? `Ran \`${truncateSummary(cmd)}\`` : 'Ran a command';
|
||||||
|
},
|
||||||
|
web_fetch: (args) => {
|
||||||
|
const url = stringArg(args, 'url');
|
||||||
|
if (!url) return 'Fetched a URL';
|
||||||
|
try {
|
||||||
|
const hostname = new URL(url).hostname;
|
||||||
|
return `Fetched \`${hostname}\``;
|
||||||
|
} catch {
|
||||||
|
return `Fetched \`${truncateSummary(url)}\``;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
web_search: (args) => {
|
||||||
|
const query = stringArg(args, 'query');
|
||||||
|
return query ? `Searched web: "${truncateSummary(query)}"` : 'Web search';
|
||||||
|
},
|
||||||
|
sql: (args) => {
|
||||||
|
const desc = stringArg(args, 'description');
|
||||||
|
return desc ? `SQL: ${truncateSummary(desc)}` : 'SQL query';
|
||||||
|
},
|
||||||
|
task: (args) => {
|
||||||
|
const desc = stringArg(args, 'description');
|
||||||
|
return desc ? `Launched agent: ${truncateSummary(desc)}` : 'Launched agent';
|
||||||
|
},
|
||||||
|
ask_user: (args) => {
|
||||||
|
const q = stringArg(args, 'question');
|
||||||
|
return q ? `Asked: "${truncateSummary(q)}"` : 'Asked a question';
|
||||||
|
},
|
||||||
|
skill: (args) => {
|
||||||
|
const name = stringArg(args, 'skill');
|
||||||
|
return name ? `Used skill \`${name}\`` : 'Used a skill';
|
||||||
|
},
|
||||||
|
store_memory: () => 'Stored a memory',
|
||||||
|
report_intent: (args) => {
|
||||||
|
const intent = stringArg(args, 'intent');
|
||||||
|
return intent ? intent : 'Updated intent';
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Produces a verb-based, context-rich label for a tool call.
|
||||||
|
* E.g. "Viewed `ChatPane.tsx:148-250`", "Searched for `toolCall`".
|
||||||
|
* Falls back to "Used <toolName>" when no specific formatter exists.
|
||||||
|
*/
|
||||||
|
export function formatToolCallPrimaryLabel(
|
||||||
|
toolName: string | undefined,
|
||||||
|
toolArguments: Record<string, unknown> | undefined,
|
||||||
|
): string {
|
||||||
|
if (!toolName) return 'Tool call';
|
||||||
|
|
||||||
|
const args = toolArguments ?? {};
|
||||||
|
|
||||||
|
// GitHub MCP tools
|
||||||
|
if (toolName.startsWith('github-')) {
|
||||||
|
const detail = summarizeGitHub(toolName, args);
|
||||||
|
const shortName = toolName.replace(/^github-mcp-server-/, '').replace(/_/g, ' ');
|
||||||
|
return detail ? `GitHub: ${shortName} — ${detail}` : `GitHub: ${shortName}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const extractor = verbLabels[toolName];
|
||||||
|
if (extractor) {
|
||||||
|
return extractor(args);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unknown tool — try to extract something useful
|
||||||
|
const detail = fallbackSummary(args);
|
||||||
|
return detail ? `Used ${toolName}: ${detail}` : `Used ${toolName}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Produces a compact group label for N consecutive calls of the same tool.
|
||||||
|
* E.g. "Viewed 4 files", "Ran 3 commands", "Searched 5 patterns".
|
||||||
|
*/
|
||||||
|
export function formatToolGroupLabel(toolName: string, count: number): string {
|
||||||
|
const n = count;
|
||||||
|
switch (toolName) {
|
||||||
|
case 'view': return `Viewed ${n} ${n === 1 ? 'file' : 'files'}`;
|
||||||
|
case 'edit': return `Edited ${n} ${n === 1 ? 'file' : 'files'}`;
|
||||||
|
case 'create': return `Created ${n} ${n === 1 ? 'file' : 'files'}`;
|
||||||
|
case 'grep': return `Searched ${n} ${n === 1 ? 'pattern' : 'patterns'}`;
|
||||||
|
case 'glob': return `Glob ${n} ${n === 1 ? 'pattern' : 'patterns'}`;
|
||||||
|
case 'lsp': return `${n} LSP ${n === 1 ? 'operation' : 'operations'}`;
|
||||||
|
case 'powershell': return `Ran ${n} ${n === 1 ? 'command' : 'commands'}`;
|
||||||
|
case 'web_fetch': return `Fetched ${n} ${n === 1 ? 'URL' : 'URLs'}`;
|
||||||
|
case 'sql': return `${n} SQL ${n === 1 ? 'query' : 'queries'}`;
|
||||||
|
case 'task': return `Launched ${n} ${n === 1 ? 'agent' : 'agents'}`;
|
||||||
|
default: return `${n} ${toolName} ${n === 1 ? 'call' : 'calls'}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extracts a short contextual snippet for a tool call to show inside
|
||||||
|
* a grouped row's detail list (e.g. the file path for view, the pattern for grep).
|
||||||
|
*/
|
||||||
|
export function extractToolCallSnippet(
|
||||||
|
toolName: string,
|
||||||
|
toolArguments: Record<string, unknown> | undefined,
|
||||||
|
): string | undefined {
|
||||||
|
if (!toolArguments) return undefined;
|
||||||
|
switch (toolName) {
|
||||||
|
case 'view':
|
||||||
|
case 'edit':
|
||||||
|
case 'create':
|
||||||
|
return pathWithRange(toolArguments) ?? stringArg(toolArguments, 'path');
|
||||||
|
case 'grep':
|
||||||
|
case 'glob':
|
||||||
|
return stringArg(toolArguments, 'pattern');
|
||||||
|
case 'powershell':
|
||||||
|
return stringArg(toolArguments, 'command') ? truncateSummary(stringArg(toolArguments, 'command')!) : undefined;
|
||||||
|
case 'lsp': {
|
||||||
|
const op = stringArg(toolArguments, 'operation');
|
||||||
|
const file = stringArg(toolArguments, 'file') ? shortenPath(stringArg(toolArguments, 'file')!) : undefined;
|
||||||
|
if (op && file) return `${op} ${file}`;
|
||||||
|
return op ?? undefined;
|
||||||
|
}
|
||||||
|
case 'web_fetch':
|
||||||
|
return stringArg(toolArguments, 'url');
|
||||||
|
case 'sql':
|
||||||
|
return stringArg(toolArguments, 'description');
|
||||||
|
case 'task':
|
||||||
|
return stringArg(toolArguments, 'description');
|
||||||
|
default:
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+13
-1
@@ -663,6 +663,17 @@ body {
|
|||||||
animation: turn-activity-row-in 0.15s cubic-bezier(0.16, 1, 0.3, 1) both;
|
animation: turn-activity-row-in 0.15s cubic-bezier(0.16, 1, 0.3, 1) both;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@keyframes intent-divider-in {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: scaleX(0.7);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.turn-activity-row[role="separator"] {
|
||||||
|
animation: intent-divider-in 0.2s cubic-bezier(0.16, 1, 0.3, 1) both;
|
||||||
|
}
|
||||||
|
|
||||||
/* ── Respect reduced motion ──────────────────────────────────── */
|
/* ── Respect reduced motion ──────────────────────────────────── */
|
||||||
|
|
||||||
@media (prefers-reduced-motion: reduce) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
@@ -677,7 +688,8 @@ body {
|
|||||||
.banner-slide-enter,
|
.banner-slide-enter,
|
||||||
.update-banner-enter,
|
.update-banner-enter,
|
||||||
.turn-activity-enter,
|
.turn-activity-enter,
|
||||||
.turn-activity-row {
|
.turn-activity-row,
|
||||||
|
.turn-activity-row[role="separator"] {
|
||||||
animation: none;
|
animation: none;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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',
|
kind: 'tool-call',
|
||||||
agentName: 'Writer',
|
agentName: 'Writer',
|
||||||
toolName: 'file_search',
|
toolName: 'file_search',
|
||||||
}))).toBe('Writer used file_search');
|
}))).toBe('Used file_search');
|
||||||
expect(formatEventLabel(createEvent({
|
expect(formatEventLabel(createEvent({
|
||||||
kind: 'approval',
|
kind: 'approval',
|
||||||
status: 'running',
|
status: 'running',
|
||||||
|
|||||||
@@ -4,6 +4,9 @@ import {
|
|||||||
formatToolCallSummary,
|
formatToolCallSummary,
|
||||||
formatToolArgumentValue,
|
formatToolArgumentValue,
|
||||||
getDisplayableArguments,
|
getDisplayableArguments,
|
||||||
|
formatToolCallPrimaryLabel,
|
||||||
|
formatToolGroupLabel,
|
||||||
|
extractToolCallSnippet,
|
||||||
} from '@renderer/lib/toolCallSummary';
|
} from '@renderer/lib/toolCallSummary';
|
||||||
|
|
||||||
describe('formatToolCallSummary', () => {
|
describe('formatToolCallSummary', () => {
|
||||||
@@ -171,3 +174,106 @@ describe('getDisplayableArguments', () => {
|
|||||||
expect(result).toEqual([['query', 'INSERT ...']]);
|
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