Skip to content

Commit 7b64948

Browse files
7418claude
andcommitted
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>
1 parent 74fb784 commit 7b64948

6 files changed

Lines changed: 314 additions & 76 deletions

File tree

package-lock.json

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "codepilot",
3-
"version": "0.7.0",
3+
"version": "0.7.1",
44
"private": true,
55
"main": "dist-electron/main.js",
66
"scripts": {

src/components/ai-elements/message.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,7 @@ export const MessageContent = ({
5757
className={cn(
5858
"is-user:dark flex w-fit min-w-0 max-w-full flex-col gap-2 overflow-hidden text-sm",
5959
"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",
60-
"group-[.is-assistant]:text-foreground",
60+
"group-[.is-assistant]:w-full group-[.is-assistant]:text-foreground",
6161
className
6262
)}
6363
{...props}
Lines changed: 285 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,285 @@
1+
'use client';
2+
3+
import { useState, useEffect, useRef } from 'react';
4+
import { motion, AnimatePresence } from 'motion/react';
5+
import { HugeiconsIcon } from "@hugeicons/react";
6+
import type { IconSvgElement } from "@hugeicons/react";
7+
import {
8+
File01Icon,
9+
FileEditIcon,
10+
CommandLineIcon,
11+
Search01Icon,
12+
Wrench01Icon,
13+
Loading02Icon,
14+
CheckmarkCircle02Icon,
15+
CancelCircleIcon,
16+
} from "@hugeicons/core-free-icons";
17+
import { ChevronRightIcon } from 'lucide-react';
18+
import { cn } from '@/lib/utils';
19+
20+
// ---------------------------------------------------------------------------
21+
// Types
22+
// ---------------------------------------------------------------------------
23+
24+
export interface ToolAction {
25+
id?: string;
26+
name: string;
27+
input: unknown;
28+
result?: string;
29+
isError?: boolean;
30+
}
31+
32+
interface ToolActionsGroupProps {
33+
tools: ToolAction[];
34+
isStreaming?: boolean;
35+
streamingToolOutput?: string;
36+
}
37+
38+
// ---------------------------------------------------------------------------
39+
// Tool categorisation
40+
// ---------------------------------------------------------------------------
41+
42+
type ToolCategory = 'read' | 'write' | 'bash' | 'search' | 'other';
43+
44+
function getToolCategory(name: string): ToolCategory {
45+
const lower = name.toLowerCase();
46+
if (lower === 'read' || lower === 'readfile' || lower === 'read_file') return 'read';
47+
if (
48+
lower === 'write' || lower === 'edit' || lower === 'writefile' ||
49+
lower === 'write_file' || lower === 'create_file' || lower === 'createfile' ||
50+
lower === 'notebookedit' || lower === 'notebook_edit'
51+
) return 'write';
52+
if (
53+
lower === 'bash' || lower === 'execute' || lower === 'run' ||
54+
lower === 'shell' || lower === 'execute_command'
55+
) return 'bash';
56+
if (
57+
lower === 'search' || lower === 'glob' || lower === 'grep' ||
58+
lower === 'find_files' || lower === 'search_files' ||
59+
lower === 'websearch' || lower === 'web_search'
60+
) return 'search';
61+
return 'other';
62+
}
63+
64+
function getToolIcon(category: ToolCategory): IconSvgElement {
65+
switch (category) {
66+
case 'read': return File01Icon;
67+
case 'write': return FileEditIcon;
68+
case 'bash': return CommandLineIcon;
69+
case 'search': return Search01Icon;
70+
case 'other': return Wrench01Icon;
71+
}
72+
}
73+
74+
// ---------------------------------------------------------------------------
75+
// Summary helpers
76+
// ---------------------------------------------------------------------------
77+
78+
function extractFilename(path: string): string {
79+
const parts = path.split('/');
80+
return parts[parts.length - 1] || path;
81+
}
82+
83+
function getToolSummary(name: string, input: unknown, category: ToolCategory): string {
84+
const inp = input as Record<string, unknown> | undefined;
85+
if (!inp) return name;
86+
87+
switch (category) {
88+
case 'read':
89+
case 'write': {
90+
const path = (inp.file_path || inp.path || inp.filePath || '') as string;
91+
return path ? extractFilename(path) : name;
92+
}
93+
case 'bash': {
94+
const cmd = (inp.command || inp.cmd || '') as string;
95+
if (cmd) return cmd.length > 60 ? cmd.slice(0, 57) + '...' : cmd;
96+
return name;
97+
}
98+
case 'search': {
99+
const pattern = (inp.pattern || inp.query || inp.glob || '') as string;
100+
return pattern ? `"${pattern.length > 50 ? pattern.slice(0, 47) + '...' : pattern}"` : name;
101+
}
102+
default:
103+
return name;
104+
}
105+
}
106+
107+
function getFilePath(input: unknown): string {
108+
const inp = input as Record<string, unknown> | undefined;
109+
if (!inp) return '';
110+
return (inp.file_path || inp.path || inp.filePath || '') as string;
111+
}
112+
113+
function truncatePath(path: string, maxLen = 50): string {
114+
if (path.length <= maxLen) return path;
115+
return '...' + path.slice(path.length - maxLen + 3);
116+
}
117+
118+
// ---------------------------------------------------------------------------
119+
// Status indicator — running: gray, completed: green, error: red
120+
// ---------------------------------------------------------------------------
121+
122+
type ToolStatus = 'running' | 'success' | 'error';
123+
124+
function getStatus(tool: ToolAction): ToolStatus {
125+
if (tool.result === undefined) return 'running';
126+
return tool.isError ? 'error' : 'success';
127+
}
128+
129+
function StatusDot({ status }: { status: ToolStatus }) {
130+
switch (status) {
131+
case 'running':
132+
return (
133+
<HugeiconsIcon icon={Loading02Icon} className="h-3.5 w-3.5 shrink-0 animate-spin text-muted-foreground/50" />
134+
);
135+
case 'success':
136+
return <HugeiconsIcon icon={CheckmarkCircle02Icon} className="h-3.5 w-3.5 shrink-0 text-green-500" />;
137+
case 'error':
138+
return <HugeiconsIcon icon={CancelCircleIcon} className="h-3.5 w-3.5 shrink-0 text-red-500" />;
139+
}
140+
}
141+
142+
// ---------------------------------------------------------------------------
143+
// Compact row for a single tool action
144+
// ---------------------------------------------------------------------------
145+
146+
function ToolActionRow({ tool }: { tool: ToolAction }) {
147+
const category = getToolCategory(tool.name);
148+
const icon = getToolIcon(category);
149+
const summary = getToolSummary(tool.name, tool.input, category);
150+
const filePath = getFilePath(tool.input);
151+
const status = getStatus(tool);
152+
153+
const label = category === 'bash' ? '' : tool.name;
154+
155+
return (
156+
<div className="flex items-center gap-2 px-2 py-1 min-h-[28px] text-xs hover:bg-muted/30 rounded-sm transition-colors">
157+
<HugeiconsIcon icon={icon} className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
158+
159+
{label && (
160+
<span className="font-medium text-muted-foreground shrink-0">{label}</span>
161+
)}
162+
163+
<span className="font-mono text-muted-foreground/60 truncate flex-1">
164+
{summary}
165+
</span>
166+
167+
{filePath && (category === 'read' || category === 'write') && (
168+
<span className="text-muted-foreground/40 text-[11px] font-mono truncate max-w-[200px] hidden sm:inline">
169+
{truncatePath(filePath)}
170+
</span>
171+
)}
172+
173+
<StatusDot status={status} />
174+
</div>
175+
);
176+
}
177+
178+
// ---------------------------------------------------------------------------
179+
// Header summary helper — build running task description
180+
// ---------------------------------------------------------------------------
181+
182+
function getRunningDescription(tools: ToolAction[]): string {
183+
const running = tools.filter((t) => t.result === undefined);
184+
if (running.length === 0) return '';
185+
const last = running[running.length - 1];
186+
const category = getToolCategory(last.name);
187+
return getToolSummary(last.name, last.input, category);
188+
}
189+
190+
// ---------------------------------------------------------------------------
191+
// Main group component
192+
// ---------------------------------------------------------------------------
193+
194+
export function ToolActionsGroup({
195+
tools,
196+
isStreaming = false,
197+
streamingToolOutput: _streamingToolOutput,
198+
}: ToolActionsGroupProps) {
199+
const hasRunningTool = tools.some((t) => t.result === undefined);
200+
201+
const [userToggled, setUserToggled] = useState(false);
202+
const [expanded, setExpanded] = useState(hasRunningTool);
203+
204+
useEffect(() => {
205+
if (!userToggled) {
206+
setExpanded(hasRunningTool || isStreaming);
207+
}
208+
}, [hasRunningTool, isStreaming, userToggled]);
209+
210+
if (tools.length === 0) return null;
211+
212+
const runningCount = tools.filter((t) => t.result === undefined).length;
213+
const doneCount = tools.length - runningCount;
214+
const runningDesc = getRunningDescription(tools);
215+
216+
const handleToggle = () => {
217+
setUserToggled(true);
218+
setExpanded((prev) => !prev);
219+
};
220+
221+
// Build summary text parts
222+
const summaryParts: string[] = [];
223+
if (runningCount > 0) summaryParts.push(`${runningCount} running`);
224+
if (doneCount > 0) summaryParts.push(`${doneCount} completed`);
225+
if (summaryParts.length === 0) summaryParts.push(`${tools.length} actions`);
226+
227+
return (
228+
<div className="w-[min(100%,48rem)]">
229+
{/* Header — minimal: chevron + count + gray summary */}
230+
<button
231+
type="button"
232+
onClick={handleToggle}
233+
className="flex w-full items-center gap-2 py-1 text-xs rounded-sm hover:bg-muted/30 transition-colors"
234+
>
235+
<ChevronRightIcon
236+
className={cn(
237+
"h-3 w-3 shrink-0 text-muted-foreground/60 transition-transform duration-200",
238+
expanded && "rotate-90"
239+
)}
240+
/>
241+
242+
<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">
243+
{tools.length}
244+
</span>
245+
246+
<span className="text-muted-foreground/60 truncate">
247+
{summaryParts.join(' · ')}
248+
</span>
249+
250+
{/* Show running task description on the right */}
251+
{runningDesc && (
252+
<span className="ml-auto text-muted-foreground/40 text-[11px] font-mono truncate max-w-[40%]">
253+
{runningDesc}
254+
</span>
255+
)}
256+
</button>
257+
258+
{/* Expanded list — left vertical line like blockquote */}
259+
<AnimatePresence initial={false}>
260+
{expanded && (
261+
<motion.div
262+
initial={{ height: 0 }}
263+
animate={{ height: 'auto' }}
264+
exit={{ height: 0 }}
265+
transition={{ duration: 0.15, ease: 'easeOut' }}
266+
style={{ overflow: 'hidden', transformOrigin: 'top' }}
267+
>
268+
<motion.div
269+
initial={{ opacity: 0, y: -4 }}
270+
animate={{ opacity: 1, y: 0 }}
271+
exit={{ opacity: 0, y: -4 }}
272+
transition={{ duration: 0.12, ease: 'easeOut' }}
273+
>
274+
<div className="ml-1.5 mt-0.5 border-l-2 border-border/50 pl-2">
275+
{tools.map((tool, i) => (
276+
<ToolActionRow key={tool.id || `tool-${i}`} tool={tool} />
277+
))}
278+
</div>
279+
</motion.div>
280+
</motion.div>
281+
)}
282+
</AnimatePresence>
283+
</div>
284+
);
285+
}

src/components/chat/MessageItem.tsx

Lines changed: 11 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -7,15 +7,8 @@ import {
77
MessageContent,
88
MessageResponse,
99
} from '@/components/ai-elements/message';
10-
import {
11-
Tool,
12-
ToolHeader,
13-
ToolContent,
14-
ToolInput,
15-
ToolOutput,
16-
} from '@/components/ai-elements/tool';
10+
import { ToolActionsGroup } from '@/components/ai-elements/tool-actions-group';
1711
import { CopyIcon, CheckIcon, ChevronDownIcon, ChevronUpIcon } from 'lucide-react';
18-
import type { ToolUIPart } from 'ai';
1912
import { FileAttachmentDisplay } from './FileAttachmentDisplay';
2013

2114
interface MessageItemProps {
@@ -149,12 +142,6 @@ function pairTools(tools: ToolBlock[]): Array<{
149142
return paired;
150143
}
151144

152-
function getToolState(result?: string, isError?: boolean): ToolUIPart['state'] {
153-
if (result === undefined) return 'input-available';
154-
if (isError) return 'output-error';
155-
return 'output-available';
156-
}
157-
158145
function parseMessageFiles(content: string): { files: FileAttachment[]; text: string } {
159146
const match = content.match(/^<!--files:(.*?)-->\n?/);
160147
if (!match) return { files: [], text: content };
@@ -261,26 +248,17 @@ export function MessageItem({ message }: MessageItemProps) {
261248
<FileAttachmentDisplay files={files} />
262249
)}
263250

264-
{/* Tool calls for assistant messages */}
251+
{/* Tool calls for assistant messages — compact collapsible group */}
265252
{!isUser && pairedTools.length > 0 && (
266-
<div className="space-y-2 w-full">
267-
{pairedTools.map((tool, i) => (
268-
<Tool key={`tool-${i}`}>
269-
<ToolHeader
270-
type="tool-invocation"
271-
title={tool.name}
272-
state={getToolState(tool.result, tool.isError)}
273-
/>
274-
<ToolContent>
275-
<ToolInput input={tool.input} />
276-
<ToolOutput
277-
output={tool.result}
278-
errorText={tool.isError ? tool.result : undefined}
279-
/>
280-
</ToolContent>
281-
</Tool>
282-
))}
283-
</div>
253+
<ToolActionsGroup
254+
tools={pairedTools.map((tool, i) => ({
255+
id: `hist-${i}`,
256+
name: tool.name,
257+
input: tool.input,
258+
result: tool.result,
259+
isError: tool.isError,
260+
}))}
261+
/>
284262
)}
285263

286264
{/* Text content */}

0 commit comments

Comments
 (0)