From 51e55a06d37f400c1f6d01de6b4cf8da42185846 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carlos=20Flor=C3=AAncio?= Date: Wed, 21 Jan 2026 20:54:41 +0000 Subject: [PATCH] feat: refactored chat input panels + pie context (#70) --- .../web/components/task/chat/chat-toolbar.tsx | 126 +++++ .../task/chat/token-usage-display.tsx | 75 ++- .../task/chat/virtualized-message-list.tsx | 142 ++++++ apps/web/components/task/task-chat-input.tsx | 127 +---- apps/web/components/task/task-chat-panel.tsx | 436 +++--------------- apps/web/hooks/use-chat-completions.ts | 74 +++ apps/web/hooks/use-message-handler.ts | 45 ++ apps/web/hooks/use-processed-messages.ts | 115 +++++ apps/web/hooks/use-session-model.ts | 33 ++ apps/web/hooks/use-session-state.ts | 28 ++ apps/web/lib/editor/chat-editor-theme.ts | 58 +++ 11 files changed, 741 insertions(+), 518 deletions(-) create mode 100644 apps/web/components/task/chat/chat-toolbar.tsx create mode 100644 apps/web/components/task/chat/virtualized-message-list.tsx create mode 100644 apps/web/hooks/use-chat-completions.ts create mode 100644 apps/web/hooks/use-message-handler.ts create mode 100644 apps/web/hooks/use-processed-messages.ts create mode 100644 apps/web/hooks/use-session-model.ts create mode 100644 apps/web/hooks/use-session-state.ts create mode 100644 apps/web/lib/editor/chat-editor-theme.ts diff --git a/apps/web/components/task/chat/chat-toolbar.tsx b/apps/web/components/task/chat/chat-toolbar.tsx new file mode 100644 index 0000000000..ac7a81857b --- /dev/null +++ b/apps/web/components/task/chat/chat-toolbar.tsx @@ -0,0 +1,126 @@ +'use client'; + +import { IconBrain, 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; +}; + +export function ChatToolbar({ + sessionId, + taskId, + taskTitle, + taskDescription, + planModeEnabled, + onPlanModeChange, + isAgentBusy, + isStarting, + isSending, + onCancel, +}: ChatToolbarProps) { + return ( +
+
+ + + + + + + + + Thinking level + + + High + Medium + Low + Off + + + + +
+ + {planModeEnabled && Plan mode active} +
+
+ Toggle plan mode +
+ {/* Context window usage indicator */} + +
+
+ + + + + + Add attachments + + + + +
+
+ ); +} diff --git a/apps/web/components/task/chat/token-usage-display.tsx b/apps/web/components/task/chat/token-usage-display.tsx index d65deb2117..7654c14e70 100644 --- a/apps/web/components/task/chat/token-usage-display.tsx +++ b/apps/web/components/task/chat/token-usage-display.tsx @@ -2,10 +2,10 @@ import { Tooltip, TooltipContent, TooltipTrigger } from '@kandev/ui/tooltip'; import { cn } from '@/lib/utils'; -import type { ContextWindowEntry } from '@/lib/state/store'; +import { useSessionContextWindow } from '@/hooks/use-session-context-window'; type TokenUsageDisplayProps = { - contextWindow: ContextWindowEntry; + sessionId: string | null; className?: string; }; @@ -19,39 +19,72 @@ function formatNumber(num: number): string { return num.toLocaleString(); } -function getBarColor(efficiency: number): string { - if (efficiency >= 90) return 'bg-red-500'; - if (efficiency >= 75) return 'bg-orange-500'; - if (efficiency >= 50) return 'bg-yellow-500'; - return 'bg-emerald-500'; +function getCircleColor(efficiency: number): string { + if (efficiency >= 90) return 'text-yellow-500'; + if (efficiency >= 75) return 'text-yellow-300'; + if (efficiency >= 50) return 'text-blue-500'; + return 'text-blue-300'; } -export function TokenUsageDisplay({ contextWindow, className }: TokenUsageDisplayProps) { - const { size, used, remaining, efficiency } = contextWindow; +export function TokenUsageDisplay({ sessionId, className }: TokenUsageDisplayProps) { + const contextWindow = useSessionContextWindow(sessionId); - if (size === 0) return null; + if (!contextWindow || contextWindow.size === 0) return null; + + const { size, used, efficiency } = contextWindow; const usagePercent = Math.min(efficiency, 100); + const progress = usagePercent / 100; + + // SVG circle parameters + const radius = 10; + const strokeWidth = 2.5; + const circumference = 2 * Math.PI * radius; + const strokeDashoffset = circumference * (1 - progress); return ( -
-
-
+
+
+
- - {efficiency.toFixed(0)}% -
-
Context: {formatNumber(used)} / {formatNumber(size)}
-
{formatNumber(remaining)} remaining
+
{efficiency.toFixed(0)}% ({formatNumber(used)} / {formatNumber(size)})
diff --git a/apps/web/components/task/chat/virtualized-message-list.tsx b/apps/web/components/task/chat/virtualized-message-list.tsx new file mode 100644 index 0000000000..81d9d07302 --- /dev/null +++ b/apps/web/components/task/chat/virtualized-message-list.tsx @@ -0,0 +1,142 @@ +'use client'; + +import { useCallback, useEffect, useRef } from 'react'; +import { useVirtualizer } from '@tanstack/react-virtual'; +import { IconLoader2 } from '@tabler/icons-react'; +import type { Message } from '@/lib/types/http'; +import { MessageRenderer } from '@/components/task/chat/message-renderer'; +import { useLazyLoadMessages } from '@/hooks/use-lazy-load-messages'; + +type VirtualizedMessageListProps = { + messages: Message[]; + permissionsByToolCallId: Map; + taskId?: string; + sessionId: string | null; + messagesLoading: boolean; + isWorking: boolean; +}; + +export function VirtualizedMessageList({ + messages, + permissionsByToolCallId, + taskId, + sessionId, + messagesLoading, + isWorking, +}: VirtualizedMessageListProps) { + const messagesContainerRef = useRef(null); + const wasAtBottomRef = useRef(true); + + const isInitialLoading = messagesLoading && messages.length === 0; + const showLoadingState = (messagesLoading || isInitialLoading) && !isWorking; + const { loadMore, hasMore, isLoading: isLoadingMore } = useLazyLoadMessages(sessionId); + + const itemCount = messages.length; + + // eslint-disable-next-line react-hooks/incompatible-library + const virtualizer = useVirtualizer({ + count: itemCount, + getScrollElement: () => messagesContainerRef.current, + estimateSize: () => 96, + overscan: 6, + }); + + const checkAtBottom = useCallback(() => { + const element = messagesContainerRef.current; + if (!element) return; + const { scrollTop, scrollHeight, clientHeight } = element; + wasAtBottomRef.current = scrollHeight - scrollTop - clientHeight < 48; + }, []); + + useEffect(() => { + const element = messagesContainerRef.current; + if (!element) return; + element.addEventListener('scroll', checkAtBottom); + return () => element.removeEventListener('scroll', checkAtBottom); + }, [checkAtBottom]); + + // Scroll to bottom when new messages arrive or when typing indicator appears + useEffect(() => { + if (itemCount === 0) return; + if (wasAtBottomRef.current) { + virtualizer.scrollToIndex(itemCount - 1, { align: 'end' }); + } + }, [itemCount, virtualizer]); + + const virtualItems = virtualizer.getVirtualItems(); + + // Lazy load older messages when scrolling to top + useEffect(() => { + const [firstItem] = virtualItems; + if (!firstItem) return; + const element = messagesContainerRef.current; + if (!element) return; + if (firstItem.index !== 0 || element.scrollTop > 40) { + return; + } + if (!hasMore || isLoadingMore) { + return; + } + const prevScrollHeight = element.scrollHeight; + const prevScrollTop = element.scrollTop; + loadMore().then((added) => { + if (!added) return; + requestAnimationFrame(() => { + const nextScrollHeight = element.scrollHeight; + element.scrollTop = prevScrollTop + (nextScrollHeight - prevScrollHeight); + }); + }); + }, [virtualItems, hasMore, isLoadingMore, loadMore]); + + return ( +
+ {isLoadingMore && hasMore && ( +
+ Loading older messages... +
+ )} + {/* Show loading messages spinner when initially loading */} + {showLoadingState && ( +
+ + Loading messages... +
+ )} + {/* Show empty state when no messages and no loading */} + {!messagesLoading && !isInitialLoading && messages.length === 0 && ( +
+ No messages yet. Start the conversation! +
+ )} + {/* Render messages */} + {!isInitialLoading && itemCount > 0 && ( +
+ {virtualizer.getVirtualItems().map((virtualRow) => { + const message = messages[virtualRow.index]; + return ( +
+
+ +
+
+ ); + })} +
+ )} +
+ ); +} diff --git a/apps/web/components/task/task-chat-input.tsx b/apps/web/components/task/task-chat-input.tsx index 2a1c3dc823..9b03c76d5c 100644 --- a/apps/web/components/task/task-chat-input.tsx +++ b/apps/web/components/task/task-chat-input.tsx @@ -1,17 +1,17 @@ 'use client'; -import { useMemo } from 'react'; import CodeMirror from '@uiw/react-codemirror'; import { markdown } from '@codemirror/lang-markdown'; import { EditorView, keymap, placeholder as cmPlaceholder } from '@codemirror/view'; -import { autocompletion, closeBrackets, closeBracketsKeymap, CompletionContext } from '@codemirror/autocomplete'; +import { autocompletion, closeBrackets, closeBracketsKeymap } from '@codemirror/autocomplete'; import { defaultKeymap, history, historyKeymap } from '@codemirror/commands'; import { Prec } from '@codemirror/state'; import type { Extension } from '@codemirror/state'; import { cn } from '@/lib/utils'; import { SHORTCUTS } from '@/lib/keyboard/constants'; import { matchesShortcut, shortcutToCodeMirrorKeybinding } from '@/lib/keyboard/utils'; -import { useCustomPrompts } from '@/hooks/use-custom-prompts'; +import { chatEditorTheme } from '@/lib/editor/chat-editor-theme'; +import { useChatCompletions } from '@/hooks/use-chat-completions'; type TaskChatInputProps = { value: string; @@ -33,124 +33,7 @@ export function TaskChatInput({ planModeEnabled, }: TaskChatInputProps) { const submitKey = shortcutToCodeMirrorKeybinding(SHORTCUTS.SUBMIT); - const { prompts } = useCustomPrompts(); - - const promptOptions = useMemo(() => { - return prompts.map((prompt) => ({ - id: prompt.id, - name: prompt.name, - content: prompt.content, - })); - }, [prompts]); - - const truncatePreview = (text: string, max = 140) => { - if (text.length <= max) return text; - return `${text.slice(0, max)}…`; - }; - - const completionSource = (context: CompletionContext) => { - const slashMatch = context.matchBefore(/(^|\s)\/[\w-]*/); - if (slashMatch) { - const from = slashMatch.from + (slashMatch.text.startsWith('/') ? 0 : 1); - return { - from, - options: [ - { label: '/plan', type: 'slash', info: 'Start a planning response' }, - { label: '/todo', type: 'slash', info: 'Add a todo list' }, - { label: '/review', type: 'slash', info: 'Request a review' }, - { label: '/summarize', type: 'slash', info: 'Summarize the task' }, - ], - }; - } - - const mentionMatch = context.matchBefore(/(^|\s)@[\w-]*/); - if (mentionMatch) { - const from = mentionMatch.from + (mentionMatch.text.startsWith('@') ? 0 : 1); - const trimmed = mentionMatch.text.trim(); - const query = trimmed.startsWith('@') ? trimmed.slice(1).toLowerCase() : trimmed.toLowerCase(); - const promptMatches = promptOptions - .filter((prompt) => prompt.name.toLowerCase().startsWith(query)) - .map((prompt) => ({ - label: `@${prompt.name}`, - type: 'prompt', - detail: 'Prompt', - info: truncatePreview(prompt.content), - apply: (view: EditorView, _completion: unknown, applyFrom: number, applyTo: number) => { - const insertText = `${prompt.content}\n`; - const cursorPos = applyFrom + insertText.length; - view.dispatch({ - changes: { from: applyFrom, to: applyTo, insert: insertText }, - selection: { anchor: cursorPos }, - }); - view.dispatch({ effects: EditorView.scrollIntoView(cursorPos) }); - }, - })); - return { - from, - // Future: add file path completions alongside prompts using additional providers. - options: [...promptMatches], - }; - } - - return null; - }; - - const editorTheme = EditorView.theme({ - '&': { - color: 'var(--foreground)', - fontSize: '0.875rem', - backgroundColor: 'transparent', - }, - '.cm-editor': { - borderRadius: 'inherit', - background: 'transparent', - outline: 'none', - boxShadow: 'none', - overflow: 'hidden', - }, - '.cm-editor.cm-focused': { - outline: 'none', - boxShadow: 'none', - }, - '.cm-scroller': { - fontFamily: 'var(--font-sans)', - borderRadius: 'inherit', - background: 'transparent', - backgroundColor: 'transparent', - overflow: 'auto', - }, - '.cm-content': { - padding: '0.5rem', - fontFamily: 'var(--font-sans)', - color: 'var(--foreground)', - background: 'transparent', - }, - '.cm-line': { - color: 'var(--foreground)', - }, - '.cm-placeholder': { - color: 'var(--muted-foreground)', - opacity: '0.8', - }, - '.cm-tooltip-autocomplete, .cm-tooltip-autocomplete li, .cm-completionDetail': { - fontSize: '0.875rem', - }, - '.cm-cursor': { - borderLeftColor: 'var(--foreground)', - }, - '.cm-selectionBackground': { - backgroundColor: 'var(--primary)', - opacity: '0.7', - }, - '.cm-selectionMatch': { - backgroundColor: 'var(--primary)', - opacity: '0.6', - }, - '.cm-selectionLayer .cm-selectionBackground': { - backgroundColor: 'var(--primary)', - opacity: '0.7', - }, - }); + const completionSource = useChatCompletions(); const handleSubmitKey = () => { onSubmit(); @@ -166,7 +49,7 @@ export function TaskChatInput({ const extensions: Extension[] = [ EditorView.lineWrapping, - editorTheme, + chatEditorTheme, EditorView.domEventHandlers({ keydown: (event) => { if (matchesShortcut(event, SHORTCUTS.SUBMIT)) { diff --git a/apps/web/components/task/task-chat-panel.tsx b/apps/web/components/task/task-chat-panel.tsx index 965128c763..d1cb049c57 100644 --- a/apps/web/components/task/task-chat-panel.tsx +++ b/apps/web/components/task/task-chat-panel.tsx @@ -1,37 +1,19 @@ 'use client'; -import { FormEvent, useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { FormEvent, useCallback, useEffect, useRef, useState } from 'react'; import { flushSync } from 'react-dom'; -import { useVirtualizer } from '@tanstack/react-virtual'; -import { IconBrain, 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 { useAppStore } from '@/components/state-provider'; import { getWebSocketClient } from '@/lib/ws/connection'; -import type { Message } from '@/lib/types/http'; -import { SHORTCUTS } from '@/lib/keyboard/constants'; -import { KeyboardShortcutTooltip } from '@/components/keyboard-shortcut-tooltip'; -import { TaskChatInput } from '@/components/task/task-chat-input'; -import { useLazyLoadMessages } from '@/hooks/use-lazy-load-messages'; -import { useSession } from '@/hooks/use-session'; -import { useTask } from '@/hooks/use-task'; import { useSessionMessages } from '@/hooks/use-session-messages'; import { useSettingsData } from '@/hooks/use-settings-data'; -import { MessageRenderer } from '@/components/task/chat/message-renderer'; +import { useSessionState } from '@/hooks/use-session-state'; +import { useProcessedMessages } from '@/hooks/use-processed-messages'; +import { useSessionModel } from '@/hooks/use-session-model'; +import { useMessageHandler } from '@/hooks/use-message-handler'; +import { TaskChatInput } from '@/components/task/task-chat-input'; import { RunningIndicator } from '@/components/task/chat/messages/running-indicator'; -import { TokenUsageDisplay } from '@/components/task/chat/token-usage-display'; import { TodoSummary } from '@/components/task/chat/todo-summary'; -import { SessionsDropdown } from '@/components/task/sessions-dropdown'; -import { useSessionContextWindow } from '@/hooks/use-session-context-window'; -import { ModelSelector } from '@/components/task/model-selector'; +import { VirtualizedMessageList } from '@/components/task/chat/virtualized-message-list'; +import { ChatToolbar } from '@/components/task/chat/chat-toolbar'; type TaskChatPanelProps = { onSend?: (message: string) => void; @@ -45,238 +27,55 @@ export function TaskChatPanel({ const [messageInput, setMessageInput] = useState(''); const [planModeEnabled, setPlanModeEnabled] = useState(false); const [isSending, setIsSending] = useState(false); - const messagesContainerRef = useRef(null); const lastAgentMessageCountRef = useRef(0); - const wasAtBottomRef = useRef(true); - - const activeSessionId = useAppStore((state) => state.tasks.activeSessionId); - const resolvedSessionId = sessionId ?? activeSessionId; - - // Get task session details and derived task information from session ID - const { session } = useSession(resolvedSessionId); - const task = useTask(session?.task_id ?? null); - const taskId = session?.task_id ?? null; - const taskDescription = task?.description ?? null; - const isStarting = session?.state === 'STARTING'; - const isWorking = isStarting || session?.state === 'RUNNING'; - const isAgentBusy = session?.state === 'CREATED' || session?.state === 'RUNNING'; // Ensure agent profile data is loaded (may not be hydrated from SSR in all navigation paths) useSettingsData(true); - // Get model from agent profile using agent_profile_id - const settingsAgents = useAppStore((state) => state.settingsAgents.items); - const sessionProfile = useMemo(() => { - if (!session?.agent_profile_id) return null; - for (const agent of settingsAgents) { - const profile = agent.profiles.find((p) => p.id === session.agent_profile_id); - if (profile) return profile; - } - return null; - }, [session?.agent_profile_id, settingsAgents]); - - const sessionModel = sessionProfile?.model ?? null; - - // Get pending model state for this session - const pendingModels = useAppStore((state) => state.pendingModel.bySessionId); - const clearPendingModel = useAppStore((state) => state.clearPendingModel); - const setActiveModel = useAppStore((state) => state.setActiveModel); - const pendingModel = resolvedSessionId ? pendingModels[resolvedSessionId] : null; - - // Fetch context window usage for this session - const contextWindow = useSessionContextWindow(resolvedSessionId); + // Session state management + const { + resolvedSessionId, + session, + task, + taskId, + taskDescription, + isStarting, + isWorking, + isAgentBusy, + } = useSessionState(sessionId); // Fetch messages for this session const { messages, isLoading: messagesLoading } = useSessionMessages(resolvedSessionId); - const isInitialLoading = messagesLoading && messages.length === 0; - const showLoadingState = (messagesLoading || isInitialLoading) && !isWorking; - const { loadMore, hasMore, isLoading: isLoadingMore } = useLazyLoadMessages(resolvedSessionId); - // Build a set of tool_call_ids that exist in the messages - const toolCallIds = useMemo(() => { - const set = new Set(); - for (const message of messages) { - if (message.type === 'tool_call') { - const toolCallId = (message.metadata as { tool_call_id?: string } | undefined)?.tool_call_id; - if (toolCallId) { - set.add(toolCallId); - } - } - } - return set; - }, [messages]); + // Process messages (filtering, todos, etc.) + const { allMessages, permissionsByToolCallId, todoItems, agentMessageCount } = useProcessedMessages( + messages, + taskId, + resolvedSessionId, + taskDescription + ); - // Build a lookup map of tool_call_id → permission_request message - const permissionsByToolCallId = useMemo(() => { - const map = new Map(); - for (const message of messages) { - if (message.type === 'permission_request') { - const toolCallId = (message.metadata as { tool_call_id?: string } | undefined)?.tool_call_id; - if (toolCallId) { - map.set(toolCallId, message); - } - } - } - return map; - }, [messages]); + // Model management + const { sessionModel, pendingModel, clearPendingModel, setActiveModel } = useSessionModel( + resolvedSessionId, + session?.agent_profile_id + ); - // Filter to only show chat-relevant types. - // Permission requests are only hidden if they have a matching tool_call in the messages - // (so they can be displayed inline). Standalone permissions are shown as separate messages. - const visibleMessages = messages.filter((message) => { - // Standard visible types - if ( - !message.type || - message.type === 'message' || - message.type === 'content' || - message.type === 'tool_call' || - message.type === 'progress' || - message.type === 'status' || - message.type === 'error' || - message.type === 'thinking' || - message.type === 'todo' - ) { - return true; - } - - // Permission requests: show if no matching tool_call exists (standalone permission) - if (message.type === 'permission_request') { - const toolCallId = (message.metadata as { tool_call_id?: string } | undefined)?.tool_call_id; - // Show as standalone if no tool_call_id or no matching tool_call message exists - return !toolCallId || !toolCallIds.has(toolCallId); - } - - return false; - }); - - // Create a synthetic "user" message for the task description - const taskDescriptionMessage: Message | null = - taskDescription && visibleMessages.length === 0 - ? { - id: 'task-description', - task_id: taskId ?? '', - session_id: resolvedSessionId ?? '', - author_type: 'user', - content: taskDescription, - type: 'message', - created_at: '', - } - : null; - - // Combine task description with visible messages - const allMessages = taskDescriptionMessage - ? [taskDescriptionMessage, ...visibleMessages] - : visibleMessages; - - const latestTodos = [...visibleMessages] - .reverse() - .find((message) => message.type === 'todo' || (message.metadata as { todos?: unknown })?.todos); - - const todoItems = (latestTodos?.metadata as { todos?: Array<{ text: string; done?: boolean } | string> } | undefined) - ?.todos - ?.map((item) => (typeof item === 'string' ? { text: item, done: false } : item)) - .filter((item) => item.text) ?? []; - - // Count agent messages to detect new responses - const agentMessageCount = visibleMessages.filter((c) => c.author_type !== 'user').length; + // Message sending + const { handleSendMessage } = useMessageHandler( + resolvedSessionId, + taskId, + sessionModel, + pendingModel, + setActiveModel, + clearPendingModel + ); // Clear awaiting state when a new agent message arrives useEffect(() => { lastAgentMessageCountRef.current = agentMessageCount; }, [agentMessageCount]); - const itemCount = allMessages.length; - - const virtualizer = useVirtualizer({ - count: itemCount, - getScrollElement: () => messagesContainerRef.current, - estimateSize: () => 96, - overscan: 6, - }); - - const checkAtBottom = useCallback(() => { - const element = messagesContainerRef.current; - if (!element) return; - const { scrollTop, scrollHeight, clientHeight } = element; - wasAtBottomRef.current = scrollHeight - scrollTop - clientHeight < 48; - }, []); - - useEffect(() => { - const element = messagesContainerRef.current; - if (!element) return; - element.addEventListener('scroll', checkAtBottom); - return () => element.removeEventListener('scroll', checkAtBottom); - }, [checkAtBottom]); - - // Track last message content to detect streaming updates - // Use messages from store directly (stable reference) to avoid lint warning - const lastMessageContent = useMemo(() => { - if (messages.length === 0) return ''; - const lastMsg = messages[messages.length - 1]; - return lastMsg?.content ?? ''; - }, [messages]); - - // Scroll to bottom when new messages arrive or when last message content changes (streaming) - useEffect(() => { - if (itemCount === 0) return; - if (wasAtBottomRef.current) { - virtualizer.scrollToIndex(itemCount - 1, { align: 'end' }); - } - }, [itemCount, lastMessageContent, virtualizer]); - - const virtualItems = virtualizer.getVirtualItems(); - - useEffect(() => { - const [firstItem] = virtualItems; - if (!firstItem) return; - const element = messagesContainerRef.current; - if (!element) return; - if (firstItem.index !== 0 || element.scrollTop > 40) { - return; - } - if (!hasMore || isLoadingMore) { - return; - } - const prevScrollHeight = element.scrollHeight; - const prevScrollTop = element.scrollTop; - loadMore().then((added) => { - if (!added) return; - requestAnimationFrame(() => { - const nextScrollHeight = element.scrollHeight; - element.scrollTop = prevScrollTop + (nextScrollHeight - prevScrollHeight); - }); - }); - }, [virtualItems, hasMore, isLoadingMore, loadMore]); - - const handleSendMessage = useCallback(async (message: string) => { - if (!taskId || !resolvedSessionId) { - console.error('No active task session. Start an agent before sending a message.'); - return; - } - const client = getWebSocketClient(); - if (!client) return; - - // Include pending model in the request if one is selected - const modelToSend = pendingModel && pendingModel !== sessionModel ? pendingModel : undefined; - - await client.request( - 'message.add', - { - task_id: taskId, - session_id: resolvedSessionId, - content: message, - ...(modelToSend && { model: modelToSend }), - }, - 10000 - ); - - // Move pending model to active model after sending (it's now the model in use) - if (modelToSend && resolvedSessionId) { - setActiveModel(resolvedSessionId, modelToSend); - clearPendingModel(resolvedSessionId); - } - }, [resolvedSessionId, taskId, pendingModel, sessionModel, setActiveModel, clearPendingModel]); - // Cancels the current agent turn without terminating the agent process, // allowing the user to interrupt and send a new prompt. const handleCancelTurn = useCallback(async () => { @@ -313,55 +112,14 @@ export function TaskChatPanel({ return ( <> -
- {isLoadingMore && hasMore && ( -
- Loading older messages... -
- )} - {/* Show loading messages spinner when initially loading */} - {showLoadingState && ( -
- - Loading messages... -
- )} - {/* Show empty state when no messages and no loading */} - {!messagesLoading && !isInitialLoading && visibleMessages.length === 0 && ( -
- No messages yet. Start the conversation! -
- )} - {/* Render messages */} - {!isInitialLoading && itemCount > 0 && ( -
- {virtualizer.getVirtualItems().map((virtualRow) => { - const message = allMessages[virtualRow.index]; - return ( -
-
- -
-
- ); - })} -
- )} -
+ {/* Session info - shows agent state */} {session?.state && ( @@ -371,9 +129,7 @@ export function TaskChatPanel({ )}
- {todoItems.length > 0 && ( - - )} + {todoItems.length > 0 && } -
-
- - - - - - - - - Thinking level - - - High - Medium - Low - Off - - - - -
- - {planModeEnabled && ( - Plan mode active - )} -
-
- Toggle plan mode -
- {/* Context window usage indicator */} - {contextWindow && } -
-
- - - - - - Add attachments - - - - -
-
+ ); diff --git a/apps/web/hooks/use-chat-completions.ts b/apps/web/hooks/use-chat-completions.ts new file mode 100644 index 0000000000..2650942ff1 --- /dev/null +++ b/apps/web/hooks/use-chat-completions.ts @@ -0,0 +1,74 @@ +import { useMemo } from 'react'; +import { EditorView } from '@codemirror/view'; +import type { CompletionContext } from '@codemirror/autocomplete'; +import { useCustomPrompts } from '@/hooks/use-custom-prompts'; + +const truncatePreview = (text: string, max = 140) => { + if (text.length <= max) return text; + return `${text.slice(0, max)}…`; +}; + +export function useChatCompletions() { + const { prompts } = useCustomPrompts(); + + const promptOptions = useMemo(() => { + return prompts.map((prompt) => ({ + id: prompt.id, + name: prompt.name, + content: prompt.content, + })); + }, [prompts]); + + const completionSource = useMemo(() => { + return (context: CompletionContext) => { + // Slash command completions + const slashMatch = context.matchBefore(/(^|\s)\/[\w-]*/); + if (slashMatch) { + const from = slashMatch.from + (slashMatch.text.startsWith('/') ? 0 : 1); + return { + from, + options: [ + { label: '/plan', type: 'slash', info: 'Start a planning response' }, + { label: '/todo', type: 'slash', info: 'Add a todo list' }, + { label: '/review', type: 'slash', info: 'Request a review' }, + { label: '/summarize', type: 'slash', info: 'Summarize the task' }, + ], + }; + } + + // @prompt mention completions + const mentionMatch = context.matchBefore(/(^|\s)@[\w-]*/); + if (mentionMatch) { + const from = mentionMatch.from + (mentionMatch.text.startsWith('@') ? 0 : 1); + const trimmed = mentionMatch.text.trim(); + const query = trimmed.startsWith('@') ? trimmed.slice(1).toLowerCase() : trimmed.toLowerCase(); + const promptMatches = promptOptions + .filter((prompt) => prompt.name.toLowerCase().startsWith(query)) + .map((prompt) => ({ + label: `@${prompt.name}`, + type: 'prompt', + detail: 'Prompt', + info: truncatePreview(prompt.content), + apply: (view: EditorView, _completion: unknown, applyFrom: number, applyTo: number) => { + const insertText = `${prompt.content}\n`; + const cursorPos = applyFrom + insertText.length; + view.dispatch({ + changes: { from: applyFrom, to: applyTo, insert: insertText }, + selection: { anchor: cursorPos }, + }); + view.dispatch({ effects: EditorView.scrollIntoView(cursorPos) }); + }, + })); + return { + from, + // Future: add file path completions alongside prompts using additional providers. + options: [...promptMatches], + }; + } + + return null; + }; + }, [promptOptions]); + + return completionSource; +} diff --git a/apps/web/hooks/use-message-handler.ts b/apps/web/hooks/use-message-handler.ts new file mode 100644 index 0000000000..26537ba50e --- /dev/null +++ b/apps/web/hooks/use-message-handler.ts @@ -0,0 +1,45 @@ +import { useCallback } from 'react'; +import { getWebSocketClient } from '@/lib/ws/connection'; + +export function useMessageHandler( + resolvedSessionId: string | null, + taskId: string | null, + sessionModel: string | null, + pendingModel: string | null, + setActiveModel: (sessionId: string, modelId: string) => void, + clearPendingModel: (sessionId: string) => void +) { + const handleSendMessage = useCallback( + async (message: string) => { + if (!taskId || !resolvedSessionId) { + console.error('No active task session. Start an agent before sending a message.'); + return; + } + const client = getWebSocketClient(); + if (!client) return; + + // Include pending model in the request if one is selected + const modelToSend = pendingModel && pendingModel !== sessionModel ? pendingModel : undefined; + + await client.request( + 'message.add', + { + task_id: taskId, + session_id: resolvedSessionId, + content: message, + ...(modelToSend && { model: modelToSend }), + }, + 10000 + ); + + // Move pending model to active model after sending (it's now the model in use) + if (modelToSend && resolvedSessionId) { + setActiveModel(resolvedSessionId, modelToSend); + clearPendingModel(resolvedSessionId); + } + }, + [resolvedSessionId, taskId, pendingModel, sessionModel, setActiveModel, clearPendingModel] + ); + + return { handleSendMessage }; +} diff --git a/apps/web/hooks/use-processed-messages.ts b/apps/web/hooks/use-processed-messages.ts new file mode 100644 index 0000000000..ac56a684c5 --- /dev/null +++ b/apps/web/hooks/use-processed-messages.ts @@ -0,0 +1,115 @@ +import { useMemo } from 'react'; +import type { Message } from '@/lib/types/http'; + +export function useProcessedMessages( + messages: Message[], + taskId: string | null, + resolvedSessionId: string | null, + taskDescription: string | null +) { + // Build a set of tool_call_ids that exist in the messages + const toolCallIds = useMemo(() => { + const set = new Set(); + for (const message of messages) { + if (message.type === 'tool_call') { + const toolCallId = (message.metadata as { tool_call_id?: string } | undefined)?.tool_call_id; + if (toolCallId) { + set.add(toolCallId); + } + } + } + return set; + }, [messages]); + + // Build a lookup map of tool_call_id → permission_request message + const permissionsByToolCallId = useMemo(() => { + const map = new Map(); + for (const message of messages) { + if (message.type === 'permission_request') { + const toolCallId = (message.metadata as { tool_call_id?: string } | undefined)?.tool_call_id; + if (toolCallId) { + map.set(toolCallId, message); + } + } + } + return map; + }, [messages]); + + // Filter to only show chat-relevant types. + // Permission requests are only hidden if they have a matching tool_call in the messages + // (so they can be displayed inline). Standalone permissions are shown as separate messages. + const visibleMessages = useMemo(() => { + return messages.filter((message) => { + // Standard visible types + if ( + !message.type || + message.type === 'message' || + message.type === 'content' || + message.type === 'tool_call' || + message.type === 'progress' || + message.type === 'status' || + message.type === 'error' || + message.type === 'thinking' || + message.type === 'todo' + ) { + return true; + } + + // Permission requests: show if no matching tool_call exists (standalone permission) + if (message.type === 'permission_request') { + const toolCallId = (message.metadata as { tool_call_id?: string } | undefined)?.tool_call_id; + // Show as standalone if no tool_call_id or no matching tool_call message exists + return !toolCallId || !toolCallIds.has(toolCallId); + } + + return false; + }); + }, [messages, toolCallIds]); + + // Create a synthetic "user" message for the task description + const taskDescriptionMessage: Message | null = useMemo(() => { + return taskDescription && visibleMessages.length === 0 + ? { + id: 'task-description', + task_id: taskId ?? '', + session_id: resolvedSessionId ?? '', + author_type: 'user', + content: taskDescription, + type: 'message', + created_at: '', + } + : null; + }, [taskDescription, visibleMessages.length, taskId, resolvedSessionId]); + + // Combine task description with visible messages + const allMessages = useMemo(() => { + return taskDescriptionMessage ? [taskDescriptionMessage, ...visibleMessages] : visibleMessages; + }, [taskDescriptionMessage, visibleMessages]); + + // Extract todo items from latest message + const todoItems = useMemo(() => { + const latestTodos = [...visibleMessages] + .reverse() + .find((message) => message.type === 'todo' || (message.metadata as { todos?: unknown })?.todos); + + return ( + (latestTodos?.metadata as { todos?: Array<{ text: string; done?: boolean } | string> } | undefined)?.todos + ?.map((item) => (typeof item === 'string' ? { text: item, done: false } : item)) + .filter((item) => item.text) ?? [] + ); + }, [visibleMessages]); + + // Count agent messages to detect new responses + const agentMessageCount = useMemo(() => { + return visibleMessages.filter((c) => c.author_type !== 'user').length; + }, [visibleMessages]); + + return { + visibleMessages, + allMessages, + toolCallIds, + permissionsByToolCallId, + todoItems, + agentMessageCount, + }; +} diff --git a/apps/web/hooks/use-session-model.ts b/apps/web/hooks/use-session-model.ts new file mode 100644 index 0000000000..63c0077929 --- /dev/null +++ b/apps/web/hooks/use-session-model.ts @@ -0,0 +1,33 @@ +import { useMemo } from 'react'; +import { useAppStore } from '@/components/state-provider'; + +export function useSessionModel( + resolvedSessionId: string | null, + agentProfileId: string | null | undefined +) { + // Get model from agent profile using agent_profile_id + const settingsAgents = useAppStore((state) => state.settingsAgents.items); + const sessionProfile = useMemo(() => { + if (!agentProfileId) return null; + for (const agent of settingsAgents) { + const profile = agent.profiles.find((p) => p.id === agentProfileId); + if (profile) return profile; + } + return null; + }, [agentProfileId, settingsAgents]); + + const sessionModel = sessionProfile?.model ?? null; + + // Get pending model state for this session + const pendingModels = useAppStore((state) => state.pendingModel.bySessionId); + const clearPendingModel = useAppStore((state) => state.clearPendingModel); + const setActiveModel = useAppStore((state) => state.setActiveModel); + const pendingModel = resolvedSessionId ? pendingModels[resolvedSessionId] : null; + + return { + sessionModel, + pendingModel, + clearPendingModel, + setActiveModel, + }; +} diff --git a/apps/web/hooks/use-session-state.ts b/apps/web/hooks/use-session-state.ts new file mode 100644 index 0000000000..e9f9631964 --- /dev/null +++ b/apps/web/hooks/use-session-state.ts @@ -0,0 +1,28 @@ +import { useAppStore } from '@/components/state-provider'; +import { useSession } from '@/hooks/use-session'; +import { useTask } from '@/hooks/use-task'; + +export function useSessionState(sessionId: string | null) { + const activeSessionId = useAppStore((state) => state.tasks.activeSessionId); + const resolvedSessionId = sessionId ?? activeSessionId; + + const { session } = useSession(resolvedSessionId); + const task = useTask(session?.task_id ?? null); + + const taskId = session?.task_id ?? null; + const taskDescription = task?.description ?? null; + const isStarting = session?.state === 'STARTING'; + const isWorking = isStarting || session?.state === 'RUNNING'; + const isAgentBusy = session?.state === 'CREATED' || session?.state === 'RUNNING'; + + return { + resolvedSessionId, + session, + task, + taskId, + taskDescription, + isStarting, + isWorking, + isAgentBusy, + }; +} diff --git a/apps/web/lib/editor/chat-editor-theme.ts b/apps/web/lib/editor/chat-editor-theme.ts new file mode 100644 index 0000000000..835c7edb4b --- /dev/null +++ b/apps/web/lib/editor/chat-editor-theme.ts @@ -0,0 +1,58 @@ +import { EditorView } from '@codemirror/view'; + +export const chatEditorTheme = EditorView.theme({ + '&': { + color: 'var(--foreground)', + fontSize: '0.875rem', + backgroundColor: 'transparent', + }, + '.cm-editor': { + borderRadius: 'inherit', + background: 'transparent', + outline: 'none', + boxShadow: 'none', + overflow: 'hidden', + }, + '.cm-editor.cm-focused': { + outline: 'none', + boxShadow: 'none', + }, + '.cm-scroller': { + fontFamily: 'var(--font-sans)', + borderRadius: 'inherit', + background: 'transparent', + backgroundColor: 'transparent', + overflow: 'auto', + }, + '.cm-content': { + padding: '0.5rem', + fontFamily: 'var(--font-sans)', + color: 'var(--foreground)', + background: 'transparent', + }, + '.cm-line': { + color: 'var(--foreground)', + }, + '.cm-placeholder': { + color: 'var(--muted-foreground)', + opacity: '0.8', + }, + '.cm-tooltip-autocomplete, .cm-tooltip-autocomplete li, .cm-completionDetail': { + fontSize: '0.875rem', + }, + '.cm-cursor': { + borderLeftColor: 'var(--foreground)', + }, + '.cm-selectionBackground': { + backgroundColor: 'var(--primary)', + opacity: '0.7', + }, + '.cm-selectionMatch': { + backgroundColor: 'var(--primary)', + opacity: '0.6', + }, + '.cm-selectionLayer .cm-selectionBackground': { + backgroundColor: 'var(--primary)', + opacity: '0.7', + }, +});