'use client'; import { FormEvent, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { flushSync } from 'react-dom'; import { useVirtualizer } from '@tanstack/react-virtual'; import { IconBrain, IconCpu, IconListCheck, IconLoader2, IconPaperclip } from '@tabler/icons-react'; import { Button } from '@kandev/ui/button'; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, } from '@kandev/ui/dropdown-menu'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@kandev/ui/select'; 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 { 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'; type AgentOption = { id: string; label: string; }; type TaskChatPanelProps = { agents: AgentOption[]; onSend?: (message: string) => void; sessionId?: string | null; }; export function TaskChatPanel({ agents, onSend, sessionId = null, }: TaskChatPanelProps) { const [messageInput, setMessageInput] = useState(''); const [selectedAgent, setSelectedAgent] = useState(agents[0]?.id ?? ''); 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; // Fetch context window usage for this session const contextWindow = useSessionContextWindow(resolvedSessionId); // 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]); // 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 = 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; // 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]); // 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(); 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; await client.request( 'message.add', { task_id: taskId, session_id: resolvedSessionId, content: message }, 10000 ); }, [resolvedSessionId, taskId]); // Cancels the current agent turn without terminating the agent process, // allowing the user to interrupt and send a new prompt. const handleCancelTurn = useCallback(async () => { if (!resolvedSessionId) return; const client = getWebSocketClient(); if (!client) return; try { await client.request('agent.cancel', { session_id: resolvedSessionId }, 15000); } catch (error) { console.error('Failed to cancel agent turn:', error); } }, [resolvedSessionId]); const handleSubmit = async (event?: FormEvent) => { event?.preventDefault(); const trimmed = messageInput.trim(); if (!trimmed || isSending) return; setIsSending(true); flushSync(() => { setMessageInput(''); }); try { if (onSend) { await onSend(trimmed); } else { await handleSendMessage(trimmed); } } finally { setIsSending(false); } }; 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 and model */} {session?.state && (
{/* Model indicator */} {sessionModel && (
{sessionModel}
)}
)}
{todoItems.length > 0 && ( )} handleSubmit()} placeholder={ agentMessageCount > 0 ? 'Continue working on this task...' : 'Write to submit work to the agent...' } planModeEnabled={planModeEnabled} />
Thinking level High Medium Low Off
{planModeEnabled && ( Plan mode active )}
Toggle plan mode
{/* Context window usage indicator */} {contextWindow && }
Add attachments
); }