feat: show expandable tool call details in chat activity panel

Thread toolArguments from the sidecar through shared contracts, main-process pipeline, and into the renderer. Add ToolCallDetailPanel with inline summaries (command, path, pattern, etc.) and expandable argument details for every tool-call event in the activity timeline.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
David Kaya
2026-04-07 14:58:22 +02:00
co-authored by Copilot
parent fd9cc03c5c
commit 507bd5408c
8 changed files with 391 additions and 0 deletions
@@ -0,0 +1,89 @@
import { useState } from 'react';
import { ChevronRight } from 'lucide-react';
import {
formatToolCallSummary,
formatToolArgumentValue,
getDisplayableArguments,
} from '@renderer/lib/toolCallSummary';
export interface ToolCallDetailPanelProps {
toolName?: string;
toolArguments?: Record<string, unknown>;
}
export function ToolCallDetailPanel({ toolName, toolArguments }: ToolCallDetailPanelProps) {
const [expanded, setExpanded] = useState(false);
const summary = formatToolCallSummary(toolName, toolArguments);
const displayArgs = getDisplayableArguments(toolArguments);
const hasExpandableContent = displayArgs.length > 0;
if (!summary && !hasExpandableContent) return null;
return (
<div className="mt-0.5">
{/* Inline summary — always visible when summary exists */}
<button
type="button"
className={`group flex max-w-full items-start gap-1 text-left text-[11px] leading-snug ${
hasExpandableContent
? 'cursor-pointer hover:text-[var(--color-text-secondary)]'
: 'cursor-default'
}`}
onClick={hasExpandableContent ? () => setExpanded((prev) => !prev) : undefined}
aria-expanded={hasExpandableContent ? expanded : undefined}
aria-label={hasExpandableContent ? `Toggle ${toolName} arguments` : undefined}
tabIndex={hasExpandableContent ? 0 : -1}
onKeyDown={hasExpandableContent
? (e) => { if (e.key === ' ') { e.preventDefault(); setExpanded((prev) => !prev); } }
: undefined}
>
{hasExpandableContent && (
<ChevronRight
className={`mt-px size-2.5 shrink-0 text-[var(--color-text-muted)] transition-transform duration-150 ${
expanded ? 'rotate-90' : ''
}`}
/>
)}
{summary && (
<span className="min-w-0 truncate font-mono text-[var(--color-text-muted)] group-hover:text-[var(--color-text-secondary)]">
{summary}
</span>
)}
</button>
{/* Expanded argument list */}
{expanded && hasExpandableContent && (
<div className="mt-1 overflow-hidden rounded-md border border-[var(--color-border-subtle)] bg-[var(--color-surface-0)]/80">
<div className="max-h-48 overflow-auto">
{displayArgs.map(([key, value]) => {
const formatted = formatToolArgumentValue(value);
const isMultiline = formatted.includes('\n') || formatted.length > 120;
return (
<div
key={key}
className="border-b border-[var(--color-border-subtle)] px-2 py-1 last:border-b-0"
>
<span className="text-[10px] font-semibold tracking-wide text-[var(--color-accent-purple)]">
{key}
</span>
{isMultiline ? (
<pre className="mt-0.5 max-h-32 overflow-auto whitespace-pre-wrap break-all font-mono text-[10px] leading-relaxed text-[var(--color-text-secondary)]">
{formatted}
</pre>
) : (
<span className="ml-1.5 font-mono text-[10px] text-[var(--color-text-secondary)]">
{formatted}
</span>
)}
</div>
);
})}
</div>
</div>
)}
</div>
);
}
@@ -15,6 +15,7 @@ import {
import { useElapsedTimer } from '@renderer/hooks/useElapsedTimer';
import { FileChangePreview } from '@renderer/components/chat/FileChangePreview';
import { ToolCallDetailPanel } from '@renderer/components/chat/ToolCallDetailPanel';
import { RunChangeSummaryCard } from '@renderer/components/chat/RunChangeSummaryCard';
import { formatEventLabel, truncateContent, filterEventsByAgent, summarizeActivity, type ActivitySummary } from '@renderer/lib/runTimelineFormatting';
import type { ChatMessageRecord } from '@shared/domain/session';
@@ -186,6 +187,11 @@ function ActivityTimelineEventRow({ event }: { event: RunTimelineEventRecord })
</p>
)}
{/* Tool call argument details */}
{event.kind === 'tool-call' && (
<ToolCallDetailPanel toolName={event.toolName} toolArguments={event.toolArguments} />
)}
{/* File change preview for tool-call events */}
{event.kind === 'tool-call' && event.fileChanges && event.fileChanges.length > 0 && (
<div className="mt-1">
+116
View File
@@ -0,0 +1,116 @@
const MAX_SUMMARY_LENGTH = 80;
function truncateSummary(value: string): string {
const firstLine = value.split('\n')[0] ?? '';
const cleaned = firstLine.trim();
if (cleaned.length <= MAX_SUMMARY_LENGTH) return cleaned;
return `${cleaned.slice(0, MAX_SUMMARY_LENGTH)}`;
}
function stringArg(args: Record<string, unknown>, key: string): string | undefined {
const value = args[key];
return typeof value === 'string' && value.trim().length > 0 ? value.trim() : undefined;
}
function summarizePath(args: Record<string, unknown>): string | undefined {
const path = stringArg(args, 'path');
if (!path) return undefined;
const range = args['view_range'] ?? args['viewRange'];
if (Array.isArray(range) && range.length === 2) {
return truncateSummary(`${path}:${range[0]}-${range[1]}`);
}
return truncateSummary(path);
}
function summarizeGitHub(toolName: string, args: Record<string, unknown>): string | undefined {
const owner = stringArg(args, 'owner');
const repo = stringArg(args, 'repo');
const query = stringArg(args, 'query');
if (query) return truncateSummary(query);
if (owner && repo) return truncateSummary(`${owner}/${repo}`);
return undefined;
}
type SummaryExtractor = (args: Record<string, unknown>, toolName: string) => string | undefined;
const toolSummarizers: Record<string, SummaryExtractor> = {
powershell: (args) => stringArg(args, 'command') ? truncateSummary(stringArg(args, 'command')!) : undefined,
view: (args) => summarizePath(args),
edit: (args) => summarizePath(args),
create: (args) => summarizePath(args),
grep: (args) => stringArg(args, 'pattern') ? truncateSummary(stringArg(args, 'pattern')!) : undefined,
glob: (args) => stringArg(args, 'pattern') ? truncateSummary(stringArg(args, 'pattern')!) : undefined,
lsp: (args) => {
const op = stringArg(args, 'operation');
const file = stringArg(args, 'file');
if (op && file) return truncateSummary(`${op} ${file}`);
return op ? truncateSummary(op) : undefined;
},
web_fetch: (args) => stringArg(args, 'url') ? truncateSummary(stringArg(args, 'url')!) : undefined,
sql: (args) => stringArg(args, 'description') ? truncateSummary(stringArg(args, 'description')!) : undefined,
task: (args) => stringArg(args, 'description') ? truncateSummary(stringArg(args, 'description')!) : undefined,
ask_user: (args) => stringArg(args, 'question') ? truncateSummary(stringArg(args, 'question')!) : undefined,
skill: (args) => stringArg(args, 'skill') ? truncateSummary(stringArg(args, 'skill')!) : undefined,
report_intent: (args) => stringArg(args, 'intent') ? truncateSummary(stringArg(args, 'intent')!) : undefined,
};
function fallbackSummary(args: Record<string, unknown>): string | undefined {
for (const value of Object.values(args)) {
if (typeof value === 'string' && value.trim().length > 0 && value !== '[truncated]') {
return truncateSummary(value);
}
}
return undefined;
}
export function formatToolCallSummary(
toolName: string | undefined,
toolArguments: Record<string, unknown> | undefined,
): string | undefined {
if (!toolName || !toolArguments || Object.keys(toolArguments).length === 0) {
return undefined;
}
// Check for GitHub tools (github-*)
if (toolName.startsWith('github-')) {
return summarizeGitHub(toolName, toolArguments);
}
const summarizer = toolSummarizers[toolName];
if (summarizer) {
return summarizer(toolArguments, toolName);
}
return fallbackSummary(toolArguments);
}
export function formatToolArgumentValue(value: unknown): string {
if (value === null || value === undefined) return '';
if (typeof value === 'string') return value;
if (typeof value === 'boolean' || typeof value === 'number') return String(value);
try {
return JSON.stringify(value, null, 2);
} catch {
return String(value);
}
}
/** Keys that are redundant with the label itself or too noisy to display inline. */
const HIDDEN_ARGUMENT_KEYS = new Set([
'description', // often duplicates the summary
]);
export function getDisplayableArguments(
toolArguments: Record<string, unknown> | undefined,
): Array<[string, unknown]> {
if (!toolArguments) return [];
return Object.entries(toolArguments).filter(
([key, value]) =>
!HIDDEN_ARGUMENT_KEYS.has(key)
&& value !== null
&& value !== undefined
&& value !== '',
);
}