feat: refactored chat input panels + pie context (#70)

This commit is contained in:
Carlos Florêncio
2026-01-21 20:54:41 +00:00
committed by GitHub
parent 8dd88e8c60
commit 51e55a06d3
11 changed files with 741 additions and 518 deletions
@@ -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 (
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2">
<ModelSelector sessionId={sessionId} />
<DropdownMenu>
<Tooltip>
<TooltipTrigger asChild>
<DropdownMenuTrigger asChild>
<Button type="button" variant="outline" size="icon" className="h-7 w-7 cursor-pointer">
<IconBrain className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
</TooltipTrigger>
<TooltipContent>Thinking level</TooltipContent>
</Tooltip>
<DropdownMenuContent align="start" side="top">
<DropdownMenuItem>High</DropdownMenuItem>
<DropdownMenuItem>Medium</DropdownMenuItem>
<DropdownMenuItem>Low</DropdownMenuItem>
<DropdownMenuItem>Off</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center gap-2">
<Button
type="button"
variant="outline"
size="icon"
className={cn(
'h-7 w-7 cursor-pointer',
planModeEnabled &&
'bg-primary/15 text-primary border-primary/40 shadow-[0_0_0_1px_rgba(59,130,246,0.35)]'
)}
onClick={() => onPlanModeChange(!planModeEnabled)}
>
<IconListCheck className="h-4 w-4" />
</Button>
{planModeEnabled && <span className="text-xs font-medium text-primary">Plan mode active</span>}
</div>
</TooltipTrigger>
<TooltipContent>Toggle plan mode</TooltipContent>
</Tooltip>
{/* Context window usage indicator */}
<TokenUsageDisplay sessionId={sessionId} />
</div>
<div className="flex items-center gap-2">
<SessionsDropdown
taskId={taskId}
activeSessionId={sessionId}
taskTitle={taskTitle}
taskDescription={taskDescription}
/>
<Tooltip>
<TooltipTrigger asChild>
<Button type="button" variant="outline" size="icon" className="h-7 w-7 cursor-pointer">
<IconPaperclip className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>Add attachments</TooltipContent>
</Tooltip>
<KeyboardShortcutTooltip shortcut={SHORTCUTS.SUBMIT} enabled={!isAgentBusy && !isStarting}>
<Button
type={isAgentBusy ? 'button' : 'submit'}
variant={isAgentBusy ? 'destructive' : 'default'}
className={cn('h-7', isAgentBusy && 'gap-2')}
disabled={isStarting || isSending}
onClick={isAgentBusy ? onCancel : undefined}
>
{isAgentBusy ? (
<>
<IconLoader2 className="h-3.5 w-3.5 animate-spin" />
Stop
</>
) : (
'Submit'
)}
</Button>
</KeyboardShortcutTooltip>
</div>
</div>
);
}
@@ -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 (
<Tooltip>
<TooltipTrigger asChild>
<div className={cn('flex items-center gap-1.5 cursor-default', className)}>
<div className="w-16 h-1.5 rounded-full bg-muted/50 overflow-hidden">
<div
className={cn('h-full rounded-full transition-all', getBarColor(efficiency))}
style={{ width: `${usagePercent}%` }}
/>
<div className={cn('flex items-center gap-2 cursor-help', className)}>
<div className="relative flex items-center justify-center">
<svg
viewBox="0 0 24 24"
className="w-5 h-5 -rotate-90"
aria-hidden="true"
>
{/* Background circle */}
<circle
cx="12"
cy="12"
r={radius}
fill="none"
stroke="currentColor"
strokeWidth={strokeWidth}
className="text-muted"
/>
{/* Progress circle */}
<circle
cx="12"
cy="12"
r={radius}
fill="none"
stroke="currentColor"
strokeWidth={strokeWidth}
strokeLinecap="round"
strokeDasharray={circumference}
strokeDashoffset={strokeDashoffset}
className={cn(
getCircleColor(efficiency),
'transition-all duration-300 ease-out'
)}
/>
</svg>
</div>
<span className="text-[10px] text-muted-foreground tabular-nums">
{efficiency.toFixed(0)}%
</span>
</div>
</TooltipTrigger>
<TooltipContent side="top">
<div className="text-xs space-y-1">
<div className="font-medium">Context: {formatNumber(used)} / {formatNumber(size)}</div>
<div className="text-muted-foreground">{formatNumber(remaining)} remaining</div>
<div className="font-medium">{efficiency.toFixed(0)}% ({formatNumber(used)} / {formatNumber(size)})</div>
</div>
</TooltipContent>
</Tooltip>
@@ -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<string, Message>;
taskId?: string;
sessionId: string | null;
messagesLoading: boolean;
isWorking: boolean;
};
export function VirtualizedMessageList({
messages,
permissionsByToolCallId,
taskId,
sessionId,
messagesLoading,
isWorking,
}: VirtualizedMessageListProps) {
const messagesContainerRef = useRef<HTMLDivElement>(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 (
<div
ref={messagesContainerRef}
className="relative flex-1 min-h-0 overflow-y-auto rounded-lg bg-background p-3"
>
{isLoadingMore && hasMore && (
<div className="absolute top-2 left-1/2 -translate-x-1/2 text-xs text-muted-foreground">
Loading older messages...
</div>
)}
{/* Show loading messages spinner when initially loading */}
{showLoadingState && (
<div className="flex items-center justify-center py-8 text-muted-foreground">
<IconLoader2 className="h-5 w-5 animate-spin mr-2" />
<span>Loading messages...</span>
</div>
)}
{/* Show empty state when no messages and no loading */}
{!messagesLoading && !isInitialLoading && messages.length === 0 && (
<div className="flex items-center justify-center py-8 text-muted-foreground">
<span>No messages yet. Start the conversation!</span>
</div>
)}
{/* Render messages */}
{!isInitialLoading && itemCount > 0 && (
<div className="relative w-full" style={{ height: `${virtualizer.getTotalSize()}px` }}>
{virtualizer.getVirtualItems().map((virtualRow) => {
const message = messages[virtualRow.index];
return (
<div
key={virtualRow.key}
ref={virtualizer.measureElement}
data-index={virtualRow.index}
className="absolute left-0 top-0 w-full"
style={{ transform: `translateY(${virtualRow.start}px)` }}
>
<div className="pb-3">
<MessageRenderer
comment={message}
isTaskDescription={message.id === 'task-description'}
taskId={taskId}
permissionsByToolCallId={permissionsByToolCallId}
/>
</div>
</div>
);
})}
</div>
)}
</div>
);
}
+5 -122
View File
@@ -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)) {
+61 -375
View File
@@ -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<HTMLDivElement>(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<string>();
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<string, Message>();
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 (
<>
<div
ref={messagesContainerRef}
className="relative flex-1 min-h-0 overflow-y-auto rounded-lg bg-background p-3"
>
{isLoadingMore && hasMore && (
<div className="absolute top-2 left-1/2 -translate-x-1/2 text-xs text-muted-foreground">
Loading older messages...
</div>
)}
{/* Show loading messages spinner when initially loading */}
{showLoadingState && (
<div className="flex items-center justify-center py-8 text-muted-foreground">
<IconLoader2 className="h-5 w-5 animate-spin mr-2" />
<span>Loading messages...</span>
</div>
)}
{/* Show empty state when no messages and no loading */}
{!messagesLoading && !isInitialLoading && visibleMessages.length === 0 && (
<div className="flex items-center justify-center py-8 text-muted-foreground">
<span>No messages yet. Start the conversation!</span>
</div>
)}
{/* Render messages */}
{!isInitialLoading && itemCount > 0 && (
<div className="relative w-full" style={{ height: `${virtualizer.getTotalSize()}px` }}>
{virtualizer.getVirtualItems().map((virtualRow) => {
const message = allMessages[virtualRow.index];
return (
<div
key={virtualRow.key}
ref={virtualizer.measureElement}
data-index={virtualRow.index}
className="absolute left-0 top-0 w-full"
style={{ transform: `translateY(${virtualRow.start}px)` }}
>
<div className="pb-3">
<MessageRenderer
comment={message}
isTaskDescription={message.id === 'task-description'}
taskId={taskId ?? undefined}
permissionsByToolCallId={permissionsByToolCallId}
/>
</div>
</div>
);
})}
</div>
)}
</div>
<VirtualizedMessageList
messages={allMessages}
permissionsByToolCallId={permissionsByToolCallId}
taskId={taskId ?? undefined}
sessionId={resolvedSessionId}
messagesLoading={messagesLoading}
isWorking={isWorking}
/>
{/* Session info - shows agent state */}
{session?.state && (
@@ -371,9 +129,7 @@ export function TaskChatPanel({
)}
<form onSubmit={handleSubmit} className="mt-3 flex flex-col gap-2">
{todoItems.length > 0 && (
<TodoSummary todos={todoItems} />
)}
{todoItems.length > 0 && <TodoSummary todos={todoItems} />}
<TaskChatInput
value={messageInput}
onChange={setMessageInput}
@@ -385,88 +141,18 @@ export function TaskChatPanel({
}
planModeEnabled={planModeEnabled}
/>
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2">
<ModelSelector sessionId={resolvedSessionId} />
<DropdownMenu>
<Tooltip>
<TooltipTrigger asChild>
<DropdownMenuTrigger asChild>
<Button type="button" variant="outline" size="icon" className="h-7 w-7 cursor-pointer">
<IconBrain className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
</TooltipTrigger>
<TooltipContent>Thinking level</TooltipContent>
</Tooltip>
<DropdownMenuContent align="start" side="top">
<DropdownMenuItem>High</DropdownMenuItem>
<DropdownMenuItem>Medium</DropdownMenuItem>
<DropdownMenuItem>Low</DropdownMenuItem>
<DropdownMenuItem>Off</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center gap-2">
<Button
type="button"
variant="outline"
size="icon"
className={cn(
'h-7 w-7 cursor-pointer',
planModeEnabled &&
'bg-primary/15 text-primary border-primary/40 shadow-[0_0_0_1px_rgba(59,130,246,0.35)]'
)}
onClick={() => setPlanModeEnabled((value) => !value)}
>
<IconListCheck className="h-4 w-4" />
</Button>
{planModeEnabled && (
<span className="text-xs font-medium text-primary">Plan mode active</span>
)}
</div>
</TooltipTrigger>
<TooltipContent>Toggle plan mode</TooltipContent>
</Tooltip>
{/* Context window usage indicator */}
{contextWindow && <TokenUsageDisplay contextWindow={contextWindow} />}
</div>
<div className="flex items-center gap-2">
<SessionsDropdown
taskId={taskId}
activeSessionId={resolvedSessionId}
taskTitle={task?.title}
taskDescription={taskDescription ?? ''}
/>
<Tooltip>
<TooltipTrigger asChild>
<Button type="button" variant="outline" size="icon" className="h-7 w-7 cursor-pointer">
<IconPaperclip className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>Add attachments</TooltipContent>
</Tooltip>
<KeyboardShortcutTooltip shortcut={SHORTCUTS.SUBMIT} enabled={!isAgentBusy && !isStarting}>
<Button
type={isAgentBusy ? 'button' : 'submit'}
variant={isAgentBusy ? 'destructive' : 'default'}
className={cn('h-7', isAgentBusy && 'gap-2')}
disabled={isStarting || isSending}
onClick={isAgentBusy ? handleCancelTurn : undefined}
>
{isAgentBusy ? (
<>
<IconLoader2 className="h-3.5 w-3.5 animate-spin" />
Stop
</>
) : (
'Submit'
)}
</Button>
</KeyboardShortcutTooltip>
</div>
</div>
<ChatToolbar
sessionId={resolvedSessionId}
taskId={taskId}
taskTitle={task?.title}
taskDescription={taskDescription ?? ''}
planModeEnabled={planModeEnabled}
onPlanModeChange={setPlanModeEnabled}
isAgentBusy={isAgentBusy}
isStarting={isStarting}
isSending={isSending}
onCancel={handleCancelTurn}
/>
</form>
</>
);
+74
View File
@@ -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;
}
+45
View File
@@ -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 };
}
+115
View File
@@ -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<string>();
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<string, Message>();
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,
};
}
+33
View File
@@ -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,
};
}
+28
View File
@@ -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,
};
}
+58
View File
@@ -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',
},
});