feat: refactored chat input panels + pie context (#70)
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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',
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user