feat: compact collapsible tool actions UI

Replace full-card tool rendering with a compact, collapsible group.
Each tool action is a single line with category icon, summary, and
status indicator. Collapsed by default for history, auto-expands
during streaming. Uses blockquote-style left border when expanded.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
7418andClaude Opus 4.6 committed 2026-02-10 12:09:20 +08:00
1 parent 74fb784f6d
commit 7b64948cb4
6 files changed
+314 -76

No files matched your search

+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "codepilot",
"version": "0.7.0",
"version": "0.7.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "codepilot",
"version": "0.7.0",
"version": "0.7.1",
"dependencies": {
"@anthropic-ai/claude-agent-sdk": "^0.2.33",
"@hugeicons/core-free-icons": "^3.1.1",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "codepilot",
"version": "0.7.0",
"version": "0.7.1",
"private": true,
"main": "dist-electron/main.js",
"scripts": {
+1 -1
View File
@@ -57,7 +57,7 @@ export const MessageContent = ({
className={cn(
"is-user:dark flex w-fit min-w-0 max-w-full flex-col gap-2 overflow-hidden text-sm",
"group-[.is-user]:ml-auto group-[.is-user]:rounded-lg group-[.is-user]:bg-secondary group-[.is-user]:px-4 group-[.is-user]:py-3 group-[.is-user]:text-foreground",
"group-[.is-assistant]:text-foreground",
"group-[.is-assistant]:w-full group-[.is-assistant]:text-foreground",
className
)}
{...props}
@@ -0,0 +1,285 @@
'use client';
import { useState, useEffect, useRef } from 'react';
import { motion, AnimatePresence } from 'motion/react';
import { HugeiconsIcon } from "@hugeicons/react";
import type { IconSvgElement } from "@hugeicons/react";
import {
File01Icon,
FileEditIcon,
CommandLineIcon,
Search01Icon,
Wrench01Icon,
Loading02Icon,
CheckmarkCircle02Icon,
CancelCircleIcon,
} from "@hugeicons/core-free-icons";
import { ChevronRightIcon } from 'lucide-react';
import { cn } from '@/lib/utils';
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export interface ToolAction {
id?: string;
name: string;
input: unknown;
result?: string;
isError?: boolean;
}
interface ToolActionsGroupProps {
tools: ToolAction[];
isStreaming?: boolean;
streamingToolOutput?: string;
}
// ---------------------------------------------------------------------------
// Tool categorisation
// ---------------------------------------------------------------------------
type ToolCategory = 'read' | 'write' | 'bash' | 'search' | 'other';
function getToolCategory(name: string): ToolCategory {
const lower = name.toLowerCase();
if (lower === 'read' || lower === 'readfile' || lower === 'read_file') return 'read';
if (
lower === 'write' || lower === 'edit' || lower === 'writefile' ||
lower === 'write_file' || lower === 'create_file' || lower === 'createfile' ||
lower === 'notebookedit' || lower === 'notebook_edit'
) return 'write';
if (
lower === 'bash' || lower === 'execute' || lower === 'run' ||
lower === 'shell' || lower === 'execute_command'
) return 'bash';
if (
lower === 'search' || lower === 'glob' || lower === 'grep' ||
lower === 'find_files' || lower === 'search_files' ||
lower === 'websearch' || lower === 'web_search'
) return 'search';
return 'other';
}
function getToolIcon(category: ToolCategory): IconSvgElement {
switch (category) {
case 'read': return File01Icon;
case 'write': return FileEditIcon;
case 'bash': return CommandLineIcon;
case 'search': return Search01Icon;
case 'other': return Wrench01Icon;
}
}
// ---------------------------------------------------------------------------
// Summary helpers
// ---------------------------------------------------------------------------
function extractFilename(path: string): string {
const parts = path.split('/');
return parts[parts.length - 1] || path;
}
function getToolSummary(name: string, input: unknown, category: ToolCategory): string {
const inp = input as Record<string, unknown> | undefined;
if (!inp) return name;
switch (category) {
case 'read':
case 'write': {
const path = (inp.file_path || inp.path || inp.filePath || '') as string;
return path ? extractFilename(path) : name;
}
case 'bash': {
const cmd = (inp.command || inp.cmd || '') as string;
if (cmd) return cmd.length > 60 ? cmd.slice(0, 57) + '...' : cmd;
return name;
}
case 'search': {
const pattern = (inp.pattern || inp.query || inp.glob || '') as string;
return pattern ? `"${pattern.length > 50 ? pattern.slice(0, 47) + '...' : pattern}"` : name;
}
default:
return name;
}
}
function getFilePath(input: unknown): string {
const inp = input as Record<string, unknown> | undefined;
if (!inp) return '';
return (inp.file_path || inp.path || inp.filePath || '') as string;
}
function truncatePath(path: string, maxLen = 50): string {
if (path.length <= maxLen) return path;
return '...' + path.slice(path.length - maxLen + 3);
}
// ---------------------------------------------------------------------------
// Status indicator — running: gray, completed: green, error: red
// ---------------------------------------------------------------------------
type ToolStatus = 'running' | 'success' | 'error';
function getStatus(tool: ToolAction): ToolStatus {
if (tool.result === undefined) return 'running';
return tool.isError ? 'error' : 'success';
}
function StatusDot({ status }: { status: ToolStatus }) {
switch (status) {
case 'running':
return (
<HugeiconsIcon icon={Loading02Icon} className="h-3.5 w-3.5 shrink-0 animate-spin text-muted-foreground/50" />
);
case 'success':
return <HugeiconsIcon icon={CheckmarkCircle02Icon} className="h-3.5 w-3.5 shrink-0 text-green-500" />;
case 'error':
return <HugeiconsIcon icon={CancelCircleIcon} className="h-3.5 w-3.5 shrink-0 text-red-500" />;
}
}
// ---------------------------------------------------------------------------
// Compact row for a single tool action
// ---------------------------------------------------------------------------
function ToolActionRow({ tool }: { tool: ToolAction }) {
const category = getToolCategory(tool.name);
const icon = getToolIcon(category);
const summary = getToolSummary(tool.name, tool.input, category);
const filePath = getFilePath(tool.input);
const status = getStatus(tool);
const label = category === 'bash' ? '' : tool.name;
return (
<div className="flex items-center gap-2 px-2 py-1 min-h-[28px] text-xs hover:bg-muted/30 rounded-sm transition-colors">
<HugeiconsIcon icon={icon} className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
{label && (
<span className="font-medium text-muted-foreground shrink-0">{label}</span>
)}
<span className="font-mono text-muted-foreground/60 truncate flex-1">
{summary}
</span>
{filePath && (category === 'read' || category === 'write') && (
<span className="text-muted-foreground/40 text-[11px] font-mono truncate max-w-[200px] hidden sm:inline">
{truncatePath(filePath)}
</span>
)}
<StatusDot status={status} />
</div>
);
}
// ---------------------------------------------------------------------------
// Header summary helper — build running task description
// ---------------------------------------------------------------------------
function getRunningDescription(tools: ToolAction[]): string {
const running = tools.filter((t) => t.result === undefined);
if (running.length === 0) return '';
const last = running[running.length - 1];
const category = getToolCategory(last.name);
return getToolSummary(last.name, last.input, category);
}
// ---------------------------------------------------------------------------
// Main group component
// ---------------------------------------------------------------------------
export function ToolActionsGroup({
tools,
isStreaming = false,
streamingToolOutput: _streamingToolOutput,
}: ToolActionsGroupProps) {
const hasRunningTool = tools.some((t) => t.result === undefined);
const [userToggled, setUserToggled] = useState(false);
const [expanded, setExpanded] = useState(hasRunningTool);
useEffect(() => {
if (!userToggled) {
setExpanded(hasRunningTool || isStreaming);
}
}, [hasRunningTool, isStreaming, userToggled]);
if (tools.length === 0) return null;
const runningCount = tools.filter((t) => t.result === undefined).length;
const doneCount = tools.length - runningCount;
const runningDesc = getRunningDescription(tools);
const handleToggle = () => {
setUserToggled(true);
setExpanded((prev) => !prev);
};
// Build summary text parts
const summaryParts: string[] = [];
if (runningCount > 0) summaryParts.push(`${runningCount} running`);
if (doneCount > 0) summaryParts.push(`${doneCount} completed`);
if (summaryParts.length === 0) summaryParts.push(`${tools.length} actions`);
return (
<div className="w-[min(100%,48rem)]">
{/* Header — minimal: chevron + count + gray summary */}
<button
type="button"
onClick={handleToggle}
className="flex w-full items-center gap-2 py-1 text-xs rounded-sm hover:bg-muted/30 transition-colors"
>
<ChevronRightIcon
className={cn(
"h-3 w-3 shrink-0 text-muted-foreground/60 transition-transform duration-200",
expanded && "rotate-90"
)}
/>
<span className="inline-flex items-center justify-center rounded bg-muted/80 px-1.5 py-0.5 text-[10px] font-medium leading-none text-muted-foreground/70 tabular-nums">
{tools.length}
</span>
<span className="text-muted-foreground/60 truncate">
{summaryParts.join(' · ')}
</span>
{/* Show running task description on the right */}
{runningDesc && (
<span className="ml-auto text-muted-foreground/40 text-[11px] font-mono truncate max-w-[40%]">
{runningDesc}
</span>
)}
</button>
{/* Expanded list — left vertical line like blockquote */}
<AnimatePresence initial={false}>
{expanded && (
<motion.div
initial={{ height: 0 }}
animate={{ height: 'auto' }}
exit={{ height: 0 }}
transition={{ duration: 0.15, ease: 'easeOut' }}
style={{ overflow: 'hidden', transformOrigin: 'top' }}
>
<motion.div
initial={{ opacity: 0, y: -4 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -4 }}
transition={{ duration: 0.12, ease: 'easeOut' }}
>
<div className="ml-1.5 mt-0.5 border-l-2 border-border/50 pl-2">
{tools.map((tool, i) => (
<ToolActionRow key={tool.id || `tool-${i}`} tool={tool} />
))}
</div>
</motion.div>
</motion.div>
)}
</AnimatePresence>
</div>
);
}
+11 -33
View File
@@ -7,15 +7,8 @@ import {
MessageContent,
MessageResponse,
} from '@/components/ai-elements/message';
import {
Tool,
ToolHeader,
ToolContent,
ToolInput,
ToolOutput,
} from '@/components/ai-elements/tool';
import { ToolActionsGroup } from '@/components/ai-elements/tool-actions-group';
import { CopyIcon, CheckIcon, ChevronDownIcon, ChevronUpIcon } from 'lucide-react';
import type { ToolUIPart } from 'ai';
import { FileAttachmentDisplay } from './FileAttachmentDisplay';
interface MessageItemProps {
@@ -149,12 +142,6 @@ function pairTools(tools: ToolBlock[]): Array<{
return paired;
}
function getToolState(result?: string, isError?: boolean): ToolUIPart['state'] {
if (result === undefined) return 'input-available';
if (isError) return 'output-error';
return 'output-available';
}
function parseMessageFiles(content: string): { files: FileAttachment[]; text: string } {
const match = content.match(/^<!--files:(.*?)-->\n?/);
if (!match) return { files: [], text: content };
@@ -261,26 +248,17 @@ export function MessageItem({ message }: MessageItemProps) {
<FileAttachmentDisplay files={files} />
)}
{/* Tool calls for assistant messages */}
{/* Tool calls for assistant messages — compact collapsible group */}
{!isUser && pairedTools.length > 0 && (
<div className="space-y-2 w-full">
{pairedTools.map((tool, i) => (
<Tool key={`tool-${i}`}>
<ToolHeader
type="tool-invocation"
title={tool.name}
state={getToolState(tool.result, tool.isError)}
/>
<ToolContent>
<ToolInput input={tool.input} />
<ToolOutput
output={tool.result}
errorText={tool.isError ? tool.result : undefined}
/>
</ToolContent>
</Tool>
))}
</div>
<ToolActionsGroup
tools={pairedTools.map((tool, i) => ({
id: `hist-${i}`,
name: tool.name,
input: tool.input,
result: tool.result,
isError: tool.isError,
}))}
/>
)}
{/* Text content */}
+14 -39
View File
@@ -6,13 +6,7 @@ import {
MessageContent,
MessageResponse,
} from '@/components/ai-elements/message';
import {
Tool,
ToolHeader,
ToolContent,
ToolInput,
ToolOutput,
} from '@/components/ai-elements/tool';
import { ToolActionsGroup } from '@/components/ai-elements/tool-actions-group';
import {
Confirmation,
ConfirmationTitle,
@@ -50,12 +44,6 @@ interface StreamingMessageProps {
permissionResolved?: 'allow' | 'deny' | null;
}
function getToolState(hasResult: boolean, isError?: boolean): ToolUIPart['state'] {
if (!hasResult) return 'input-available';
if (isError) return 'output-error';
return 'output-available';
}
function ElapsedTimer() {
const [elapsed, setElapsed] = useState(0);
const startRef = useRef(Date.now());
@@ -149,35 +137,22 @@ export function StreamingMessage({
return (
<AIMessage from="assistant">
<MessageContent>
{/* Tool calls */}
{/* Tool calls — compact collapsible group */}
{toolUses.length > 0 && (
<div className="space-y-2 w-full">
{toolUses.map((tool, index) => {
<ToolActionsGroup
tools={toolUses.map((tool) => {
const result = toolResults.find((r) => r.tool_use_id === tool.id);
const isRunning = !result;
const isLastRunning = isRunning && index === toolUses.length - 1;
return (
<Tool key={tool.id} defaultOpen={isRunning || !!result}>
<ToolHeader
type="tool-invocation"
title={tool.name}
state={getToolState(!!result, result?.is_error)}
/>
<ToolContent>
<ToolInput input={tool.input} />
{isLastRunning && streamingToolOutput ? (
<ToolOutput output={streamingToolOutput} errorText={undefined} />
) : (
<ToolOutput
output={result?.content}
errorText={result?.is_error ? result.content : undefined}
/>
)}
</ToolContent>
</Tool>
);
return {
id: tool.id,
name: tool.name,
input: tool.input,
result: result?.content,
isError: result?.is_error,
};
})}
</div>
isStreaming={isStreaming}
streamingToolOutput={streamingToolOutput}
/>
)}
{/* Permission approval confirmation */}