feat: tanstack virtual

This commit is contained in:
Carlos Florêncio
2026-01-14 21:12:33 +00:00
parent e965524590
commit 79bdee07db
5 changed files with 85 additions and 68 deletions
-11
View File
@@ -6,10 +6,6 @@ import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from '@kandev/ui
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@kandev/ui/tabs';
import { TooltipProvider } from '@kandev/ui/tooltip';
import { Textarea } from '@kandev/ui/textarea';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@kandev/ui/dialog';
import { Button } from '@kandev/ui/button';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@kandev/ui/select';
import { Label } from '@kandev/ui/label';
import { IconX } from '@tabler/icons-react';
import { getLocalStorage, setLocalStorage } from '@/lib/local-storage';
import { TaskChatPanel } from '@/components/task/task-chat-panel';
@@ -26,13 +22,6 @@ import { useAppStore, useAppStoreApi } from '@/components/state-provider';
import { useRepositories } from '@/hooks/use-repositories';
import { useRepositoryBranches } from '@/hooks/use-repository-branches';
type AgentProfile = {
id: string;
name: string;
agent_id: string;
agent_name: string;
};
const AGENTS = [
{ id: 'codex', label: 'Codex' },
{ id: 'claude', label: 'Claude Code' },
+1
View File
@@ -244,6 +244,7 @@ export function KanbanBoard() {
});
}
}, [
boardsState.activeId,
boardsState.items,
commitSettings,
setActiveBoard,
+83 -56
View File
@@ -1,6 +1,7 @@
'use client';
import { FormEvent, useEffect, useRef, useState } from 'react';
import { useVirtualizer } from '@tanstack/react-virtual';
import {
IconBrain,
IconCheck,
@@ -209,7 +210,7 @@ export function TaskChatPanel({ agents, onSend, isLoading, isAgentWorking, taskD
const [planModeEnabled, setPlanModeEnabled] = useState(false);
const [isSending, setIsSending] = useState(false);
const [isAwaitingResponse, setIsAwaitingResponse] = useState(false);
const messagesEndRef = useRef<HTMLDivElement>(null);
const messagesContainerRef = useRef<HTMLDivElement>(null);
const lastAgentMessageCountRef = useRef(0);
const commentsState = useAppStore((state) => state.comments);
@@ -250,10 +251,20 @@ export function TaskChatPanel({ agents, onSend, isLoading, isAgentWorking, taskD
// Show typing indicator when awaiting response AND agent session is active
const showTypingIndicator = isAwaitingResponse && isAgentWorking;
const itemCount = allMessages.length + (showTypingIndicator ? 1 : 0);
const virtualizer = useVirtualizer({
count: itemCount,
getScrollElement: () => messagesContainerRef.current,
estimateSize: () => 96,
overscan: 6,
});
// Scroll to bottom when new comments arrive or when typing indicator appears
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [visibleComments.length, showTypingIndicator]);
if (itemCount === 0) return;
virtualizer.scrollToIndex(itemCount - 1, { align: 'end', behavior: 'smooth' });
}, [itemCount, virtualizer]);
const handleSubmit = async (event: FormEvent) => {
event.preventDefault();
@@ -280,9 +291,57 @@ export function TaskChatPanel({ agents, onSend, isLoading, isAgentWorking, taskD
return comment.content || '(empty)';
};
const renderMessage = (comment: Comment) => {
if (comment.type === 'tool_call') {
return <ToolCallCard comment={comment} />;
}
const isUser = comment.author_type === 'user';
const isTaskDescription = comment.id === 'task-description';
let label: string;
let containerClass: string;
if (isTaskDescription) {
label = 'Task';
containerClass = 'ml-auto bg-primary/20 text-foreground border border-primary/40';
} else if (isUser) {
label = 'You';
containerClass = 'ml-auto bg-primary text-primary-foreground';
} else {
label = 'Agent';
containerClass = 'bg-muted text-foreground';
}
return (
<div className={cn('max-w-[85%] rounded-lg px-4 py-3 text-sm', containerClass)}>
<p
className={cn(
'text-[11px] uppercase tracking-wide mb-2',
isTaskDescription ? 'text-primary font-medium' : 'opacity-70'
)}
>
{label}
</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>
);
};
return (
<>
<div className="flex-1 min-h-0 overflow-y-auto rounded-lg bg-background p-3 space-y-3">
<div
ref={messagesContainerRef}
className="flex-1 min-h-0 overflow-y-auto rounded-lg bg-background p-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" />
@@ -294,59 +353,27 @@ export function TaskChatPanel({ agents, onSend, isLoading, isAgentWorking, taskD
<span>No messages yet. Start the conversation!</span>
</div>
)}
{allMessages.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';
const isTaskDescription = comment.id === 'task-description';
// Determine label and styling
let label: string;
let containerClass: string;
if (isTaskDescription) {
label = 'Task';
containerClass = 'ml-auto bg-primary/20 text-foreground border border-primary/40';
} else if (isUser) {
label = 'You';
containerClass = 'ml-auto bg-primary text-primary-foreground';
} else {
label = 'Agent';
containerClass = 'bg-muted text-foreground';
}
return (
<div
key={comment.id}
className={cn(
'max-w-[85%] rounded-lg px-4 py-3 text-sm',
containerClass
)}
>
<p className={cn(
"text-[11px] uppercase tracking-wide mb-2",
isTaskDescription ? "text-primary font-medium" : "opacity-70"
)}>
{label}
</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>
{!isLoading && !commentsLoading && itemCount > 0 && (
<div className="relative w-full" style={{ height: `${virtualizer.getTotalSize()}px` }}>
{virtualizer.getVirtualItems().map((virtualRow) => {
const isTypingRow = showTypingIndicator && virtualRow.index === allMessages.length;
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">
{isTypingRow ? <TypingIndicator /> : renderMessage(message)}
</div>
</div>
)}
</div>
);
})}
{showTypingIndicator && <TypingIndicator />}
<div ref={messagesEndRef} />
);
})}
</div>
)}
</div>
<form onSubmit={handleSubmit} className="mt-3 flex flex-col gap-2">
<Textarea
-1
View File
@@ -8,7 +8,6 @@ import type {
ListExecutorsResponse,
ListRepositoriesResponse,
ListWorkspacesResponse,
Comment,
ListCommentsResponse,
RepositoryBranchesResponse,
Task,
+1
View File
@@ -32,6 +32,7 @@
"@kandev/types": "workspace:*",
"@kandev/ui": "workspace:*",
"@tabler/icons-react": "^3.36.1",
"@tanstack/react-virtual": "^3.13.18",
"@uiw/codemirror-theme-vscode": "^4.25.4",
"@uiw/react-codemirror": "^4.25.4",
"class-variance-authority": "^0.7.1",