diff --git a/src/components/app/AppContent.tsx b/src/components/app/AppContent.tsx index e9ac674a..33d3ea29 100644 --- a/src/components/app/AppContent.tsx +++ b/src/components/app/AppContent.tsx @@ -122,8 +122,28 @@ export default function AppContent() { } }, [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 ( -
+
{!isMobile ? (
diff --git a/src/components/chat/tools/ToolRenderer.tsx b/src/components/chat/tools/ToolRenderer.tsx index 7023a1ac..5713fcc7 100644 --- a/src/components/chat/tools/ToolRenderer.tsx +++ b/src/components/chat/tools/ToolRenderer.tsx @@ -6,6 +6,8 @@ import type { SubagentChildTool } from '../types/types'; import { getToolConfig } from './configs/toolConfigs'; import { OneLineDisplay, CollapsibleDisplay, ToolDiffViewer, MarkdownContent, FileListContent, TodoListContent, TaskListContent, TextContent, QuestionAnswerContent, SubagentContainer } from './components'; import { PlanDisplay } from './components/PlanDisplay'; +import { ToolStatusBadge } from './components/ToolStatusBadge'; +import type { ToolStatus } from './components/ToolStatusBadge'; type DiffLine = { type: string; @@ -39,12 +41,24 @@ function getToolCategory(toolName: string): string { if (toolName === 'Bash') return 'bash'; if (['TodoWrite', 'TodoRead'].includes(toolName)) return 'todo'; 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 === 'AskUserQuestion') return 'question'; 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 * Routes to OneLineDisplay or CollapsibleDisplay based on tool config @@ -76,6 +90,12 @@ export const ToolRenderer: React.FC = memo(({ } }, [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(() => { if (displayConfig?.action === 'open-file' && onFileOpen) { const value = displayConfig.getValue?.(parsedData) || ''; @@ -85,9 +105,7 @@ export const ToolRenderer: React.FC = memo(({ // Route subagent containers to dedicated component (after hooks to satisfy Rules of Hooks) if (isSubagentContainer && subagentState) { - if (mode === 'result') { - return null; - } + if (mode === 'result') return null; return ( = memo(({ wrapText={displayConfig.wrapText} colorScheme={displayConfig.colorScheme} resultId={mode === 'input' ? `tool-result-${toolId}` : undefined} + status={toolStatus} /> ); } @@ -164,7 +183,6 @@ export const ToolRenderer: React.FC = memo(({ onFileOpen }) || {}; - // Build the content component based on contentType let contentComponent: React.ReactNode = null; switch (displayConfig.contentType) { @@ -241,7 +259,6 @@ export const ToolRenderer: React.FC = memo(({ } } - // For edit tools, make the title (filename) clickable to open the file const handleTitleClick = (toolName === 'Edit' || toolName === 'Write' || toolName === 'ApplyPatch') && contentProps.filePath && onFileOpen ? () => onFileOpen(contentProps.filePath, { old_string: contentProps.oldContent, @@ -249,6 +266,8 @@ export const ToolRenderer: React.FC = memo(({ }) : undefined; + const badgeElement = toolStatus ? : undefined; + return ( = memo(({ title={title} defaultOpen={defaultOpen} onTitleClick={handleTitleClick} + badge={badgeElement} showRawParameters={mode === 'input' && showRawParameters} rawContent={rawToolInput} toolCategory={getToolCategory(toolName)} diff --git a/src/components/chat/tools/components/CollapsibleDisplay.tsx b/src/components/chat/tools/components/CollapsibleDisplay.tsx index 1175893e..51faf12b 100644 --- a/src/components/chat/tools/components/CollapsibleDisplay.tsx +++ b/src/components/chat/tools/components/CollapsibleDisplay.tsx @@ -1,4 +1,5 @@ import React from 'react'; +import { Collapsible, CollapsibleTrigger, CollapsibleContent } from '../../../../shared/view/ui'; import { CollapsibleSection } from './CollapsibleSection'; interface CollapsibleDisplayProps { @@ -7,6 +8,7 @@ interface CollapsibleDisplayProps { title: string; defaultOpen?: boolean; action?: React.ReactNode; + badge?: React.ReactNode; onTitleClick?: () => void; children: React.ReactNode; showRawParameters?: boolean; @@ -17,14 +19,14 @@ interface CollapsibleDisplayProps { const borderColorMap: Record = { 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', todo: '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', plan: 'border-l-indigo-500 dark:border-l-indigo-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 = ({ @@ -32,14 +34,14 @@ export const CollapsibleDisplay: React.FC = ({ title, defaultOpen = false, action, + badge, onTitleClick, children, showRawParameters = false, rawContent, className = '', - toolCategory + toolCategory, }) => { - // Fall back to default styling for unknown/new categories so className never includes "undefined". const borderColor = borderColorMap[toolCategory || 'default'] || borderColorMap.default; return ( @@ -49,15 +51,16 @@ export const CollapsibleDisplay: React.FC = ({ toolName={toolName} open={defaultOpen} action={action} + badge={badge} onTitleClick={onTitleClick} > {children} {showRawParameters && rawContent && ( -
- + + = ({ raw params - -
-              {rawContent}
-            
-
+ + +
+                {rawContent}
+              
+
+ )}
diff --git a/src/components/chat/tools/components/CollapsibleSection.tsx b/src/components/chat/tools/components/CollapsibleSection.tsx index c19e8e8e..8e1f3185 100644 --- a/src/components/chat/tools/components/CollapsibleSection.tsx +++ b/src/components/chat/tools/components/CollapsibleSection.tsx @@ -1,10 +1,13 @@ import React from 'react'; +import { Collapsible, CollapsibleTrigger, CollapsibleContent } from '../../../../shared/view/ui'; +import { cn } from '../../../../lib/utils'; interface CollapsibleSectionProps { title: string; toolName?: string; open?: boolean; action?: React.ReactNode; + badge?: React.ReactNode; onTitleClick?: () => void; children: React.ReactNode; className?: string; @@ -18,44 +21,68 @@ export const CollapsibleSection: React.FC = ({ toolName, open = false, action, + badge, onTitleClick, children, - className = '' + className = '', }) => { return ( -
- - - - - {toolName && ( - {toolName} - )} - {toolName && ( - / - )} - {onTitleClick ? ( + + {/* When there's a clickable title (Edit/Write), only the chevron toggles collapse */} + {onTitleClick ? ( +
+ + + + + + {toolName && ( + {toolName} + )} + {toolName && ( + / + )} - ) : ( - - {title} - - )} - {action && {action}} -
-
- {children} -
-
+ {badge && {badge}} + {action && {action}} +
+ ) : ( + + + + + {toolName && ( + {toolName} + )} + {toolName && ( + / + )} + {title} + {badge && {badge}} + {action && {action}} + + )} + + +
+ {children} +
+
+ ); }; diff --git a/src/components/chat/tools/components/ContentRenderers/TodoList.tsx b/src/components/chat/tools/components/ContentRenderers/TodoList.tsx index a9e0a403..8d9ccd49 100644 --- a/src/components/chat/tools/components/ContentRenderers/TodoList.tsx +++ b/src/components/chat/tools/components/ContentRenderers/TodoList.tsx @@ -1,114 +1,21 @@ import { memo, useMemo } from 'react'; -import { CheckCircle2, Circle, Clock, type LucideIcon } from 'lucide-react'; -import { Badge } from '../../../../../shared/view/ui'; - -type TodoStatus = 'completed' | 'in_progress' | 'pending'; -type TodoPriority = 'high' | 'medium' | 'low'; +import { Queue, QueueItem, QueueItemIndicator, QueueItemContent } from '../../../../../shared/view/ui'; +import type { QueueItemStatus } from '../../../../../shared/view/ui'; export type TodoItem = { id?: string; content: string; status: string; priority?: string; + activeForm?: string; }; -type NormalizedTodoItem = { - id?: string; - content: string; - 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 = { - 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 = { - 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; - } +const normalizeStatus = (status: string): QueueItemStatus => { + if (status === 'completed') return 'completed'; + if (status === 'in_progress') return 'in_progress'; 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 ( -
-
- -
-
-
-

- {todo.content} -

-
- - {todo.priority} - - - {todo.status.replace('_', ' ')} - -
-
-
-
- ); - } -); - const TodoList = memo( ({ todos, @@ -117,36 +24,33 @@ const TodoList = memo( todos: TodoItem[]; isResult?: boolean; }) => { - // Memoize normalization to avoid recomputing list metadata on every render. - const normalizedTodos = useMemo( - () => - todos.map((todo) => ({ - id: todo.id, - content: todo.content, - status: normalizeStatus(todo.status), - priority: normalizePriority(todo.priority), - })), - [todos] + const normalized = useMemo( + () => todos.map((todo) => ({ ...todo, queueStatus: normalizeStatus(todo.status) })), + [todos], ); - if (normalizedTodos.length === 0) { - return null; - } + if (normalized.length === 0) return null; return ( -
+
{isResult && ( -
- Todo List ({normalizedTodos.length}{' '} - {normalizedTodos.length === 1 ? 'item' : 'items'}) +
+ Todo List ({normalized.length} {normalized.length === 1 ? 'item' : 'items'})
)} - {normalizedTodos.map((todo, index) => ( - - ))} + + {normalized.map((todo, index) => ( + + + {todo.content} + + ))} +
); - } + }, ); +TodoList.displayName = 'TodoList'; + export default TodoList; diff --git a/src/components/chat/tools/components/OneLineDisplay.tsx b/src/components/chat/tools/components/OneLineDisplay.tsx index a3fcd421..1b6e5ffd 100644 --- a/src/components/chat/tools/components/OneLineDisplay.tsx +++ b/src/components/chat/tools/components/OneLineDisplay.tsx @@ -1,5 +1,7 @@ import React, { useState } from 'react'; import { copyTextToClipboard } from '../../../../utils/clipboard'; +import { ToolStatusBadge } from './ToolStatusBadge'; +import type { ToolStatus } from './ToolStatusBadge'; type ActionType = 'copy' | 'open-file' | 'jump-to-results' | 'none'; @@ -23,6 +25,7 @@ interface OneLineDisplayProps { resultId?: string; toolResult?: any; toolId?: string; + status?: ToolStatus; } /** @@ -40,14 +43,15 @@ export const OneLineDisplay: React.FC = ({ style, wrapText = false, colorScheme = { - primary: 'text-gray-700 dark:text-gray-300', - secondary: 'text-gray-500 dark:text-gray-400', + primary: 'text-foreground', + secondary: 'text-muted-foreground', background: '', - border: 'border-gray-300 dark:border-gray-600', - icon: 'text-gray-500 dark:text-gray-400' + border: 'border-border', + icon: 'text-muted-foreground', }, toolResult, - toolId + toolId, + status, }) => { const [copied, setCopied] = useState(false); const isTerminal = style === 'terminal'; @@ -55,9 +59,7 @@ export const OneLineDisplay: React.FC = ({ const handleAction = async () => { if (action === 'copy' && value) { const didCopy = await copyTextToClipboard(value); - if (!didCopy) { - return; - } + if (!didCopy) return; setCopied(true); setTimeout(() => setCopied(false), 2000); } else if (onAction) { @@ -68,7 +70,7 @@ export const OneLineDisplay: React.FC = ({ const renderCopyButton = () => ( ); - // Terminal style: dark pill only around the command + // Terminal style: dark pill around the command if (isTerminal) { return (
@@ -100,12 +102,13 @@ export const OneLineDisplay: React.FC = ({ $ {value}
+ {status && } {action === 'copy' && renderCopyButton()}
{secondary && (
- + {secondary}
@@ -114,20 +117,21 @@ export const OneLineDisplay: React.FC = ({ ); } - // File open style - show filename only, full path on hover + // File open style if (action === 'open-file') { const displayName = value.split('/').pop() || value; return (
- {label || toolName} - / + {label || toolName} + / + {status && }
); } @@ -136,20 +140,21 @@ export const OneLineDisplay: React.FC = ({ if (action === 'jump-to-results') { return ( ); diff --git a/src/components/chat/tools/components/SubagentContainer.tsx b/src/components/chat/tools/components/SubagentContainer.tsx index ae8099da..83d34208 100644 --- a/src/components/chat/tools/components/SubagentContainer.tsx +++ b/src/components/chat/tools/components/SubagentContainer.tsx @@ -1,6 +1,7 @@ import React from 'react'; import type { SubagentChildTool } from '../../types/types'; import { CollapsibleSection } from './CollapsibleSection'; +import { Collapsible, CollapsibleTrigger, CollapsibleContent } from '../../../../shared/view/ui'; interface SubagentContainerProps { toolInput: unknown; @@ -65,21 +66,21 @@ export const SubagentContainer: React.FC = ({ > {/* Prompt/request to the subagent */} {prompt && ( -
+
{prompt}
)} {/* Current tool indicator (while running) */} {currentTool && !isComplete && ( -
+
- Currently: - {currentTool.toolName} + Currently: + {currentTool.toolName} {getCompactToolDisplay(currentTool.toolName, currentTool.toolInput) && ( <> - / - + / + {getCompactToolDisplay(currentTool.toolName, currentTool.toolInput)} @@ -99,10 +100,10 @@ export const SubagentContainer: React.FC = ({ {/* Tool history (collapsed) */} {childTools.length > 0 && ( -
- + + = ({ View tool history ({childTools.length}) - -
- {childTools.map((child, index) => ( -
- {index + 1}. - {child.toolName} - {getCompactToolDisplay(child.toolName, child.toolInput) && ( - - {getCompactToolDisplay(child.toolName, child.toolInput)} - - )} - {child.toolResult?.isError && ( - (error) - )} -
- ))} -
-
+ + +
+ {childTools.map((child, index) => ( +
+ {index + 1}. + {child.toolName} + {getCompactToolDisplay(child.toolName, child.toolInput) && ( + + {getCompactToolDisplay(child.toolName, child.toolInput)} + + )} + {child.toolResult?.isError && ( + (error) + )} +
+ ))} +
+
+ )} {/* Final result */} {isComplete && toolResult && ( -
+
{(() => { let content = toolResult.content; diff --git a/src/components/chat/tools/components/ToolStatusBadge.tsx b/src/components/chat/tools/components/ToolStatusBadge.tsx new file mode 100644 index 00000000..90a5ffb8 --- /dev/null +++ b/src/components/chat/tools/components/ToolStatusBadge.tsx @@ -0,0 +1,42 @@ +import { cn } from '../../../../lib/utils'; + +export type ToolStatus = 'running' | 'completed' | 'error' | 'denied'; + +const STATUS_CONFIG: Record = { + 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 ( + + {config.label} + + ); +} diff --git a/src/components/chat/tools/components/index.ts b/src/components/chat/tools/components/index.ts index 5e419662..58f79ca4 100644 --- a/src/components/chat/tools/components/index.ts +++ b/src/components/chat/tools/components/index.ts @@ -5,3 +5,5 @@ export { CollapsibleDisplay } from './CollapsibleDisplay'; export { SubagentContainer } from './SubagentContainer'; export * from './ContentRenderers'; export * from './InteractiveRenderers'; +export { ToolStatusBadge } from './ToolStatusBadge'; +export type { ToolStatus } from './ToolStatusBadge'; diff --git a/src/components/chat/view/subcomponents/ChatComposer.tsx b/src/components/chat/view/subcomponents/ChatComposer.tsx index b9d9076c..90387401 100644 --- a/src/components/chat/view/subcomponents/ChatComposer.tsx +++ b/src/components/chat/view/subcomponents/ChatComposer.tsx @@ -98,7 +98,6 @@ interface ChatComposerProps { onTextareaScrollSync: (target: HTMLTextAreaElement) => void; onTextareaInput: (event: FormEvent) => void; onInputFocusChange?: (focused: boolean) => void; - isInputFocused?: boolean; placeholder: string; isTextareaExpanded: boolean; sendByCtrlEnter?: boolean; @@ -154,7 +153,6 @@ export default function ChatComposer({ onTextareaScrollSync, onTextareaInput, onInputFocusChange, - isInputFocused, placeholder, isTextareaExpanded, sendByCtrlEnter, @@ -175,13 +173,8 @@ export default function ChatComposer({ // Hide the thinking/status bar while any permission request is pending 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 ( -
+
{!hasPendingPermissions && ( (null); + +function useQueueItem() { + const ctx = React.useContext(QueueItemContext); + if (!ctx) throw new Error('QueueItem sub-components must be used within '); + return ctx; +} + +/* ─── Queue ──────────────────────────────────────────────────────── */ + +export const Queue = React.forwardRef>( + ({ className, ...props }, ref) => ( +
+ ), +); +Queue.displayName = 'Queue'; + +/* ─── QueueItem ──────────────────────────────────────────────────── */ + +export interface QueueItemProps extends React.HTMLAttributes { + status?: QueueItemStatus; +} + +export const QueueItem = React.forwardRef( + ({ status = 'pending', className, children, ...props }, ref) => { + const value = React.useMemo(() => ({ status }), [status]); + + return ( + +
+ {children} +
+
+ ); + }, +); +QueueItem.displayName = 'QueueItem'; + +/* ─── QueueItemIndicator ─────────────────────────────────────────── */ + +export const QueueItemIndicator = React.forwardRef>( + ({ className, ...props }, ref) => { + const { status } = useQueueItem(); + + return ( + + ); + }, +); +QueueItemIndicator.displayName = 'QueueItemIndicator'; + +/* ─── QueueItemContent ───────────────────────────────────────────── */ + +export const QueueItemContent = React.forwardRef>( + ({ className, children, ...props }, ref) => { + const { status } = useQueueItem(); + + return ( +
+ {children} +
+ ); + }, +); +QueueItemContent.displayName = 'QueueItemContent'; diff --git a/src/shared/view/ui/index.ts b/src/shared/view/ui/index.ts index 17409e28..c75e952f 100644 --- a/src/shared/view/ui/index.ts +++ b/src/shared/view/ui/index.ts @@ -14,3 +14,5 @@ export { Shimmer } from './Shimmer'; export { default as Tooltip } from './Tooltip'; export { PromptInput, PromptInputHeader, PromptInputBody, PromptInputTextarea, PromptInputFooter, PromptInputTools, PromptInputButton, PromptInputSubmit } from './PromptInput'; export { PillBar, Pill } from './PillBar'; +export { Queue, QueueItem, QueueItemIndicator, QueueItemContent } from './Queue'; +export type { QueueItemStatus } from './Queue';