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:
1 parent
74fb784f6d
commit
7b64948cb4
6 files changed
+314
-76
No files matched your search
Generated
+2
-2
@@ -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
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "codepilot",
|
||||
"version": "0.7.0",
|
||||
"version": "0.7.1",
|
||||
"private": true,
|
||||
"main": "dist-electron/main.js",
|
||||
"scripts": {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
Reference in new issue
Block a user