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({
)}
>
);
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',
+ },
+});