mirror of
https://github.com/siteboon/claudecodeui.git
synced 2026-06-09 07:05:51 +08:00
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:
@@ -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} />
|
||||||
|
|||||||
@@ -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)}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
42
src/components/chat/tools/components/ToolStatusBadge.tsx
Normal file
42
src/components/chat/tools/components/ToolStatusBadge.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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';
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
122
src/shared/view/ui/Queue.tsx
Normal file
122
src/shared/view/ui/Queue.tsx
Normal 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';
|
||||||
@@ -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';
|
||||||
|
|||||||
Reference in New Issue
Block a user