diff --git a/src/components/app/AppContent.tsx b/src/components/app/AppContent.tsx index e9ac674a..63c11df0 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 = () => { + // Only resize matters — keyboard open/close changes vv.height. + // Do NOT listen to scroll: on iOS Safari, scrolling content changes + // vv.offsetTop which would make --keyboard-height fluctuate during + // normal scrolling, causing the container to bounce up and down. + const kb = Math.max(0, window.innerHeight - vv.height); + document.documentElement.style.setProperty('--keyboard-height', `${kb}px`); + }; + vv.addEventListener('resize', update); + return () => vv.removeEventListener('resize', update); + }, []); + return ( -
+
{!isMobile ? (
diff --git a/src/components/chat/hooks/useChatComposerState.ts b/src/components/chat/hooks/useChatComposerState.ts index 15f4b63f..3b167215 100644 --- a/src/components/chat/hooks/useChatComposerState.ts +++ b/src/components/chat/hooks/useChatComposerState.ts @@ -737,7 +737,7 @@ export function useChatComposerState({ } // Re-run when input changes so restored drafts get the same autosize behavior as typed text. textareaRef.current.style.height = 'auto'; - textareaRef.current.style.height = `${textareaRef.current.scrollHeight}px`; + textareaRef.current.style.height = `${Math.max(22, textareaRef.current.scrollHeight)}px`; const lineHeight = parseInt(window.getComputedStyle(textareaRef.current).lineHeight); const expanded = textareaRef.current.scrollHeight > lineHeight * 2; setIsTextareaExpanded(expanded); @@ -824,7 +824,7 @@ export function useChatComposerState({ (event: FormEvent) => { const target = event.currentTarget; target.style.height = 'auto'; - target.style.height = `${target.scrollHeight}px`; + target.style.height = `${Math.max(22, target.scrollHeight)}px`; setCursorPosition(target.selectionStart); syncInputOverlayScroll(target); diff --git a/src/components/chat/tools/ToolRenderer.tsx b/src/components/chat/tools/ToolRenderer.tsx index 26524c51..a48f6cb8 100644 --- a/src/components/chat/tools/ToolRenderer.tsx +++ b/src/components/chat/tools/ToolRenderer.tsx @@ -1,8 +1,13 @@ import React, { memo, useMemo, useCallback } from 'react'; + import type { Project } from '../../../types/app'; 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; @@ -36,12 +41,32 @@ 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'; } +// Exact denial messages from server/claude-sdk.js — other providers can't reliably signal denial +const CLAUDE_DENIAL_MESSAGES = [ + 'user denied tool use', + 'tool disallowed by settings', + 'permission request timed out', + 'permission request cancelled', +]; + +function deriveToolStatus(toolResult: any): ToolStatus { + if (!toolResult) return 'running'; + if (toolResult.isError) { + const content = String(toolResult.content || '').toLowerCase().trim(); + if (CLAUDE_DENIAL_MESSAGES.some((msg) => content.includes(msg))) { + return 'denied'; + } + return 'error'; + } + return 'completed'; +} + /** * Main tool renderer router * Routes to OneLineDisplay or CollapsibleDisplay based on tool config @@ -73,6 +98,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) || ''; @@ -82,9 +113,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 !== 'completed' ? toolStatus : undefined} + /> + ); + } + + if (displayConfig.type === 'plan') { + const title = typeof displayConfig.title === 'function' + ? displayConfig.title(parsedData) + : displayConfig.title || 'Plan'; + + const contentProps = displayConfig.getContentProps?.(parsedData, { + selectedProject, + createDiff, + onFileOpen + }) || {}; + + const isStreaming = mode === 'input' && !toolResult; + + return ( + ); } @@ -134,7 +191,6 @@ export const ToolRenderer: React.FC = memo(({ onFileOpen }) || {}; - // Build the content component based on contentType let contentComponent: React.ReactNode = null; switch (displayConfig.contentType) { @@ -211,7 +267,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, @@ -219,6 +274,8 @@ export const ToolRenderer: React.FC = memo(({ }) : undefined; + const badgeElement = toolStatus && toolStatus !== 'completed' ? : 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/PlanDisplay.tsx b/src/components/chat/tools/components/PlanDisplay.tsx new file mode 100644 index 00000000..fe10e399 --- /dev/null +++ b/src/components/chat/tools/components/PlanDisplay.tsx @@ -0,0 +1,137 @@ +import React from 'react'; +import { ChevronsUpDown, FileText } from 'lucide-react'; + +import { + Card, + CardHeader, + CardTitle, + CardContent, + CardFooter, + Button, + Collapsible, + CollapsibleTrigger, + CollapsibleContent, + Shimmer, +} from '../../../../shared/view/ui'; +import { usePermission } from '../../../../contexts/PermissionContext'; + +import { MarkdownContent } from './ContentRenderers'; + +interface PlanDisplayProps { + title: string; + content: string; + defaultOpen?: boolean; + isStreaming?: boolean; + showRawParameters?: boolean; + rawContent?: string; + toolName: string; + toolId?: string; +} + +export const PlanDisplay: React.FC = ({ + title, + content, + defaultOpen = false, + isStreaming = false, + showRawParameters = false, + rawContent, + toolName: _toolName, +}) => { + const permissionCtx = usePermission(); + + const pendingRequest = permissionCtx?.pendingPermissionRequests.find( + (r) => r.toolName === 'ExitPlanMode' || r.toolName === 'exit_plan_mode' + ); + + const handleBuild = () => { + if (pendingRequest && permissionCtx) { + permissionCtx.handlePermissionDecision(pendingRequest.requestId, { allow: true }); + } + }; + + const handleRevise = () => { + if (pendingRequest && permissionCtx) { + permissionCtx.handlePermissionDecision(pendingRequest.requestId, { + allow: false, + message: 'User asked to revise the plan', + }); + } + }; + + return ( + + + {/* Header — always visible */} + +
+ + + {isStreaming ? {title} : title} + +
+ + + Toggle plan + +
+ + {/* Collapsible content */} + + + {content ? ( + + ) : isStreaming ? ( +
+ Generating plan... +
+ ) : null} + + {showRawParameters && rawContent && ( + + + + + + raw params + + +
+                    {rawContent}
+                  
+
+
+ )} +
+
+ + {/* Footer — always visible when permission is pending */} + {pendingRequest && ( + + + + + )} +
+
+ ); +}; 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/tools/configs/toolConfigs.ts b/src/components/chat/tools/configs/toolConfigs.ts index 40dd3dbc..fbb3f2d4 100644 --- a/src/components/chat/tools/configs/toolConfigs.ts +++ b/src/components/chat/tools/configs/toolConfigs.ts @@ -5,7 +5,7 @@ export interface ToolDisplayConfig { input: { - type: 'one-line' | 'collapsible' | 'hidden'; + type: 'one-line' | 'collapsible' | 'plan' | 'hidden'; // One-line config icon?: string; label?: string; @@ -31,7 +31,7 @@ export interface ToolDisplayConfig { result?: { hidden?: boolean; hideOnSuccess?: boolean; - type?: 'one-line' | 'collapsible' | 'special'; + type?: 'one-line' | 'collapsible' | 'plan' | 'special'; title?: string | ((result: any) => string); defaultOpen?: boolean; // Special result handlers @@ -494,7 +494,7 @@ export const TOOL_CONFIGS: Record = { exit_plan_mode: { input: { - type: 'collapsible', + type: 'plan', title: 'Implementation plan', defaultOpen: true, contentType: 'markdown', @@ -503,29 +503,14 @@ export const TOOL_CONFIGS: Record = { }) }, result: { - type: 'collapsible', - contentType: 'markdown', - getContentProps: (result) => { - try { - let parsed = result.content; - if (typeof parsed === 'string') { - parsed = JSON.parse(parsed); - } - return { - content: parsed.plan?.replace(/\\n/g, '\n') || parsed.plan - }; - } catch (e) { - console.warn('Failed to parse plan content:', e); - return { content: '' }; - } - } + hidden: true } }, // Also register as ExitPlanMode (the actual tool name used by Claude) ExitPlanMode: { input: { - type: 'collapsible', + type: 'plan', title: 'Implementation plan', defaultOpen: true, contentType: 'markdown', @@ -534,22 +519,7 @@ export const TOOL_CONFIGS: Record = { }) }, result: { - type: 'collapsible', - contentType: 'markdown', - getContentProps: (result) => { - try { - let parsed = result.content; - if (typeof parsed === 'string') { - parsed = JSON.parse(parsed); - } - return { - content: parsed.plan?.replace(/\\n/g, '\n') || parsed.plan - }; - } catch (e) { - console.warn('Failed to parse plan content:', e); - return { content: '' }; - } - } + hidden: true } }, diff --git a/src/components/chat/view/ChatInterface.tsx b/src/components/chat/view/ChatInterface.tsx index 44709502..1b3ae95c 100644 --- a/src/components/chat/view/ChatInterface.tsx +++ b/src/components/chat/view/ChatInterface.tsx @@ -1,6 +1,8 @@ -import React, { useCallback, useEffect, useRef } from 'react'; +import React, { useCallback, useEffect, useMemo, useRef } from 'react'; import { useTranslation } from 'react-i18next'; + import { useTasksSettings } from '../../../contexts/TasksSettingsContext'; +import PermissionContext from '../../../contexts/PermissionContext'; import { QuickSettingsPanel } from '../../quick-settings-panel'; import type { ChatInterfaceProps, Provider } from '../types/types'; import type { LLMProvider } from '../../../types/app'; @@ -9,6 +11,7 @@ import { useChatSessionState } from '../hooks/useChatSessionState'; import { useChatRealtimeHandlers } from '../hooks/useChatRealtimeHandlers'; import { useChatComposerState } from '../hooks/useChatComposerState'; import { useSessionStore } from '../../../stores/useSessionStore'; + import ChatMessagesPane from './subcomponents/ChatMessagesPane'; import ChatComposer from './subcomponents/ChatComposer'; @@ -267,6 +270,11 @@ function ChatInterface({ }; }, [resetStreamingState]); + const permissionContextValue = useMemo(() => ({ + pendingPermissionRequests, + handlePermissionDecision, + }), [pendingPermissionRequests, handlePermissionDecision]); + if (!selectedProject) { const selectedProviderLabel = provider === 'cursor' @@ -292,7 +300,7 @@ function ChatInterface({ } return ( - <> +
- + ); } diff --git a/src/components/chat/view/subcomponents/ChatComposer.tsx b/src/components/chat/view/subcomponents/ChatComposer.tsx index 69e8fc3c..90387401 100644 --- a/src/components/chat/view/subcomponents/ChatComposer.tsx +++ b/src/components/chat/view/subcomponents/ChatComposer.tsx @@ -11,12 +11,24 @@ import type { SetStateAction, TouchEvent, } from 'react'; +import { ImageIcon, MessageSquareIcon, XIcon, ArrowDownIcon } from 'lucide-react'; import type { PendingPermissionRequest, PermissionMode, Provider } from '../../types/types'; import CommandMenu from './CommandMenu'; import ClaudeStatus from './ClaudeStatus'; import ImageAttachment from './ImageAttachment'; import PermissionRequestsBanner from './PermissionRequestsBanner'; -import ChatInputControls from './ChatInputControls'; +import ThinkingModeSelector from './ThinkingModeSelector'; +import TokenUsagePie from './TokenUsagePie'; +import { + PromptInput, + PromptInputHeader, + PromptInputBody, + PromptInputTextarea, + PromptInputFooter, + PromptInputTools, + PromptInputButton, + PromptInputSubmit, +} from '../../../../shared/view/ui'; interface MentionableFile { name: string; @@ -86,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; @@ -142,7 +153,6 @@ export default function ChatComposer({ onTextareaScrollSync, onTextareaInput, onInputFocusChange, - isInputFocused, placeholder, isTextareaExpanded, sendByCtrlEnter, @@ -163,81 +173,40 @@ 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 && ( -
- + )} + + {pendingPermissionRequests.length > 0 && ( +
+
)} -
- - - {!hasQuestionPanel && } -
- - {!hasQuestionPanel &&
) => void} className="relative mx-auto max-w-4xl"> - {isDragActive && ( -
-
- - - -

Drop images here

-
+ {!hasQuestionPanel &&
+ {isUserScrolledUp && hasMessages && ( +
+
)} - - {attachedImages.length > 0 && ( -
-
- {attachedImages.map((file, index) => ( - onRemoveImage(index)} - uploadProgress={uploadingImages.get(file.name)} - error={imageErrors.get(file.name)} - /> - ))} -
-
- )} - {showFileDropdown && filteredFiles.length > 0 && (
{filteredFiles.map((file, index) => ( @@ -275,21 +244,56 @@ export default function ChatComposer({ frequentCommands={frequentCommands} /> -
) => void} + status={isLoading ? 'streaming' : 'ready'} + className={isTextareaExpanded ? 'chat-input-expanded' : ''} {...getRootProps()} - className={`relative overflow-hidden rounded-2xl border border-border/50 bg-card/80 shadow-sm backdrop-blur-sm transition-all duration-200 focus-within:border-primary/30 focus-within:shadow-md focus-within:ring-1 focus-within:ring-primary/15 ${ - isTextareaExpanded ? 'chat-input-expanded' : '' - }`} > - -