Files
kandev/apps/web/components/task/task-chat-panel.tsx
T
2026-01-12 20:56:03 +00:00

393 lines
15 KiB
TypeScript

'use client';
import { FormEvent, useEffect, useRef, useState } from 'react';
import {
IconBrain,
IconCheck,
IconChevronDown,
IconChevronRight,
IconCode,
IconEdit,
IconEye,
IconFile,
IconListCheck,
IconLoader2,
IconPaperclip,
IconSearch,
IconTerminal2,
IconX,
} from '@tabler/icons-react';
import ReactMarkdown from 'react-markdown';
import remarkBreaks from 'remark-breaks';
import remarkGfm from 'remark-gfm';
import { Button } from '@kandev/ui/button';
import { Textarea } from '@kandev/ui/textarea';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@kandev/ui/dropdown-menu';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@kandev/ui/select';
import { Tooltip, TooltipContent, TooltipTrigger } from '@kandev/ui/tooltip';
import { cn } from '@/lib/utils';
import { useAppStore } from '@/components/state-provider';
import type { Comment } from '@/lib/types/http';
type AgentOption = {
id: string;
label: string;
};
type TaskChatPanelProps = {
taskId: string;
agents: AgentOption[];
onSend: (message: string) => void;
isLoading?: boolean;
isAgentWorking?: boolean;
};
type ToolCallMetadata = {
tool_call_id?: string;
tool_name?: string;
title?: string;
status?: 'pending' | 'running' | 'complete' | 'error';
args?: Record<string, unknown>;
result?: string;
};
function getToolIcon(toolName?: string) {
const name = toolName?.toLowerCase() ?? '';
// Match ACP ToolKind values: read, edit, delete, move, search, execute
if (name === 'edit' || name.includes('edit') || name.includes('replace') || name.includes('write') || name.includes('save')) {
return IconEdit;
}
if (name === 'read' || name.includes('view') || name.includes('read')) {
return IconEye;
}
if (name === 'search' || name.includes('search') || name.includes('find') || name.includes('retrieval')) {
return IconSearch;
}
if (name === 'execute' || name.includes('terminal') || name.includes('exec') || name.includes('launch') || name.includes('process')) {
return IconTerminal2;
}
if (name === 'delete' || name === 'move' || name.includes('file') || name.includes('create')) {
return IconFile;
}
return IconCode;
}
function getStatusIcon(status?: string) {
switch (status) {
case 'complete':
return <IconCheck className="h-3.5 w-3.5 text-green-500" />;
case 'error':
return <IconX className="h-3.5 w-3.5 text-red-500" />;
case 'running':
return <IconLoader2 className="h-3.5 w-3.5 text-blue-500 animate-spin" />;
default:
return null;
}
}
function ToolCallCard({ comment }: { comment: Comment }) {
const [isExpanded, setIsExpanded] = useState(false);
const metadata = comment.metadata as ToolCallMetadata | undefined;
const toolName = metadata?.tool_name ?? '';
const title = metadata?.title ?? comment.content ?? 'Tool call';
const status = metadata?.status;
const args = metadata?.args;
const result = metadata?.result;
const ToolIcon = getToolIcon(toolName);
const hasDetails = args && Object.keys(args).length > 0;
// Extract file path from various possible sources
let filePath: string | undefined;
const rawPath = args?.path ?? args?.file ?? args?.file_path;
if (typeof rawPath === 'string') {
filePath = rawPath;
}
// Also try to get path from locations array if available
if (!filePath && Array.isArray(args?.locations) && args.locations.length > 0) {
const firstLoc = args.locations[0] as { path?: string } | undefined;
if (firstLoc?.path) {
filePath = firstLoc.path;
}
}
return (
<div className="rounded-md border border-border/40 bg-muted/20 overflow-hidden">
<button
type="button"
onClick={() => hasDetails && setIsExpanded(!isExpanded)}
className={cn(
'w-full flex items-center gap-2 px-3 py-2 text-sm text-left',
hasDetails && 'cursor-pointer hover:bg-muted/40 transition-colors'
)}
disabled={!hasDetails}
>
<ToolIcon className="h-4 w-4 text-amber-600 dark:text-amber-400 flex-shrink-0" />
<span className="flex-1 font-mono text-xs text-muted-foreground truncate">
{title}
</span>
{filePath && (
<span className="text-xs text-muted-foreground/60 truncate max-w-[200px]">
{filePath}
</span>
)}
{getStatusIcon(status)}
{hasDetails && (
isExpanded
? <IconChevronDown className="h-4 w-4 text-muted-foreground/50" />
: <IconChevronRight className="h-4 w-4 text-muted-foreground/50" />
)}
</button>
{isExpanded && hasDetails && (
<div className="border-t border-border/30 bg-background/50 p-3 space-y-2">
{args && Object.entries(args).map(([key, value]) => {
const strValue = typeof value === 'string' ? value : JSON.stringify(value, null, 2);
const isLongValue = strValue.length > 100 || strValue.includes('\n');
return (
<div key={key} className="text-xs">
<span className="font-medium text-muted-foreground">{key}:</span>
{isLongValue ? (
<pre className="mt-1 p-2 bg-muted/50 rounded text-[11px] overflow-x-auto max-h-[200px] overflow-y-auto whitespace-pre-wrap break-all">
{strValue}
</pre>
) : (
<span className="ml-2 font-mono text-foreground/80">{strValue}</span>
)}
</div>
);
})}
{result && (
<div className="text-xs border-t border-border/30 pt-2 mt-2">
<span className="font-medium text-muted-foreground">Result:</span>
<pre className="mt-1 p-2 bg-muted/50 rounded text-[11px] overflow-x-auto max-h-[150px] overflow-y-auto whitespace-pre-wrap">
{result}
</pre>
</div>
)}
</div>
)}
</div>
);
}
function TypingIndicator() {
return (
<div className="flex items-center gap-2 px-4 py-3 max-w-[85%] rounded-lg bg-muted text-muted-foreground">
<div className="flex items-center gap-1" role="status" aria-label="Agent is typing">
<span className="text-[11px] uppercase tracking-wide opacity-70 mr-2">Agent</span>
<span
className="w-2 h-2 rounded-full bg-current animate-bounce"
style={{ animationDelay: '0ms', animationDuration: '1s' }}
/>
<span
className="w-2 h-2 rounded-full bg-current animate-bounce"
style={{ animationDelay: '150ms', animationDuration: '1s' }}
/>
<span
className="w-2 h-2 rounded-full bg-current animate-bounce"
style={{ animationDelay: '300ms', animationDuration: '1s' }}
/>
</div>
</div>
);
}
export function TaskChatPanel({ taskId, agents, onSend, isLoading, isAgentWorking }: TaskChatPanelProps) {
const [messageInput, setMessageInput] = useState('');
const [selectedAgent, setSelectedAgent] = useState(agents[0]?.id ?? '');
const [planModeEnabled, setPlanModeEnabled] = useState(false);
const [isSending, setIsSending] = useState(false);
const [isAwaitingResponse, setIsAwaitingResponse] = useState(false);
const messagesEndRef = useRef<HTMLDivElement>(null);
const lastAgentMessageCountRef = useRef(0);
const commentsState = useAppStore((state) => state.comments);
const comments = commentsState?.items ?? [];
const commentsLoading = commentsState?.isLoading ?? false;
// Filter to only show message, content, and tool_call types (not progress, etc)
const visibleComments = comments.filter(
(c) => c.type === 'message' || c.type === 'content' || c.type === 'tool_call' || !c.type
);
// Count agent messages to detect new responses
const agentMessageCount = visibleComments.filter((c) => c.author_type !== 'user').length;
// Clear awaiting state when a new agent message arrives
useEffect(() => {
if (agentMessageCount > lastAgentMessageCountRef.current) {
setIsAwaitingResponse(false);
}
lastAgentMessageCountRef.current = agentMessageCount;
}, [agentMessageCount]);
// Show typing indicator when awaiting response AND agent session is active
const showTypingIndicator = isAwaitingResponse && isAgentWorking;
// Scroll to bottom when new comments arrive or when typing indicator appears
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [visibleComments.length, showTypingIndicator]);
const handleSubmit = async (event: FormEvent) => {
event.preventDefault();
const trimmed = messageInput.trim();
if (!trimmed || isSending) return;
setIsSending(true);
setIsAwaitingResponse(true);
setMessageInput('');
try {
await onSend(trimmed);
} finally {
setIsSending(false);
}
};
const formatContent = (comment: Comment) => {
// For content type comments, the content might be streamed chunks
// For message type, show as-is
return comment.content || '(empty)';
};
return (
<>
<div className="flex-1 min-h-0 overflow-y-auto rounded-lg bg-background p-3 space-y-3">
{(isLoading || commentsLoading) && (
<div className="flex items-center justify-center py-8 text-muted-foreground">
<IconLoader2 className="h-5 w-5 animate-spin mr-2" />
<span>Loading comments...</span>
</div>
)}
{!isLoading && !commentsLoading && visibleComments.length === 0 && (
<div className="flex items-center justify-center py-8 text-muted-foreground">
<span>No messages yet. Start the conversation!</span>
</div>
)}
{visibleComments.map((comment) => {
// Tool call comments have a special rendering
if (comment.type === 'tool_call') {
return <ToolCallCard key={comment.id} comment={comment} />;
}
// Regular message or agent response - render as markdown
const isUser = comment.author_type === 'user';
return (
<div
key={comment.id}
className={cn(
'max-w-[85%] rounded-lg px-4 py-3 text-sm',
isUser
? 'ml-auto bg-primary text-primary-foreground'
: 'bg-muted text-foreground'
)}
>
<p className="text-[11px] uppercase tracking-wide mb-2 opacity-70">
{isUser ? 'You' : 'Agent'}
</p>
{isUser ? (
<p className="whitespace-pre-wrap">{formatContent(comment)}</p>
) : (
<div className="prose prose-sm dark:prose-invert max-w-none prose-p:my-2 prose-p:leading-relaxed prose-ul:my-2 prose-ol:my-2 prose-li:my-0.5 prose-pre:my-3 prose-code:px-1.5 prose-code:py-0.5 prose-code:bg-background/50 prose-code:rounded prose-code:text-xs prose-code:before:content-none prose-code:after:content-none prose-pre:bg-background/80 prose-pre:text-xs prose-strong:text-foreground prose-headings:text-foreground">
<ReactMarkdown remarkPlugins={[remarkGfm, remarkBreaks]}>
{formatContent(comment)}
</ReactMarkdown>
</div>
)}
</div>
);
})}
{showTypingIndicator && <TypingIndicator />}
<div ref={messagesEndRef} />
</div>
<form onSubmit={handleSubmit} className="mt-3 flex flex-col gap-2">
<Textarea
value={messageInput}
onChange={(event) => setMessageInput(event.target.value)}
placeholder="Write to submit work to the agent..."
className={cn(
'min-h-[90px] resize-none',
planModeEnabled &&
'border-dashed border-primary/60 !bg-primary/20 dark:!bg-primary/20 shadow-[inset_0_0_0_1px_rgba(59,130,246,0.35)]'
)}
/>
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2">
<Select value={selectedAgent} onValueChange={setSelectedAgent}>
<SelectTrigger className="w-[160px] cursor-pointer">
<SelectValue placeholder="Select agent" />
</SelectTrigger>
<SelectContent>
{agents.map((agent) => (
<SelectItem key={agent.id} value={agent.id}>
{agent.label}
</SelectItem>
))}
</SelectContent>
</Select>
<DropdownMenu>
<Tooltip>
<TooltipTrigger asChild>
<DropdownMenuTrigger asChild>
<Button type="button" variant="outline" size="icon" className="h-9 w-9 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-9 w-9 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>
</div>
<div className="flex items-center gap-2">
<Tooltip>
<TooltipTrigger asChild>
<Button type="button" variant="outline" size="icon" className="h-9 w-9 cursor-pointer">
<IconPaperclip className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>Add attachments</TooltipContent>
</Tooltip>
<Button type="submit">Submit</Button>
</div>
</div>
</form>
</>
);
}