diff --git a/apps/packages/ui/src/pannel-session.tsx b/apps/packages/ui/src/pannel-session.tsx index 35178bccc8..b24461adb4 100644 --- a/apps/packages/ui/src/pannel-session.tsx +++ b/apps/packages/ui/src/pannel-session.tsx @@ -15,6 +15,7 @@ interface SessionPanelProps { interface SessionPanelContentProps { children: ReactNode; className?: string; + wrapperClassName?: string; } export function SessionPanel({ @@ -44,16 +45,18 @@ export function SessionPanel({ } export const SessionPanelContent = forwardRef( - function SessionPanelContent({ children, className }, ref) { + function SessionPanelContent({ children, className, wrapperClassName }, ref) { return ( -
- {children} +
+
+ {children} +
); } diff --git a/apps/web/app/globals.css b/apps/web/app/globals.css index b87402cdf3..2700941986 100644 --- a/apps/web/app/globals.css +++ b/apps/web/app/globals.css @@ -187,6 +187,10 @@ } @layer utilities { + .overflow-wrap-anywhere { + overflow-wrap: anywhere; + } + .kanban-task-pulse { animation: kanban-task-pulse 5s ease-in-out infinite; } @@ -199,8 +203,66 @@ .scrollbar-hide::-webkit-scrollbar { display: none; } + + /* Thin scrollbar for menus */ + .scrollbar-thin { + scrollbar-width: thin; + scrollbar-color: var(--scrollbar-thumb) transparent; + } + + .scrollbar-thin::-webkit-scrollbar { + width: 8px; + } + + .scrollbar-thin::-webkit-scrollbar-track { + background: transparent !important; + } + + .scrollbar-thin::-webkit-scrollbar-thumb { + background: var(--scrollbar-thumb); + border-radius: 4px; + } + + .scrollbar-thin::-webkit-scrollbar-thumb:hover { + background: var(--scrollbar-thumb-hover); + } } +/* Grid Spinner - 3x3 cube animation */ +@keyframes spinner-grid { + 0%, 70%, 100% { + transform: scale3d(0.5, 0.5, 1); + } + 35% { + transform: scale3d(0, 0, 1); + } +} + +.spinner-grid { + display: inline-grid; + grid-template-columns: repeat(3, 1fr); + gap: 0.15em; + width: 1em; + height: 1em; +} + +.spinner-grid-cube { + width: 100%; + height: 100%; + background-color: currentColor; + animation: spinner-grid 1.3s ease-in-out infinite; +} + +.spinner-grid-cube:nth-child(1) { animation-delay: 0.2s; } +.spinner-grid-cube:nth-child(2) { animation-delay: 0.3s; } +.spinner-grid-cube:nth-child(3) { animation-delay: 0.4s; } +.spinner-grid-cube:nth-child(4) { animation-delay: 0.1s; } +.spinner-grid-cube:nth-child(5) { animation-delay: 0.2s; } +.spinner-grid-cube:nth-child(6) { animation-delay: 0.3s; } +.spinner-grid-cube:nth-child(7) { animation-delay: 0s; } +.spinner-grid-cube:nth-child(8) { animation-delay: 0.1s; } +.spinner-grid-cube:nth-child(9) { animation-delay: 0.2s; } + @keyframes kanban-task-pulse { 0%, 100% { @@ -211,6 +273,31 @@ } } +@keyframes border-flash-glow { + 0%, 100% { + box-shadow: inset 0 0 0 1px color-mix(in oklab, var(--primary) 20%, transparent); + } + 50% { + box-shadow: inset 0 0 0 1px color-mix(in oklab, var(--primary) 60%, transparent); + } +} + +@layer components { + .border-flash-animation { + position: relative; + } + + .border-flash-animation::after { + content: ''; + position: absolute; + inset: 0; + border-radius: inherit; + pointer-events: none; + z-index: 10; + animation: border-flash-glow 3s ease-in-out infinite; + } +} + .cm-tooltip { border: 1px solid var(--border) !important; background: var(--popover, oklch(0.205 0 0)) !important; diff --git a/apps/web/components/settings/board-card.tsx b/apps/web/components/settings/board-card.tsx index 03cfd8f440..8f1b334c1e 100644 --- a/apps/web/components/settings/board-card.tsx +++ b/apps/web/components/settings/board-card.tsx @@ -89,7 +89,6 @@ export function BoardCard({ return () => { cancelled = true; }; - // eslint-disable-next-line react-hooks/exhaustive-deps }, [board.id, initialWorkflowSteps, isNewBoard, toast]); const refreshWorkflowSteps = async () => { diff --git a/apps/web/components/task/chat/chat-input-container.tsx b/apps/web/components/task/chat/chat-input-container.tsx new file mode 100644 index 0000000000..f982530c78 --- /dev/null +++ b/apps/web/components/task/chat/chat-input-container.tsx @@ -0,0 +1,274 @@ +'use client'; + +import { useRef, useCallback, useState, type KeyboardEvent as ReactKeyboardEvent } from 'react'; +import { + IconArrowUp, + IconBrain, + IconCheck, + IconListCheck, + IconLoader2, + IconPlayerStopFilled, +} from '@tabler/icons-react'; +import { Button } from '@kandev/ui/button'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from '@kandev/ui/dropdown-menu'; +import { Tooltip, TooltipContent, TooltipTrigger } from '@kandev/ui/tooltip'; +import { cn } from '@/lib/utils'; +import { SHORTCUTS } from '@/lib/keyboard/constants'; +import { KeyboardShortcutTooltip } from '@/components/keyboard-shortcut-tooltip'; +import { TokenUsageDisplay } from '@/components/task/chat/token-usage-display'; +import { SessionsDropdown } from '@/components/task/sessions-dropdown'; +import { ModelSelector } from '@/components/task/model-selector'; +import { RichTextInput, type RichTextInputHandle } from './rich-text-input'; +import { MentionMenu } from './mention-menu'; +import { SlashCommandMenu } from './slash-command-menu'; +import { useInlineMention } from '@/hooks/use-inline-mention'; +import { useInlineSlash } from '@/hooks/use-inline-slash'; + +type ChatInputContainerProps = { + onSubmit: (message: string) => void; + sessionId: string | null; + taskId: string | null; + taskTitle?: string; + taskDescription: string; + planModeEnabled: boolean; + onPlanModeChange: (enabled: boolean) => void; + isAgentBusy: boolean; + isStarting: boolean; + isSending: boolean; + onCancel: () => void; + showApproveButton?: boolean; + onApprove?: () => void; + placeholder?: string; +}; + +export function ChatInputContainer({ + onSubmit, + sessionId, + taskId, + taskTitle, + taskDescription, + planModeEnabled, + onPlanModeChange, + isAgentBusy, + isStarting, + isSending, + onCancel, + showApproveButton, + onApprove, + placeholder, +}: ChatInputContainerProps) { + // Keep input state local to avoid re-rendering parent on every keystroke + const [value, setValue] = useState(''); + const inputRef = useRef(null); + + // Mention menu hook + const mention = useInlineMention(inputRef, value, setValue, sessionId); + + // Slash command menu hook + const slash = useInlineSlash(inputRef, value, setValue, onPlanModeChange); + + // Combined change handler + const handleChange = useCallback( + (newValue: string) => { + mention.handleChange(newValue); + slash.handleChange(newValue); + }, + [mention, slash] + ); + + // Combined keydown handler + const handleKeyDown = useCallback( + (event: ReactKeyboardEvent) => { + // Let menus handle navigation first + if (mention.isOpen) { + mention.handleKeyDown(event); + if (event.defaultPrevented) return; + } + if (slash.isOpen) { + slash.handleKeyDown(event); + if (event.defaultPrevented) return; + } + }, + [mention, slash] + ); + + // Handle form submission + const handleSubmit = useCallback(() => { + // Don't submit if a menu is open + if (mention.isOpen || slash.isOpen) return; + const trimmed = value.trim(); + if (!trimmed) return; + onSubmit(trimmed); + setValue(''); + }, [mention.isOpen, slash.isOpen, value, onSubmit]); + + const isDisabled = isStarting || isSending; + + return ( +
+ {/* Popup menus */} + + + + {/* Input area */} + + + {/* Integrated toolbar */} +
+ {/* Left: Plan, Thinking, Context */} +
+ {/* Plan mode toggle */} + + + + + Toggle plan mode + + + {/* Thinking level dropdown */} + + + + + + + + Thinking level + + + High + Medium + Low + Off + + + +
+ + {/* Spacer */} +
+ + {/* Right: Sessions, Model, Submit */} +
+ + + {/* Token usage display */} + + + + + {/* Approve button */} + {showApproveButton && onApprove && ( + + + + + Approve and move to next step + + )} + + {/* Submit/Stop button */} +
+ + {isAgentBusy ? ( + + ) : ( + + )} + +
+
+
+
+ ); +} diff --git a/apps/web/components/task/chat/chat-toolbar.tsx b/apps/web/components/task/chat/chat-toolbar.tsx deleted file mode 100644 index 83240b169c..0000000000 --- a/apps/web/components/task/chat/chat-toolbar.tsx +++ /dev/null @@ -1,146 +0,0 @@ -'use client'; - -import { IconBrain, IconCheck, IconListCheck, IconLoader2, IconPaperclip } from '@tabler/icons-react'; -import { Button } from '@kandev/ui/button'; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger, -} from '@kandev/ui/dropdown-menu'; -import { Tooltip, TooltipContent, TooltipTrigger } from '@kandev/ui/tooltip'; -import { cn } from '@/lib/utils'; -import { SHORTCUTS } from '@/lib/keyboard/constants'; -import { KeyboardShortcutTooltip } from '@/components/keyboard-shortcut-tooltip'; -import { TokenUsageDisplay } from '@/components/task/chat/token-usage-display'; -import { SessionsDropdown } from '@/components/task/sessions-dropdown'; -import { ModelSelector } from '@/components/task/model-selector'; - -type ChatToolbarProps = { - sessionId: string | null; - taskId: string | null; - taskTitle?: string; - taskDescription: string; - planModeEnabled: boolean; - onPlanModeChange: (enabled: boolean) => void; - isAgentBusy: boolean; - isStarting: boolean; - isSending: boolean; - onCancel: () => void; - showApproveButton?: boolean; - onApprove?: () => void; -}; - -export function ChatToolbar({ - sessionId, - taskId, - taskTitle, - taskDescription, - planModeEnabled, - onPlanModeChange, - isAgentBusy, - isStarting, - isSending, - onCancel, - showApproveButton, - onApprove, -}: ChatToolbarProps) { - return ( -
-
- - - - - - - - - Thinking level - - - High - Medium - Low - Off - - - - -
- - {planModeEnabled && Plan mode active} -
-
- Toggle plan mode -
- {/* Context window usage indicator */} - -
-
- - - - - - Add attachments - - {showApproveButton && onApprove && ( - - - - - Approve and move to next step - - )} - - - -
-
- ); -} diff --git a/apps/web/components/task/chat/mention-menu.tsx b/apps/web/components/task/chat/mention-menu.tsx new file mode 100644 index 0000000000..23f3c15a65 --- /dev/null +++ b/apps/web/components/task/chat/mention-menu.tsx @@ -0,0 +1,198 @@ +'use client'; + +import { useEffect, useRef } from 'react'; +import { createPortal } from 'react-dom'; +import { IconAt, IconFile, IconFolder } from '@tabler/icons-react'; +import { cn } from '@/lib/utils'; +import type { MentionItem } from '@/hooks/use-inline-mention'; + +type MentionMenuProps = { + isOpen: boolean; + isLoading: boolean; + position: { x: number; y: number } | null; + items: MentionItem[]; + query: string; + selectedIndex: number; + onSelect: (item: MentionItem) => void; + onClose: () => void; + setSelectedIndex: (index: number) => void; +}; + +const MENU_HEIGHT = 280; +const MENU_WIDTH = 320; + +// Extract filename and parent path from a full path +function parseFilePath(filePath: string): { name: string; parent: string } { + const parts = filePath.split('/'); + const name = parts.pop() || filePath; + const parent = parts.length > 0 ? parts.join('/') + '/' : ''; + return { name, parent }; +} + +// Check if path looks like a directory (ends with / or has no extension) +function isDirectory(filePath: string): boolean { + if (filePath.endsWith('/')) return true; + const name = filePath.split('/').pop() || ''; + return !name.includes('.'); +} + +export function MentionMenu({ + isOpen, + isLoading, + position, + items, + query, + selectedIndex, + onSelect, + onClose, + setSelectedIndex, +}: MentionMenuProps) { + const menuRef = useRef(null); + const itemRefs = useRef>(new Map()); + + // Scroll selected item into view + useEffect(() => { + const selectedItem = itemRefs.current.get(selectedIndex); + if (selectedItem) { + selectedItem.scrollIntoView({ block: 'nearest' }); + } + }, [selectedIndex]); + + // Close on click outside + useEffect(() => { + if (!isOpen) return; + + const handleClickOutside = (event: MouseEvent) => { + if (menuRef.current && !menuRef.current.contains(event.target as Node)) { + onClose(); + } + }; + + document.addEventListener('mousedown', handleClickOutside); + return () => document.removeEventListener('mousedown', handleClickOutside); + }, [isOpen, onClose]); + + if (!isOpen || !position) { + return null; + } + + // Calculate position (above cursor) + const menuStyle: React.CSSProperties = { + position: 'fixed', + left: Math.max(8, Math.min(position.x, window.innerWidth - MENU_WIDTH - 8)), + bottom: window.innerHeight - position.y + 8, + width: MENU_WIDTH, + maxHeight: MENU_HEIGHT, + zIndex: 50, + }; + + // Group items by type + const prompts = items.filter((item) => item.type === 'prompt'); + const files = items.filter((item) => item.type === 'file'); + + // Calculate indices for sections + let currentIndex = 0; + + const menu = ( +
+ {/* Header */} +
+ Mention files, prompts +
+ + {/* Content */} +
+ {items.length === 0 ? ( +
+ {isLoading ? 'Loading...' : query ? 'No results found' : 'Type to search...'} +
+ ) : ( + <> + {prompts.length > 0 && ( +
+ {prompts.map((item) => { + const index = currentIndex++; + return ( + + ); + })} +
+ )} + + {files.length > 0 && ( +
+ {files.map((item) => { + const index = currentIndex++; + const { name, parent } = parseFilePath(item.label); + const isDir = isDirectory(item.label); + + return ( + + ); + })} +
+ )} + + )} +
+
+ ); + + // Render via portal to escape any overflow containers + if (typeof document === 'undefined') return null; + return createPortal(menu, document.body); +} diff --git a/apps/web/components/task/chat/messages/agent-status.tsx b/apps/web/components/task/chat/messages/agent-status.tsx new file mode 100644 index 0000000000..fe0891757e --- /dev/null +++ b/apps/web/components/task/chat/messages/agent-status.tsx @@ -0,0 +1,238 @@ +'use client'; + +import { useCallback, useMemo, useState, useEffect, useRef } from 'react'; +import { IconAlertCircle, IconAlertTriangle, IconCopy, IconCheck } from '@tabler/icons-react'; +import type { Message, TaskSessionState } from '@/lib/types/http'; +import { useSessionTurn } from '@/hooks/domains/session/use-session-turn'; + +type AgentStatusProps = { + sessionState?: TaskSessionState; + sessionId: string | null; + messages?: Message[]; +}; + +const STATE_CONFIG: Record = { + CREATED: { label: 'Agent is being created', icon: 'spinner' }, + STARTING: { label: 'Agent is starting', icon: 'spinner' }, + RUNNING: { label: 'Agent is running', icon: 'spinner' }, + WAITING_FOR_INPUT: { label: '', icon: null }, + COMPLETED: { label: '', icon: null }, + FAILED: { label: 'Agent has encountered an error', icon: 'error' }, + CANCELLED: { label: 'Agent has been cancelled', icon: 'warning' }, +}; + +/** + * Format duration in seconds to a human-readable string. + */ +function formatDuration(seconds: number): string { + if (seconds < 0) return '0s'; + const hours = Math.floor(seconds / 3600); + const minutes = Math.floor((seconds % 3600) / 60); + const secs = Math.floor(seconds % 60); + if (hours > 0) return `${hours}h ${minutes}m ${secs}s`; + if (minutes > 0) return `${minutes}m ${secs}s`; + return `${secs}s`; +} + +/** + * Calculate turn duration from messages as a fallback when turn data is not available. + * Finds the last user message and the last agent message to estimate duration. + */ +function calculateTurnDurationFromMessages(messages: Message[]): string | null { + if (messages.length < 2) return null; + + // Find last agent message + let lastAgentMsg: Message | null = null; + let lastUserMsgBeforeAgent: Message | null = null; + + for (let i = messages.length - 1; i >= 0; i--) { + const msg = messages[i]; + if (!lastAgentMsg && msg.author_type === 'agent') { + lastAgentMsg = msg; + } else if (lastAgentMsg && msg.author_type === 'user') { + lastUserMsgBeforeAgent = msg; + break; + } + } + + if (!lastAgentMsg || !lastUserMsgBeforeAgent) return null; + + const startTime = new Date(lastUserMsgBeforeAgent.created_at).getTime(); + const endTime = new Date(lastAgentMsg.created_at).getTime(); + const durationSeconds = Math.floor((endTime - startTime) / 1000); + + if (durationSeconds < 0) return null; + return formatDuration(durationSeconds); +} + +/** + * Hook to track elapsed time while agent is running. + * Starts timer when isRunning becomes true, resets when it becomes false. + */ +function useRunningTimer(isRunning: boolean) { + const [elapsedSeconds, setElapsedSeconds] = useState(0); + const startTimeRef = useRef(null); + + useEffect(() => { + if (!isRunning) { + // Reset refs when not running (state reset handled below) + startTimeRef.current = null; + return; + } + + // Start timer + if (!startTimeRef.current) { + startTimeRef.current = Date.now(); + } + const interval = setInterval(() => { + if (startTimeRef.current) { + setElapsedSeconds(Math.floor((Date.now() - startTimeRef.current) / 1000)); + } + }, 1000); + return () => clearInterval(interval); + }, [isRunning]); + + // Reset elapsed seconds when not running (outside effect to avoid lint warning) + const displaySeconds = isRunning ? elapsedSeconds : 0; + + // Format as Xs or XmXs + const formatted = useMemo(() => { + return formatDuration(displaySeconds); + }, [displaySeconds]); + + return { elapsedSeconds: displaySeconds, formatted }; +} + +function GridSpinner({ className }: { className?: string }) { + return ( + + + + + + + + + + + + ); +} + +function CopyButton({ content }: { content: string }) { + const [copied, setCopied] = useState(false); + + const handleCopy = useCallback(async () => { + try { + await navigator.clipboard.writeText(content); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } catch (err) { + console.error('Failed to copy:', err); + } + }, [content]); + + return ( + + ); +} + +export function AgentStatus({ sessionState, sessionId, messages = [] }: AgentStatusProps) { + const { lastTurnDuration, isActive: isTurnActive } = useSessionTurn(sessionId); + + const config = sessionState ? STATE_CONFIG[sessionState] : null; + const isRunning = config?.icon === 'spinner'; + + // Use local timer that starts when agent is running + const { formatted: runningDuration, elapsedSeconds } = useRunningTimer(isRunning); + const isError = config?.icon === 'error'; + const isWarning = config?.icon === 'warning'; + + // Get last agent message content for copy button + const lastAgentContent = useMemo(() => { + for (let i = messages.length - 1; i >= 0; i--) { + if (messages[i].author_type === 'agent' && messages[i].content) { + return messages[i].content; + } + } + return null; + }, [messages]); + + // Calculate duration from messages as fallback + const fallbackDuration = useMemo(() => { + if (lastTurnDuration) return null; // Don't need fallback + return calculateTurnDurationFromMessages(messages); + }, [messages, lastTurnDuration]); + + const displayDuration = lastTurnDuration?.formatted ?? fallbackDuration; + + // Error state + if (isError && config) { + return ( +
+
+ ); + } + + // Warning state + if (isWarning && config) { + return ( +
+
+ ); + } + + // Running state + if (isRunning && config) { + return ( +
+ + + {config.label} + {elapsedSeconds > 0 && ( + + {runningDuration} + + )} + +
+ ); + } + + // Completed/idle state - show duration and copy button if available + const showCompletedState = !isTurnActive && (displayDuration || lastAgentContent); + if (showCompletedState) { + return ( +
+ + {displayDuration && {displayDuration}} + {lastAgentContent && } + +
+ ); + } + + return null; +} diff --git a/apps/web/components/task/chat/messages/chat-message.tsx b/apps/web/components/task/chat/messages/chat-message.tsx index d58dcec7d8..5fb2d131c6 100644 --- a/apps/web/components/task/chat/messages/chat-message.tsx +++ b/apps/web/components/task/chat/messages/chat-message.tsx @@ -85,10 +85,10 @@ export function ChatMessage({ comment, label, className, showRichBlocks }: ChatM // User message: right-aligned bubble if (isUser) { return ( -
-
-
-

+

+
+
+

{comment.content ? renderContentWithFileRefs(comment.content) : '(empty)'}

diff --git a/apps/web/components/task/chat/messages/running-indicator.tsx b/apps/web/components/task/chat/messages/running-indicator.tsx deleted file mode 100644 index 949b95b648..0000000000 --- a/apps/web/components/task/chat/messages/running-indicator.tsx +++ /dev/null @@ -1,85 +0,0 @@ -'use client'; - -import { IconAlertCircle, IconAlertTriangle, IconClock } from '@tabler/icons-react'; -import type { TaskSessionState } from '@/lib/types/http'; -import { useActiveTurnTimer } from '@/hooks/use-active-turn-timer'; - -type RunningIndicatorProps = { - state: TaskSessionState; - sessionId?: string | null; -}; - -const STATE_CONFIG: Record = { - CREATED: { label: 'Agent is being created', icon: 'spinner' }, - STARTING: { label: 'Agent is starting', icon: 'spinner' }, - RUNNING: { label: 'Agent is running', icon: 'spinner' }, - WAITING_FOR_INPUT: { label: 'Agent is waiting for input', icon: null }, - COMPLETED: { label: '', icon: null }, - FAILED: { label: 'Agent has encountered an error', icon: 'error' }, - CANCELLED: { label: 'Agent has been cancelled', icon: 'warning' }, -}; - -export function RunningIndicator({ state, sessionId }: RunningIndicatorProps) { - const config = STATE_CONFIG[state]; - const { isActive, formattedDuration } = useActiveTurnTimer(sessionId ?? null); - - // Don't show indicator for completed state or waiting for input - if (!config.icon) { - return null; - } - - const isError = config.icon === 'error'; - const isWarning = config.icon === 'warning'; - - return ( -
- {config.icon === 'spinner' && ( - <> - {config.label} - - - - - - {isActive && ( - - - )} - - )} - {config.icon === 'error' && ( - <> -
- ); -} diff --git a/apps/web/components/task/chat/messages/turn-summary.tsx b/apps/web/components/task/chat/messages/turn-summary.tsx deleted file mode 100644 index f385c7cf43..0000000000 --- a/apps/web/components/task/chat/messages/turn-summary.tsx +++ /dev/null @@ -1,41 +0,0 @@ -'use client'; - -import { IconChevronRight, IconClock, IconCpu } from '@tabler/icons-react'; -import { useSessionTurn } from '@/hooks/domains/session/use-session-turn'; - -type TurnSummaryProps = { - sessionId: string | null; -}; - -/** - * A compact display of the last completed turn's duration and model. - * Only shows when there's a completed turn and no active turn running. - */ -export function TurnSummary({ sessionId }: TurnSummaryProps) { - const { lastCompletedTurn, lastTurnDuration, isActive, sessionModel } = useSessionTurn(sessionId); - - // Don't show if there's an active turn running or no completed turn - if (isActive || !lastCompletedTurn || !lastTurnDuration) { - return null; - } - - return ( -
- -
- - {lastTurnDuration.formatted} -
- {sessionModel && ( - <> - · -
- - {sessionModel} -
- - )} -
- ); -} - diff --git a/apps/web/components/task/chat/rich-text-input.tsx b/apps/web/components/task/chat/rich-text-input.tsx new file mode 100644 index 0000000000..3d14cf45b0 --- /dev/null +++ b/apps/web/components/task/chat/rich-text-input.tsx @@ -0,0 +1,213 @@ +'use client'; + +import { + forwardRef, + useRef, + useImperativeHandle, + type KeyboardEvent, + type ChangeEvent, + useCallback, + useLayoutEffect, +} from 'react'; +import { cn } from '@/lib/utils'; + +export type RichTextInputHandle = { + focus: () => void; + blur: () => void; + getSelectionStart: () => number; + getSelectionEnd: () => number; + setSelectionRange: (start: number, end: number) => void; + getCaretRect: () => DOMRect | null; + getValue: () => string; + setValue: (value: string) => void; + insertText: (text: string, from: number, to: number) => void; +}; + +type RichTextInputProps = { + value: string; + onChange: (value: string) => void; + onKeyDown?: (event: KeyboardEvent) => void; + onSubmit?: () => void; + placeholder?: string; + disabled?: boolean; + className?: string; + planModeEnabled?: boolean; +}; + +export const RichTextInput = forwardRef( + function RichTextInput( + { + value, + onChange, + onKeyDown, + onSubmit, + placeholder, + disabled = false, + className, + planModeEnabled = false, + }, + ref + ) { + const textareaRef = useRef(null); + + // Auto-resize textarea based on content + useLayoutEffect(() => { + const textarea = textareaRef.current; + if (!textarea) return; + + // Reset height to auto to get the correct scrollHeight + textarea.style.height = 'auto'; + + // Calculate new height (clamped to min/max) + const minHeight = 88; + const maxHeight = Math.min(window.innerHeight * 0.4, 300); + const newHeight = Math.min(Math.max(textarea.scrollHeight, minHeight), maxHeight); + + textarea.style.height = `${newHeight}px`; + }, [value]); + + const getCaretRect = useCallback((): DOMRect | null => { + const textarea = textareaRef.current; + if (!textarea) return null; + + const selectionStart = textarea.selectionStart; + + // Create a mirror div to measure caret position + const mirror = document.createElement('div'); + const computed = window.getComputedStyle(textarea); + + // Copy styles that affect text layout + const stylesToCopy = [ + 'fontFamily', + 'fontSize', + 'fontWeight', + 'fontStyle', + 'letterSpacing', + 'textTransform', + 'wordSpacing', + 'textIndent', + 'whiteSpace', + 'wordWrap', + 'wordBreak', + 'overflowWrap', + 'lineHeight', + 'padding', + 'paddingTop', + 'paddingRight', + 'paddingBottom', + 'paddingLeft', + 'borderWidth', + 'boxSizing', + ] as const; + + mirror.style.position = 'absolute'; + mirror.style.visibility = 'hidden'; + mirror.style.whiteSpace = 'pre-wrap'; + mirror.style.wordWrap = 'break-word'; + mirror.style.width = `${textarea.clientWidth}px`; + + stylesToCopy.forEach((prop) => { + mirror.style[prop as unknown as number] = computed[prop]; + }); + + document.body.appendChild(mirror); + + // Get text before cursor and add a span for measuring + const textBeforeCursor = value.substring(0, selectionStart); + mirror.textContent = textBeforeCursor; + + // Add a marker span at the cursor position + const marker = document.createElement('span'); + marker.textContent = '\u200B'; // Zero-width space + mirror.appendChild(marker); + + // Get textarea position and marker position + const textareaRect = textarea.getBoundingClientRect(); + const markerRect = marker.getBoundingClientRect(); + const mirrorRect = mirror.getBoundingClientRect(); + + // Calculate position relative to viewport + const relativeTop = markerRect.top - mirrorRect.top; + const relativeLeft = markerRect.left - mirrorRect.left; + + // Account for scroll position in textarea + const scrollTop = textarea.scrollTop; + + document.body.removeChild(mirror); + + return new DOMRect( + textareaRect.left + relativeLeft, + textareaRect.top + relativeTop - scrollTop, + 0, + parseInt(computed.lineHeight, 10) || parseInt(computed.fontSize, 10) * 1.2 + ); + }, [value]); + + useImperativeHandle( + ref, + () => ({ + focus: () => textareaRef.current?.focus(), + blur: () => textareaRef.current?.blur(), + getSelectionStart: () => textareaRef.current?.selectionStart ?? 0, + getSelectionEnd: () => textareaRef.current?.selectionEnd ?? 0, + setSelectionRange: (start: number, end: number) => { + textareaRef.current?.setSelectionRange(start, end); + }, + getCaretRect, + getValue: () => textareaRef.current?.value ?? '', + setValue: (newValue: string) => onChange(newValue), + insertText: (text: string, from: number, to: number) => { + const newValue = value.substring(0, from) + text + value.substring(to); + onChange(newValue); + // Set cursor position after the inserted text + requestAnimationFrame(() => { + textareaRef.current?.setSelectionRange(from + text.length, from + text.length); + }); + }, + }), + [getCaretRect, onChange, value] + ); + + const handleChange = (event: ChangeEvent) => { + onChange(event.target.value); + }; + + const handleKeyDown = (event: KeyboardEvent) => { + // Let parent handle key events first (for menu navigation) + if (onKeyDown) { + onKeyDown(event); + if (event.defaultPrevented) return; + } + + // Submit only on Cmd/Ctrl+Enter + if (event.key === 'Enter' && (event.metaKey || event.ctrlKey)) { + event.preventDefault(); + onSubmit?.(); + } + }; + + return ( +