refactor: queue primitive, tool status badges, and tool display cleanup

- Add Queue/QueueItem/QueueItemIndicator/QueueItemContent primitive
- Rewrite TodoList using Queue (clean list, no bordered cards, no priority badges)
- Add ToolStatusBadge component (Running/Completed/Error/Denied)
- Migrate CollapsibleSection from native <details> to Collapsible primitive
- Add badge prop threading through CollapsibleDisplay and CollapsibleSection
- Add status badges to OneLineDisplay and CollapsibleDisplay via ToolRenderer
- Update SubagentContainer: theme tokens + Collapsible for tool history
- Replace hardcoded gray-* colors with theme tokens throughout tool displays
This commit is contained in:
simosmik
2026-04-20 15:30:16 +00:00
parent c471b5d3fa
commit ec0ff974cb
12 changed files with 373 additions and 227 deletions

View File

@@ -122,8 +122,28 @@ export default function AppContent() {
} }
}, [isConnected, selectedSession?.id, sendMessage]); }, [isConnected, selectedSession?.id, sendMessage]);
// Adjust the app container to stay above the virtual keyboard on iOS Safari.
// On Chrome for Android the layout viewport already shrinks when the keyboard opens,
// so inset-0 adjusts automatically. On iOS the layout viewport stays full-height and
// the keyboard overlays it — we use the Visual Viewport API to track keyboard height
// and apply it as a CSS variable that shifts the container's bottom edge up.
useEffect(() => {
const vv = window.visualViewport;
if (!vv) return;
const update = () => {
const kb = Math.max(0, window.innerHeight - (vv.offsetTop + vv.height));
document.documentElement.style.setProperty('--keyboard-height', `${kb}px`);
};
vv.addEventListener('resize', update);
vv.addEventListener('scroll', update);
return () => {
vv.removeEventListener('resize', update);
vv.removeEventListener('scroll', update);
};
}, []);
return ( return (
<div className="fixed inset-0 flex bg-background"> <div className="fixed inset-0 flex bg-background" style={{ bottom: 'var(--keyboard-height, 0px)' }}>
{!isMobile ? ( {!isMobile ? (
<div className="h-full flex-shrink-0 border-r border-border/50"> <div className="h-full flex-shrink-0 border-r border-border/50">
<Sidebar {...sidebarSharedProps} /> <Sidebar {...sidebarSharedProps} />

View File

@@ -6,6 +6,8 @@ import type { SubagentChildTool } from '../types/types';
import { getToolConfig } from './configs/toolConfigs'; import { getToolConfig } from './configs/toolConfigs';
import { OneLineDisplay, CollapsibleDisplay, ToolDiffViewer, MarkdownContent, FileListContent, TodoListContent, TaskListContent, TextContent, QuestionAnswerContent, SubagentContainer } from './components'; import { OneLineDisplay, CollapsibleDisplay, ToolDiffViewer, MarkdownContent, FileListContent, TodoListContent, TaskListContent, TextContent, QuestionAnswerContent, SubagentContainer } from './components';
import { PlanDisplay } from './components/PlanDisplay'; import { PlanDisplay } from './components/PlanDisplay';
import { ToolStatusBadge } from './components/ToolStatusBadge';
import type { ToolStatus } from './components/ToolStatusBadge';
type DiffLine = { type DiffLine = {
type: string; type: string;
@@ -39,12 +41,24 @@ function getToolCategory(toolName: string): string {
if (toolName === 'Bash') return 'bash'; if (toolName === 'Bash') return 'bash';
if (['TodoWrite', 'TodoRead'].includes(toolName)) return 'todo'; if (['TodoWrite', 'TodoRead'].includes(toolName)) return 'todo';
if (['TaskCreate', 'TaskUpdate', 'TaskList', 'TaskGet'].includes(toolName)) return 'task'; if (['TaskCreate', 'TaskUpdate', 'TaskList', 'TaskGet'].includes(toolName)) return 'task';
if (toolName === 'Task') return 'agent'; // Subagent task if (toolName === 'Task') return 'agent';
if (toolName === 'exit_plan_mode' || toolName === 'ExitPlanMode') return 'plan'; if (toolName === 'exit_plan_mode' || toolName === 'ExitPlanMode') return 'plan';
if (toolName === 'AskUserQuestion') return 'question'; if (toolName === 'AskUserQuestion') return 'question';
return 'default'; return 'default';
} }
function deriveToolStatus(toolResult: any): ToolStatus {
if (!toolResult) return 'running';
if (toolResult.isError) {
const content = String(toolResult.content || '').toLowerCase();
if (content.includes('permission') || content.includes('denied') || content.includes('not allowed')) {
return 'denied';
}
return 'error';
}
return 'completed';
}
/** /**
* Main tool renderer router * Main tool renderer router
* Routes to OneLineDisplay or CollapsibleDisplay based on tool config * Routes to OneLineDisplay or CollapsibleDisplay based on tool config
@@ -76,6 +90,12 @@ export const ToolRenderer: React.FC<ToolRendererProps> = memo(({
} }
}, [mode, toolInput, toolResult]); }, [mode, toolInput, toolResult]);
// Only derive and show status badge on input renders
const toolStatus = useMemo(
() => mode === 'input' ? deriveToolStatus(toolResult) : undefined,
[mode, toolResult],
);
const handleAction = useCallback(() => { const handleAction = useCallback(() => {
if (displayConfig?.action === 'open-file' && onFileOpen) { if (displayConfig?.action === 'open-file' && onFileOpen) {
const value = displayConfig.getValue?.(parsedData) || ''; const value = displayConfig.getValue?.(parsedData) || '';
@@ -85,9 +105,7 @@ export const ToolRenderer: React.FC<ToolRendererProps> = memo(({
// Route subagent containers to dedicated component (after hooks to satisfy Rules of Hooks) // Route subagent containers to dedicated component (after hooks to satisfy Rules of Hooks)
if (isSubagentContainer && subagentState) { if (isSubagentContainer && subagentState) {
if (mode === 'result') { if (mode === 'result') return null;
return null;
}
return ( return (
<SubagentContainer <SubagentContainer
toolInput={toolInput} toolInput={toolInput}
@@ -118,6 +136,7 @@ export const ToolRenderer: React.FC<ToolRendererProps> = memo(({
wrapText={displayConfig.wrapText} wrapText={displayConfig.wrapText}
colorScheme={displayConfig.colorScheme} colorScheme={displayConfig.colorScheme}
resultId={mode === 'input' ? `tool-result-${toolId}` : undefined} resultId={mode === 'input' ? `tool-result-${toolId}` : undefined}
status={toolStatus}
/> />
); );
} }
@@ -164,7 +183,6 @@ export const ToolRenderer: React.FC<ToolRendererProps> = memo(({
onFileOpen onFileOpen
}) || {}; }) || {};
// Build the content component based on contentType
let contentComponent: React.ReactNode = null; let contentComponent: React.ReactNode = null;
switch (displayConfig.contentType) { switch (displayConfig.contentType) {
@@ -241,7 +259,6 @@ export const ToolRenderer: React.FC<ToolRendererProps> = memo(({
} }
} }
// For edit tools, make the title (filename) clickable to open the file
const handleTitleClick = (toolName === 'Edit' || toolName === 'Write' || toolName === 'ApplyPatch') && contentProps.filePath && onFileOpen const handleTitleClick = (toolName === 'Edit' || toolName === 'Write' || toolName === 'ApplyPatch') && contentProps.filePath && onFileOpen
? () => onFileOpen(contentProps.filePath, { ? () => onFileOpen(contentProps.filePath, {
old_string: contentProps.oldContent, old_string: contentProps.oldContent,
@@ -249,6 +266,8 @@ export const ToolRenderer: React.FC<ToolRendererProps> = memo(({
}) })
: undefined; : undefined;
const badgeElement = toolStatus ? <ToolStatusBadge status={toolStatus} /> : undefined;
return ( return (
<CollapsibleDisplay <CollapsibleDisplay
toolName={toolName} toolName={toolName}
@@ -256,6 +275,7 @@ export const ToolRenderer: React.FC<ToolRendererProps> = memo(({
title={title} title={title}
defaultOpen={defaultOpen} defaultOpen={defaultOpen}
onTitleClick={handleTitleClick} onTitleClick={handleTitleClick}
badge={badgeElement}
showRawParameters={mode === 'input' && showRawParameters} showRawParameters={mode === 'input' && showRawParameters}
rawContent={rawToolInput} rawContent={rawToolInput}
toolCategory={getToolCategory(toolName)} toolCategory={getToolCategory(toolName)}

View File

@@ -1,4 +1,5 @@
import React from 'react'; import React from 'react';
import { Collapsible, CollapsibleTrigger, CollapsibleContent } from '../../../../shared/view/ui';
import { CollapsibleSection } from './CollapsibleSection'; import { CollapsibleSection } from './CollapsibleSection';
interface CollapsibleDisplayProps { interface CollapsibleDisplayProps {
@@ -7,6 +8,7 @@ interface CollapsibleDisplayProps {
title: string; title: string;
defaultOpen?: boolean; defaultOpen?: boolean;
action?: React.ReactNode; action?: React.ReactNode;
badge?: React.ReactNode;
onTitleClick?: () => void; onTitleClick?: () => void;
children: React.ReactNode; children: React.ReactNode;
showRawParameters?: boolean; showRawParameters?: boolean;
@@ -17,14 +19,14 @@ interface CollapsibleDisplayProps {
const borderColorMap: Record<string, string> = { const borderColorMap: Record<string, string> = {
edit: 'border-l-amber-500 dark:border-l-amber-400', edit: 'border-l-amber-500 dark:border-l-amber-400',
search: 'border-l-gray-400 dark:border-l-gray-500', search: 'border-l-muted-foreground/40',
bash: 'border-l-green-500 dark:border-l-green-400', bash: 'border-l-green-500 dark:border-l-green-400',
todo: 'border-l-violet-500 dark:border-l-violet-400', todo: 'border-l-violet-500 dark:border-l-violet-400',
task: 'border-l-violet-500 dark:border-l-violet-400', task: 'border-l-violet-500 dark:border-l-violet-400',
agent: 'border-l-purple-500 dark:border-l-purple-400', agent: 'border-l-purple-500 dark:border-l-purple-400',
plan: 'border-l-indigo-500 dark:border-l-indigo-400', plan: 'border-l-indigo-500 dark:border-l-indigo-400',
question: 'border-l-blue-500 dark:border-l-blue-400', question: 'border-l-blue-500 dark:border-l-blue-400',
default: 'border-l-gray-300 dark:border-l-gray-600', default: 'border-l-border',
}; };
export const CollapsibleDisplay: React.FC<CollapsibleDisplayProps> = ({ export const CollapsibleDisplay: React.FC<CollapsibleDisplayProps> = ({
@@ -32,14 +34,14 @@ export const CollapsibleDisplay: React.FC<CollapsibleDisplayProps> = ({
title, title,
defaultOpen = false, defaultOpen = false,
action, action,
badge,
onTitleClick, onTitleClick,
children, children,
showRawParameters = false, showRawParameters = false,
rawContent, rawContent,
className = '', className = '',
toolCategory toolCategory,
}) => { }) => {
// Fall back to default styling for unknown/new categories so className never includes "undefined".
const borderColor = borderColorMap[toolCategory || 'default'] || borderColorMap.default; const borderColor = borderColorMap[toolCategory || 'default'] || borderColorMap.default;
return ( return (
@@ -49,15 +51,16 @@ export const CollapsibleDisplay: React.FC<CollapsibleDisplayProps> = ({
toolName={toolName} toolName={toolName}
open={defaultOpen} open={defaultOpen}
action={action} action={action}
badge={badge}
onTitleClick={onTitleClick} onTitleClick={onTitleClick}
> >
{children} {children}
{showRawParameters && rawContent && ( {showRawParameters && rawContent && (
<details className="group/raw relative mt-2"> <Collapsible className="mt-2">
<summary className="flex cursor-pointer items-center gap-1.5 py-0.5 text-[11px] text-gray-400 hover:text-gray-600 dark:text-gray-500 dark:hover:text-gray-300"> <CollapsibleTrigger className="flex items-center gap-1.5 py-0.5 text-[11px] text-muted-foreground hover:text-foreground">
<svg <svg
className="h-2.5 w-2.5 transition-transform duration-150 group-open/raw:rotate-90" className="h-2.5 w-2.5 flex-shrink-0 transition-transform duration-150 data-[state=open]:rotate-90"
fill="none" fill="none"
stroke="currentColor" stroke="currentColor"
viewBox="0 0 24 24" viewBox="0 0 24 24"
@@ -65,11 +68,13 @@ export const CollapsibleDisplay: React.FC<CollapsibleDisplayProps> = ({
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg> </svg>
raw params raw params
</summary> </CollapsibleTrigger>
<pre className="mt-1 overflow-hidden whitespace-pre-wrap break-words rounded border border-gray-200/40 bg-gray-50 p-2 font-mono text-[11px] text-gray-600 dark:border-gray-700/40 dark:bg-gray-900/50 dark:text-gray-400"> <CollapsibleContent>
{rawContent} <pre className="mt-1 overflow-hidden whitespace-pre-wrap break-words rounded border border-border/40 bg-muted p-2 font-mono text-[11px] text-muted-foreground">
</pre> {rawContent}
</details> </pre>
</CollapsibleContent>
</Collapsible>
)} )}
</CollapsibleSection> </CollapsibleSection>
</div> </div>

View File

@@ -1,10 +1,13 @@
import React from 'react'; import React from 'react';
import { Collapsible, CollapsibleTrigger, CollapsibleContent } from '../../../../shared/view/ui';
import { cn } from '../../../../lib/utils';
interface CollapsibleSectionProps { interface CollapsibleSectionProps {
title: string; title: string;
toolName?: string; toolName?: string;
open?: boolean; open?: boolean;
action?: React.ReactNode; action?: React.ReactNode;
badge?: React.ReactNode;
onTitleClick?: () => void; onTitleClick?: () => void;
children: React.ReactNode; children: React.ReactNode;
className?: string; className?: string;
@@ -18,44 +21,68 @@ export const CollapsibleSection: React.FC<CollapsibleSectionProps> = ({
toolName, toolName,
open = false, open = false,
action, action,
badge,
onTitleClick, onTitleClick,
children, children,
className = '' className = '',
}) => { }) => {
return ( return (
<details className={`group/details relative ${className}`} open={open}> <Collapsible defaultOpen={open} className={cn('group/section', className)}>
<summary className="flex cursor-pointer select-none items-center gap-1.5 py-0.5 text-xs group-open/details:sticky group-open/details:top-0 group-open/details:z-10 group-open/details:-mx-1 group-open/details:bg-background group-open/details:px-1"> {/* When there's a clickable title (Edit/Write), only the chevron toggles collapse */}
<svg {onTitleClick ? (
className="h-3 w-3 flex-shrink-0 text-gray-400 transition-transform duration-150 group-open/details:rotate-90 dark:text-gray-500" <div className="flex cursor-default select-none items-center gap-1.5 py-0.5 text-xs group-data-[state=open]/section:sticky group-data-[state=open]/section:top-0 group-data-[state=open]/section:z-10 group-data-[state=open]/section:-mx-1 group-data-[state=open]/section:bg-background group-data-[state=open]/section:px-1">
fill="none" <CollapsibleTrigger className="flex flex-shrink-0 items-center p-0.5 text-muted-foreground hover:text-foreground">
stroke="currentColor" <svg
viewBox="0 0 24 24" className="h-3 w-3 transition-transform duration-150 group-data-[state=open]/section:rotate-90"
> fill="none"
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" /> stroke="currentColor"
</svg> viewBox="0 0 24 24"
{toolName && ( >
<span className="flex-shrink-0 font-medium text-gray-500 dark:text-gray-400">{toolName}</span> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
)} </svg>
{toolName && ( </CollapsibleTrigger>
<span className="flex-shrink-0 text-[10px] text-gray-300 dark:text-gray-600">/</span> {toolName && (
)} <span className="flex-shrink-0 font-medium text-muted-foreground">{toolName}</span>
{onTitleClick ? ( )}
{toolName && (
<span className="flex-shrink-0 text-[10px] text-muted-foreground/40">/</span>
)}
<button <button
onClick={(e) => { e.preventDefault(); e.stopPropagation(); onTitleClick(); }} onClick={onTitleClick}
className="flex-1 truncate text-left font-mono text-blue-600 transition-colors hover:text-blue-700 hover:underline dark:text-blue-400 dark:hover:text-blue-300" className="flex-1 truncate text-left font-mono text-primary transition-colors hover:text-primary/80 hover:underline"
> >
{title} {title}
</button> </button>
) : ( {badge && <span className="ml-auto flex-shrink-0">{badge}</span>}
<span className="flex-1 truncate text-gray-600 dark:text-gray-400"> {action && <span className="ml-1 flex-shrink-0">{action}</span>}
{title} </div>
</span> ) : (
)} <CollapsibleTrigger className="flex w-full select-none items-center gap-1.5 py-0.5 text-xs text-muted-foreground transition-colors hover:text-foreground group-data-[state=open]/section:sticky group-data-[state=open]/section:top-0 group-data-[state=open]/section:z-10 group-data-[state=open]/section:-mx-1 group-data-[state=open]/section:bg-background group-data-[state=open]/section:px-1">
{action && <span className="ml-1 flex-shrink-0">{action}</span>} <svg
</summary> className="h-3 w-3 flex-shrink-0 transition-transform duration-150 group-data-[state=open]/section:rotate-90"
<div className="mt-1.5 pl-[18px]"> fill="none"
{children} stroke="currentColor"
</div> viewBox="0 0 24 24"
</details> >
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
{toolName && (
<span className="flex-shrink-0 font-medium">{toolName}</span>
)}
{toolName && (
<span className="flex-shrink-0 text-[10px] text-muted-foreground/40">/</span>
)}
<span className="flex-1 truncate text-left">{title}</span>
{badge && <span className="ml-auto flex-shrink-0">{badge}</span>}
{action && <span className="ml-1 flex-shrink-0">{action}</span>}
</CollapsibleTrigger>
)}
<CollapsibleContent>
<div className="mt-1.5 pl-[18px]">
{children}
</div>
</CollapsibleContent>
</Collapsible>
); );
}; };

View File

@@ -1,114 +1,21 @@
import { memo, useMemo } from 'react'; import { memo, useMemo } from 'react';
import { CheckCircle2, Circle, Clock, type LucideIcon } from 'lucide-react'; import { Queue, QueueItem, QueueItemIndicator, QueueItemContent } from '../../../../../shared/view/ui';
import { Badge } from '../../../../../shared/view/ui'; import type { QueueItemStatus } from '../../../../../shared/view/ui';
type TodoStatus = 'completed' | 'in_progress' | 'pending';
type TodoPriority = 'high' | 'medium' | 'low';
export type TodoItem = { export type TodoItem = {
id?: string; id?: string;
content: string; content: string;
status: string; status: string;
priority?: string; priority?: string;
activeForm?: string;
}; };
type NormalizedTodoItem = { const normalizeStatus = (status: string): QueueItemStatus => {
id?: string; if (status === 'completed') return 'completed';
content: string; if (status === 'in_progress') return 'in_progress';
status: TodoStatus;
priority: TodoPriority;
};
type StatusConfig = {
icon: LucideIcon;
iconClassName: string;
badgeClassName: string;
textClassName: string;
};
// Centralized visual config keeps rendering logic compact and easier to scan.
const STATUS_CONFIG: Record<TodoStatus, StatusConfig> = {
completed: {
icon: CheckCircle2,
iconClassName: 'w-3.5 h-3.5 text-green-500 dark:text-green-400',
badgeClassName:
'bg-green-100 dark:bg-green-900/30 text-green-800 dark:text-green-200 border-green-200 dark:border-green-800',
textClassName: 'line-through text-gray-500 dark:text-gray-400',
},
in_progress: {
icon: Clock,
iconClassName: 'w-3.5 h-3.5 text-blue-500 dark:text-blue-400',
badgeClassName:
'bg-blue-100 dark:bg-blue-900/30 text-blue-800 dark:text-blue-200 border-blue-200 dark:border-blue-800',
textClassName: 'text-gray-900 dark:text-gray-100',
},
pending: {
icon: Circle,
iconClassName: 'w-3.5 h-3.5 text-gray-400 dark:text-gray-500',
badgeClassName:
'bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-400 border-gray-200 dark:border-gray-700',
textClassName: 'text-gray-900 dark:text-gray-100',
},
};
const PRIORITY_BADGE_CLASS: Record<TodoPriority, string> = {
high: 'bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-300 border-red-200 dark:border-red-800',
medium:
'bg-yellow-100 dark:bg-yellow-900/30 text-yellow-700 dark:text-yellow-300 border-yellow-200 dark:border-yellow-800',
low: 'bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-400 border-gray-200 dark:border-gray-700',
};
// Incoming tool payloads can vary; normalize to supported UI states.
const normalizeStatus = (status: string): TodoStatus => {
if (status === 'completed' || status === 'in_progress') {
return status;
}
return 'pending'; return 'pending';
}; };
const normalizePriority = (priority?: string): TodoPriority => {
if (priority === 'high' || priority === 'medium') {
return priority;
}
return 'low';
};
const TodoRow = memo(
({ todo }: { todo: NormalizedTodoItem }) => {
const statusConfig = STATUS_CONFIG[todo.status];
const StatusIcon = statusConfig.icon;
return (
<div className="flex items-start gap-2 rounded border border-gray-200 bg-white p-2 transition-colors dark:border-gray-700 dark:bg-gray-800">
<div className="mt-0.5 flex-shrink-0">
<StatusIcon className={statusConfig.iconClassName} />
</div>
<div className="min-w-0 flex-1">
<div className="mb-0.5 flex items-start justify-between gap-2">
<p className={`text-xs font-medium ${statusConfig.textClassName}`}>
{todo.content}
</p>
<div className="flex flex-shrink-0 gap-1">
<Badge
variant="outline"
className={`px-1.5 py-px text-[10px] ${PRIORITY_BADGE_CLASS[todo.priority]}`}
>
{todo.priority}
</Badge>
<Badge
variant="outline"
className={`px-1.5 py-px text-[10px] ${statusConfig.badgeClassName}`}
>
{todo.status.replace('_', ' ')}
</Badge>
</div>
</div>
</div>
</div>
);
}
);
const TodoList = memo( const TodoList = memo(
({ ({
todos, todos,
@@ -117,36 +24,33 @@ const TodoList = memo(
todos: TodoItem[]; todos: TodoItem[];
isResult?: boolean; isResult?: boolean;
}) => { }) => {
// Memoize normalization to avoid recomputing list metadata on every render. const normalized = useMemo(
const normalizedTodos = useMemo<NormalizedTodoItem[]>( () => todos.map((todo) => ({ ...todo, queueStatus: normalizeStatus(todo.status) })),
() => [todos],
todos.map((todo) => ({
id: todo.id,
content: todo.content,
status: normalizeStatus(todo.status),
priority: normalizePriority(todo.priority),
})),
[todos]
); );
if (normalizedTodos.length === 0) { if (normalized.length === 0) return null;
return null;
}
return ( return (
<div className="space-y-1.5"> <div>
{isResult && ( {isResult && (
<div className="mb-1.5 text-xs font-medium text-gray-600 dark:text-gray-400"> <div className="mb-1.5 text-xs font-medium text-muted-foreground">
Todo List ({normalizedTodos.length}{' '} Todo List ({normalized.length} {normalized.length === 1 ? 'item' : 'items'})
{normalizedTodos.length === 1 ? 'item' : 'items'})
</div> </div>
)} )}
{normalizedTodos.map((todo, index) => ( <Queue>
<TodoRow key={todo.id ?? `${todo.content}-${index}`} todo={todo} /> {normalized.map((todo, index) => (
))} <QueueItem key={todo.id ?? `${todo.content}-${index}`} status={todo.queueStatus}>
<QueueItemIndicator />
<QueueItemContent>{todo.content}</QueueItemContent>
</QueueItem>
))}
</Queue>
</div> </div>
); );
} },
); );
TodoList.displayName = 'TodoList';
export default TodoList; export default TodoList;

View File

@@ -1,5 +1,7 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import { copyTextToClipboard } from '../../../../utils/clipboard'; import { copyTextToClipboard } from '../../../../utils/clipboard';
import { ToolStatusBadge } from './ToolStatusBadge';
import type { ToolStatus } from './ToolStatusBadge';
type ActionType = 'copy' | 'open-file' | 'jump-to-results' | 'none'; type ActionType = 'copy' | 'open-file' | 'jump-to-results' | 'none';
@@ -23,6 +25,7 @@ interface OneLineDisplayProps {
resultId?: string; resultId?: string;
toolResult?: any; toolResult?: any;
toolId?: string; toolId?: string;
status?: ToolStatus;
} }
/** /**
@@ -40,14 +43,15 @@ export const OneLineDisplay: React.FC<OneLineDisplayProps> = ({
style, style,
wrapText = false, wrapText = false,
colorScheme = { colorScheme = {
primary: 'text-gray-700 dark:text-gray-300', primary: 'text-foreground',
secondary: 'text-gray-500 dark:text-gray-400', secondary: 'text-muted-foreground',
background: '', background: '',
border: 'border-gray-300 dark:border-gray-600', border: 'border-border',
icon: 'text-gray-500 dark:text-gray-400' icon: 'text-muted-foreground',
}, },
toolResult, toolResult,
toolId toolId,
status,
}) => { }) => {
const [copied, setCopied] = useState(false); const [copied, setCopied] = useState(false);
const isTerminal = style === 'terminal'; const isTerminal = style === 'terminal';
@@ -55,9 +59,7 @@ export const OneLineDisplay: React.FC<OneLineDisplayProps> = ({
const handleAction = async () => { const handleAction = async () => {
if (action === 'copy' && value) { if (action === 'copy' && value) {
const didCopy = await copyTextToClipboard(value); const didCopy = await copyTextToClipboard(value);
if (!didCopy) { if (!didCopy) return;
return;
}
setCopied(true); setCopied(true);
setTimeout(() => setCopied(false), 2000); setTimeout(() => setCopied(false), 2000);
} else if (onAction) { } else if (onAction) {
@@ -68,7 +70,7 @@ export const OneLineDisplay: React.FC<OneLineDisplayProps> = ({
const renderCopyButton = () => ( const renderCopyButton = () => (
<button <button
onClick={handleAction} onClick={handleAction}
className="ml-1 flex-shrink-0 text-gray-400 opacity-0 transition-all hover:text-gray-600 group-hover:opacity-100 dark:hover:text-gray-200" className="ml-1 flex-shrink-0 text-muted-foreground/40 opacity-0 transition-all hover:text-muted-foreground group-hover:opacity-100"
title="Copy to clipboard" title="Copy to clipboard"
aria-label="Copy to clipboard" aria-label="Copy to clipboard"
> >
@@ -84,7 +86,7 @@ export const OneLineDisplay: React.FC<OneLineDisplayProps> = ({
</button> </button>
); );
// Terminal style: dark pill only around the command // Terminal style: dark pill around the command
if (isTerminal) { if (isTerminal) {
return ( return (
<div className="group my-1"> <div className="group my-1">
@@ -100,12 +102,13 @@ export const OneLineDisplay: React.FC<OneLineDisplayProps> = ({
<span className="select-none text-green-600 dark:text-green-500">$ </span>{value} <span className="select-none text-green-600 dark:text-green-500">$ </span>{value}
</code> </code>
</div> </div>
{status && <ToolStatusBadge status={status} className="mt-0.5" />}
{action === 'copy' && renderCopyButton()} {action === 'copy' && renderCopyButton()}
</div> </div>
</div> </div>
{secondary && ( {secondary && (
<div className="ml-7 mt-1"> <div className="ml-7 mt-1">
<span className="text-[11px] italic text-gray-400 dark:text-gray-500"> <span className="text-[11px] italic text-muted-foreground/60">
{secondary} {secondary}
</span> </span>
</div> </div>
@@ -114,20 +117,21 @@ export const OneLineDisplay: React.FC<OneLineDisplayProps> = ({
); );
} }
// File open style - show filename only, full path on hover // File open style
if (action === 'open-file') { if (action === 'open-file') {
const displayName = value.split('/').pop() || value; const displayName = value.split('/').pop() || value;
return ( return (
<div className={`group flex items-center gap-1.5 border-l-2 ${colorScheme.border} my-0.5 py-0.5 pl-3`}> <div className={`group flex items-center gap-1.5 border-l-2 ${colorScheme.border} my-0.5 py-0.5 pl-3`}>
<span className="flex-shrink-0 text-xs text-gray-500 dark:text-gray-400">{label || toolName}</span> <span className="flex-shrink-0 text-xs text-muted-foreground">{label || toolName}</span>
<span className="text-[10px] text-gray-300 dark:text-gray-600">/</span> <span className="text-[10px] text-muted-foreground/40">/</span>
<button <button
onClick={handleAction} onClick={handleAction}
className="truncate font-mono text-xs text-blue-600 transition-colors hover:text-blue-700 hover:underline dark:text-blue-400 dark:hover:text-blue-300" className="truncate font-mono text-xs text-primary transition-colors hover:text-primary/80 hover:underline"
title={value} title={value}
> >
{displayName} {displayName}
</button> </button>
{status && <ToolStatusBadge status={status} className="ml-auto" />}
</div> </div>
); );
} }
@@ -136,20 +140,21 @@ export const OneLineDisplay: React.FC<OneLineDisplayProps> = ({
if (action === 'jump-to-results') { if (action === 'jump-to-results') {
return ( return (
<div className={`group flex items-center gap-1.5 border-l-2 ${colorScheme.border} my-0.5 py-0.5 pl-3`}> <div className={`group flex items-center gap-1.5 border-l-2 ${colorScheme.border} my-0.5 py-0.5 pl-3`}>
<span className="flex-shrink-0 text-xs text-gray-500 dark:text-gray-400">{label || toolName}</span> <span className="flex-shrink-0 text-xs text-muted-foreground">{label || toolName}</span>
<span className="text-[10px] text-gray-300 dark:text-gray-600">/</span> <span className="text-[10px] text-muted-foreground/40">/</span>
<span className={`min-w-0 flex-1 truncate font-mono text-xs ${colorScheme.primary}`}> <span className={`min-w-0 flex-1 truncate font-mono text-xs ${colorScheme.primary}`}>
{value} {value}
</span> </span>
{secondary && ( {secondary && (
<span className="flex-shrink-0 text-[11px] italic text-gray-400 dark:text-gray-500"> <span className="flex-shrink-0 text-[11px] italic text-muted-foreground/60">
{secondary} {secondary}
</span> </span>
)} )}
{status && <ToolStatusBadge status={status} />}
{toolResult && ( {toolResult && (
<a <a
href={`#tool-result-${toolId}`} href={`#tool-result-${toolId}`}
className="flex flex-shrink-0 items-center gap-0.5 text-[11px] text-blue-600 transition-colors hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300" className="flex flex-shrink-0 items-center gap-0.5 text-[11px] text-primary transition-colors hover:text-primary/80"
> >
<svg className="h-3 w-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg className="h-3 w-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
@@ -167,10 +172,10 @@ export const OneLineDisplay: React.FC<OneLineDisplayProps> = ({
<span className={`${colorScheme.icon} flex-shrink-0 text-xs`}>{icon}</span> <span className={`${colorScheme.icon} flex-shrink-0 text-xs`}>{icon}</span>
)} )}
{!icon && (label || toolName) && ( {!icon && (label || toolName) && (
<span className="flex-shrink-0 text-xs text-gray-500 dark:text-gray-400">{label || toolName}</span> <span className="flex-shrink-0 text-xs text-muted-foreground">{label || toolName}</span>
)} )}
{(icon || label || toolName) && ( {(icon || label || toolName) && (
<span className="text-[10px] text-gray-300 dark:text-gray-600">/</span> <span className="text-[10px] text-muted-foreground/40">/</span>
)} )}
<span className={`font-mono text-xs ${wrapText ? 'whitespace-pre-wrap break-all' : 'truncate'} min-w-0 flex-1 ${colorScheme.primary}`}> <span className={`font-mono text-xs ${wrapText ? 'whitespace-pre-wrap break-all' : 'truncate'} min-w-0 flex-1 ${colorScheme.primary}`}>
{value} {value}
@@ -180,6 +185,7 @@ export const OneLineDisplay: React.FC<OneLineDisplayProps> = ({
{secondary} {secondary}
</span> </span>
)} )}
{status && <ToolStatusBadge status={status} />}
{action === 'copy' && renderCopyButton()} {action === 'copy' && renderCopyButton()}
</div> </div>
); );

View File

@@ -1,6 +1,7 @@
import React from 'react'; import React from 'react';
import type { SubagentChildTool } from '../../types/types'; import type { SubagentChildTool } from '../../types/types';
import { CollapsibleSection } from './CollapsibleSection'; import { CollapsibleSection } from './CollapsibleSection';
import { Collapsible, CollapsibleTrigger, CollapsibleContent } from '../../../../shared/view/ui';
interface SubagentContainerProps { interface SubagentContainerProps {
toolInput: unknown; toolInput: unknown;
@@ -65,21 +66,21 @@ export const SubagentContainer: React.FC<SubagentContainerProps> = ({
> >
{/* Prompt/request to the subagent */} {/* Prompt/request to the subagent */}
{prompt && ( {prompt && (
<div className="mb-2 line-clamp-4 whitespace-pre-wrap break-words text-xs text-gray-600 dark:text-gray-400"> <div className="mb-2 line-clamp-4 whitespace-pre-wrap break-words text-xs text-muted-foreground">
{prompt} {prompt}
</div> </div>
)} )}
{/* Current tool indicator (while running) */} {/* Current tool indicator (while running) */}
{currentTool && !isComplete && ( {currentTool && !isComplete && (
<div className="mt-1 flex items-center gap-1.5 text-xs text-gray-500 dark:text-gray-400"> <div className="mt-1 flex items-center gap-1.5 text-xs text-muted-foreground">
<span className="h-1.5 w-1.5 flex-shrink-0 animate-pulse rounded-full bg-purple-500 dark:bg-purple-400" /> <span className="h-1.5 w-1.5 flex-shrink-0 animate-pulse rounded-full bg-purple-500 dark:bg-purple-400" />
<span className="text-gray-400 dark:text-gray-500">Currently:</span> <span className="text-muted-foreground/60">Currently:</span>
<span className="font-medium text-gray-600 dark:text-gray-300">{currentTool.toolName}</span> <span className="font-medium text-foreground">{currentTool.toolName}</span>
{getCompactToolDisplay(currentTool.toolName, currentTool.toolInput) && ( {getCompactToolDisplay(currentTool.toolName, currentTool.toolInput) && (
<> <>
<span className="text-gray-300 dark:text-gray-600">/</span> <span className="text-muted-foreground/40">/</span>
<span className="truncate font-mono text-gray-500 dark:text-gray-400"> <span className="truncate font-mono text-muted-foreground">
{getCompactToolDisplay(currentTool.toolName, currentTool.toolInput)} {getCompactToolDisplay(currentTool.toolName, currentTool.toolInput)}
</span> </span>
</> </>
@@ -99,10 +100,10 @@ export const SubagentContainer: React.FC<SubagentContainerProps> = ({
{/* Tool history (collapsed) */} {/* Tool history (collapsed) */}
{childTools.length > 0 && ( {childTools.length > 0 && (
<details className="group/history mt-2"> <Collapsible className="mt-2">
<summary className="flex cursor-pointer items-center gap-1 text-[11px] text-gray-400 hover:text-gray-600 dark:text-gray-500 dark:hover:text-gray-300"> <CollapsibleTrigger className="flex items-center gap-1 text-[11px] text-muted-foreground hover:text-foreground">
<svg <svg
className="h-2.5 w-2.5 flex-shrink-0 transition-transform duration-150 group-open/history:rotate-90" className="h-2.5 w-2.5 flex-shrink-0 transition-transform duration-150 data-[state=open]:rotate-90"
fill="none" fill="none"
stroke="currentColor" stroke="currentColor"
viewBox="0 0 24 24" viewBox="0 0 24 24"
@@ -110,29 +111,31 @@ export const SubagentContainer: React.FC<SubagentContainerProps> = ({
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg> </svg>
<span>View tool history ({childTools.length})</span> <span>View tool history ({childTools.length})</span>
</summary> </CollapsibleTrigger>
<div className="mt-1 space-y-0.5 border-l border-gray-200 pl-3 dark:border-gray-700"> <CollapsibleContent>
{childTools.map((child, index) => ( <div className="mt-1 space-y-0.5 border-l border-border pl-3">
<div key={child.toolId} className="flex items-center gap-1.5 text-[11px] text-gray-500 dark:text-gray-400"> {childTools.map((child, index) => (
<span className="w-4 flex-shrink-0 text-right text-gray-400 dark:text-gray-500">{index + 1}.</span> <div key={child.toolId} className="flex items-center gap-1.5 text-[11px] text-muted-foreground">
<span className="font-medium">{child.toolName}</span> <span className="w-4 flex-shrink-0 text-right text-muted-foreground/60">{index + 1}.</span>
{getCompactToolDisplay(child.toolName, child.toolInput) && ( <span className="font-medium text-foreground">{child.toolName}</span>
<span className="truncate font-mono text-gray-400 dark:text-gray-500"> {getCompactToolDisplay(child.toolName, child.toolInput) && (
{getCompactToolDisplay(child.toolName, child.toolInput)} <span className="truncate font-mono text-muted-foreground/70">
</span> {getCompactToolDisplay(child.toolName, child.toolInput)}
)} </span>
{child.toolResult?.isError && ( )}
<span className="flex-shrink-0 text-red-500">(error)</span> {child.toolResult?.isError && (
)} <span className="flex-shrink-0 text-red-500">(error)</span>
</div> )}
))} </div>
</div> ))}
</details> </div>
</CollapsibleContent>
</Collapsible>
)} )}
{/* Final result */} {/* Final result */}
{isComplete && toolResult && ( {isComplete && toolResult && (
<div className="mt-2 text-xs text-gray-600 dark:text-gray-400"> <div className="mt-2 text-xs text-muted-foreground">
{(() => { {(() => {
let content = toolResult.content; let content = toolResult.content;

View File

@@ -0,0 +1,42 @@
import { cn } from '../../../../lib/utils';
export type ToolStatus = 'running' | 'completed' | 'error' | 'denied';
const STATUS_CONFIG: Record<ToolStatus, { label: string; className: string }> = {
running: {
label: 'Running',
className: 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-300',
},
completed: {
label: 'Completed',
className: 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-300',
},
error: {
label: 'Error',
className: 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-300',
},
denied: {
label: 'Denied',
className: 'bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-300',
},
};
interface ToolStatusBadgeProps {
status: ToolStatus;
className?: string;
}
export function ToolStatusBadge({ status, className }: ToolStatusBadgeProps) {
const config = STATUS_CONFIG[status];
return (
<span
className={cn(
'inline-flex items-center rounded px-1.5 py-px text-[10px] font-medium',
config.className,
className,
)}
>
{config.label}
</span>
);
}

View File

@@ -5,3 +5,5 @@ export { CollapsibleDisplay } from './CollapsibleDisplay';
export { SubagentContainer } from './SubagentContainer'; export { SubagentContainer } from './SubagentContainer';
export * from './ContentRenderers'; export * from './ContentRenderers';
export * from './InteractiveRenderers'; export * from './InteractiveRenderers';
export { ToolStatusBadge } from './ToolStatusBadge';
export type { ToolStatus } from './ToolStatusBadge';

View File

@@ -98,7 +98,6 @@ interface ChatComposerProps {
onTextareaScrollSync: (target: HTMLTextAreaElement) => void; onTextareaScrollSync: (target: HTMLTextAreaElement) => void;
onTextareaInput: (event: FormEvent<HTMLTextAreaElement>) => void; onTextareaInput: (event: FormEvent<HTMLTextAreaElement>) => void;
onInputFocusChange?: (focused: boolean) => void; onInputFocusChange?: (focused: boolean) => void;
isInputFocused?: boolean;
placeholder: string; placeholder: string;
isTextareaExpanded: boolean; isTextareaExpanded: boolean;
sendByCtrlEnter?: boolean; sendByCtrlEnter?: boolean;
@@ -154,7 +153,6 @@ export default function ChatComposer({
onTextareaScrollSync, onTextareaScrollSync,
onTextareaInput, onTextareaInput,
onInputFocusChange, onInputFocusChange,
isInputFocused,
placeholder, placeholder,
isTextareaExpanded, isTextareaExpanded,
sendByCtrlEnter, sendByCtrlEnter,
@@ -175,13 +173,8 @@ export default function ChatComposer({
// Hide the thinking/status bar while any permission request is pending // Hide the thinking/status bar while any permission request is pending
const hasPendingPermissions = pendingPermissionRequests.length > 0; const hasPendingPermissions = pendingPermissionRequests.length > 0;
// On mobile, when input is focused, float the input box at the bottom
const mobileFloatingClass = isInputFocused
? 'max-sm:fixed max-sm:bottom-0 max-sm:left-0 max-sm:right-0 max-sm:z-50 max-sm:bg-background max-sm:shadow-[0_-4px_20px_rgba(0,0,0,0.15)]'
: '';
return ( return (
<div className={`flex-shrink-0 p-2 pb-2 sm:p-4 sm:pb-4 md:p-4 md:pb-6 ${mobileFloatingClass}`}> <div className="flex-shrink-0 p-2 pb-2 sm:p-4 sm:pb-4 md:p-4 md:pb-6">
{!hasPendingPermissions && ( {!hasPendingPermissions && (
<ClaudeStatus <ClaudeStatus
status={claudeStatus} status={claudeStatus}

View File

@@ -0,0 +1,122 @@
import * as React from 'react';
import { cn } from '../../../lib/utils';
/* ─── Types ──────────────────────────────────────────────────────── */
export type QueueItemStatus = 'completed' | 'in_progress' | 'pending';
/* ─── Context ────────────────────────────────────────────────────── */
interface QueueItemContextValue {
status: QueueItemStatus;
}
const QueueItemContext = React.createContext<QueueItemContextValue | null>(null);
function useQueueItem() {
const ctx = React.useContext(QueueItemContext);
if (!ctx) throw new Error('QueueItem sub-components must be used within <QueueItem>');
return ctx;
}
/* ─── Queue ──────────────────────────────────────────────────────── */
export const Queue = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div
ref={ref}
data-slot="queue"
role="list"
className={cn('space-y-0.5', className)}
{...props}
/>
),
);
Queue.displayName = 'Queue';
/* ─── QueueItem ──────────────────────────────────────────────────── */
export interface QueueItemProps extends React.HTMLAttributes<HTMLDivElement> {
status?: QueueItemStatus;
}
export const QueueItem = React.forwardRef<HTMLDivElement, QueueItemProps>(
({ status = 'pending', className, children, ...props }, ref) => {
const value = React.useMemo(() => ({ status }), [status]);
return (
<QueueItemContext.Provider value={value}>
<div
ref={ref}
data-slot="queue-item"
data-status={status}
role="listitem"
className={cn('flex items-start gap-2 py-0.5', className)}
{...props}
>
{children}
</div>
</QueueItemContext.Provider>
);
},
);
QueueItem.displayName = 'QueueItem';
/* ─── QueueItemIndicator ─────────────────────────────────────────── */
export const QueueItemIndicator = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => {
const { status } = useQueueItem();
return (
<div
ref={ref}
data-slot="queue-item-indicator"
aria-hidden="true"
className={cn('mt-0.5 flex h-3.5 w-3.5 flex-shrink-0 items-center justify-center', className)}
{...props}
>
{status === 'completed' && (
<svg className="h-3.5 w-3.5 text-green-500 dark:text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
)}
{status === 'in_progress' && (
<span className="h-2 w-2 animate-pulse rounded-full bg-blue-500 dark:bg-blue-400" />
)}
{status === 'pending' && (
<svg className="h-3.5 w-3.5 text-muted-foreground/50" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<circle cx="12" cy="12" r="9" strokeWidth={2} />
</svg>
)}
</div>
);
},
);
QueueItemIndicator.displayName = 'QueueItemIndicator';
/* ─── QueueItemContent ───────────────────────────────────────────── */
export const QueueItemContent = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, children, ...props }, ref) => {
const { status } = useQueueItem();
return (
<div
ref={ref}
data-slot="queue-item-content"
className={cn(
'min-w-0 flex-1 text-xs',
status === 'completed' && 'text-muted-foreground line-through',
status === 'in_progress' && 'font-medium text-foreground',
status === 'pending' && 'text-foreground',
className,
)}
{...props}
>
{children}
</div>
);
},
);
QueueItemContent.displayName = 'QueueItemContent';

View File

@@ -14,3 +14,5 @@ export { Shimmer } from './Shimmer';
export { default as Tooltip } from './Tooltip'; export { default as Tooltip } from './Tooltip';
export { PromptInput, PromptInputHeader, PromptInputBody, PromptInputTextarea, PromptInputFooter, PromptInputTools, PromptInputButton, PromptInputSubmit } from './PromptInput'; export { PromptInput, PromptInputHeader, PromptInputBody, PromptInputTextarea, PromptInputFooter, PromptInputTools, PromptInputButton, PromptInputSubmit } from './PromptInput';
export { PillBar, Pill } from './PillBar'; export { PillBar, Pill } from './PillBar';
export { Queue, QueueItem, QueueItemIndicator, QueueItemContent } from './Queue';
export type { QueueItemStatus } from './Queue';