25 KiB
Task Chat Interface - Final Implementation Plan
Comprehensive plan to build a high-performance, feature-rich task chat interface with SSR, lazy loading, and command support.
Status: Updated after rebase from main (Jan 2026)
Key Changes:
- ✅ SSR already implemented - fetches last 10 comments on server
- ✅ TanStack Virtual v3.13.18 already integrated
- ✅ Basic tool call rendering already exists
- ✅ Pagination API implemented (limit/before/after/sort + has_more/cursor)
- ✅ Smart auto-scroll added (only scrolls when at bottom)
- ✅ Lazy loading added (scroll-to-top fetch for older messages)
- ✅ CodeMirror input implemented (markdown, line wrapping, shortcut adapter, Mod-Enter submit)
- ✅ Slash + mention autocomplete added (dummy command examples)
- ✅ Message rendering refactor with adapter-style components
- ✅ Inline diff rendering (metadata diff uses @git-diff-view/react when structured)
- ⚠️ Rich content rendering partially implemented (thinking/todos/diff metadata cards, status/error/progress)
- ❌ Rich content (thinking, todos, diffs) not fully implemented
Architecture Overview
Data Flow
1. Initial Load (SSR):
Server Component → HTTP GET /api/tasks/:id/comments?limit=10&sort=desc
→ Hydrate store with last 10 messages
→ Client renders, scrolled to bottom
2. WebSocket Subscription:
Client mounts → ws.subscribe(taskId)
→ Receives comment.added notifications
→ Appends new messages to store
→ Auto-scroll if at bottom
3. Lazy Loading (Scroll Up):
User scrolls to top → Detect via TanStack Virtual
→ If not all messages loaded: HTTP GET /api/tasks/:id/comments?before=:oldestId&limit=20
→ Prepend to store
→ Maintain scroll position
Tech Stack
Core:
- React 18 + Next.js 14 (SSR)
- TanStack Virtual v3 (virtualization)
- Zustand + Immer (state)
- WebSocket (real-time)
UI:
- Tailwind CSS + shadcn/ui
- CodeMirror 6 (input + file editing)
- react-markdown + remark-gfm (display)
- @git-diff-view/react (existing)
New Dependencies:
{
"@codemirror/view": "^6.34.1",
"@codemirror/state": "^6.4.1",
"@codemirror/lang-javascript": "^6.2.2",
"@codemirror/lang-markdown": "^6.3.1",
"@codemirror/autocomplete": "^6.18.1",
"@codemirror/commands": "^6.7.0",
"@codemirror/language": "^6.10.3",
"@codemirror/theme-one-dark": "^6.1.2",
"codemirror": "^6.0.1"
}
Total: ~150KB gzipped (unified solution for chat + file editing)
Already Installed:
@tanstack/react-virtual: v3.13.18 ✅react-markdown+remark-gfm✅@git-diff-view/react✅
Current Implementation Status
✅ Already Implemented (After Rebase)
-
SSR (Server-Side Rendering)
app/task/[id]/page.tsxfetches comments vialistTaskComments()- State hydration via
StateHydrator - Fetches last 10 comments on initial load (sorted asc for display)
-
TanStack Virtual
- Version: 3.13.18
- Location:
task-chat-panel.tsx(line 256) - Current config:
estimateSize: 96,overscan: 6 - Auto-scroll to bottom on new messages
-
HTTP API Endpoint
- Backend:
GET /api/v1/tasks/:id/comments(limit/before/after/sort) - Frontend:
listTaskComments(taskId, params)inlib/http/index.ts - Response:
{ comments, total, has_more, cursor }
- Backend:
-
Basic Tool Call Rendering
- Expandable tool call cards
- Tool icons and status indicators
- Args/result display
❌ Still Missing (Needs Implementation)
-
Rich Content Display
- Basic thinking/todos/diff blocks rendered when metadata exists
- Error/status/progress cards added
- Inline diff viewer now supported for structured metadata
- Remaining: tool call enhancements, error card details, thinking toggle, diff extraction from tool results
-
CodeMirror Input
- Still using basic Textarea
- No markdown mode or syntax highlighting
- No command/mention autocomplete
-
Rich Content Display
- No thinking message display
- No todo list visualization
- No inline diff viewer
- Basic error display only
Implementation Phases
Phase 1: Pagination API + Lazy Loading (Week 1)
1.1 Backend API Enhancements
Update Existing Endpoint:
// File: apps/backend/internal/task/dto/requests.go
type ListCommentsRequest struct {
TaskID string
Limit int // default 10, max 100
Before string // comment ID for pagination (older)
After string // comment ID for newer messages
Sort string // "asc" | "desc", default "desc"
}
// File: apps/backend/internal/task/dto/dto.go
type ListCommentsResponse struct {
Comments []*v1.Comment `json:"comments"`
Total int `json:"total"`
HasMore bool `json:"has_more"` // NEW
Cursor string `json:"cursor"` // NEW
}
Implementation Steps:
- Update
ListCommentsRequestDTO to accept query params - Modify
CommentController.ListComments()to pass pagination params - Update
Service.ListComments()to support cursor-based queries - Modify SQLite repository query with
WHERE created_at < ?andLIMIT - Calculate
has_moreby checking if count > limit - Return cursor as the last comment's ID
Handler Update:
// File: apps/backend/internal/task/handlers/comment_handlers.go
func (h *CommentHandlers) httpListComments(c *gin.Context) {
taskID := c.Param("id")
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "10"))
before := c.Query("before")
sort := c.DefaultQuery("sort", "desc")
resp, err := h.commentController.ListComments(c.Request.Context(), dto.ListCommentsRequest{
TaskID: taskID,
Limit: limit,
Before: before,
Sort: sort,
})
// ... return resp with has_more and cursor
}
Index Check:
-- Should already exist from schema
CREATE INDEX IF NOT EXISTS idx_comments_task_created
ON task_comments(task_id, created_at DESC);
1.2 Update SSR to Limit to 10
Server Component: app/task/[id]/page.tsx
// Update to only fetch last 10 messages
comments = await listTaskComments(id, { limit: 10 }, { cache: 'no-store' });
Frontend HTTP Client: lib/http/index.ts
export async function listTaskComments(
taskId: string,
params?: { limit?: number; before?: string; sort?: string },
options?: ApiRequestOptions
) {
const query = new URLSearchParams();
if (params?.limit) query.set('limit', params.limit.toString());
if (params?.before) query.set('before', params.before);
if (params?.sort) query.set('sort', params.sort);
const url = `/api/v1/tasks/${taskId}/comments${query.toString() ? `?${query}` : ''}`;
return fetchJson<ListCommentsResponse>(url, options);
}
Types Update: lib/types/http.ts
export interface ListCommentsResponse {
comments: Comment[];
total: number;
has_more: boolean; // NEW
cursor: string; // NEW
}
1.3 Store Enhancements
Zustand Store:
type CommentsState = {
taskId: string | null;
items: Comment[]; // Sorted by created_at ASC
isLoading: boolean;
hasMore: boolean;
oldestCursor: string | null; // For lazy loading
// Actions
setComments(taskId: string, comments: Comment[]);
prependComments(comments: Comment[]); // For lazy load
addComment(comment: Comment); // For new messages
setCommentsMetadata(meta: { hasMore?, isLoading? });
};
1.4 Lazy Loading Hook
useLazyLoadComments.ts:
export function useLazyLoadComments(taskId: string) {
const { hasMore, oldestCursor, isLoading } = useStore(s => s.comments);
const prependComments = useStore(s => s.prependComments);
const loadMore = useCallback(async () => {
if (!hasMore || isLoading) return;
setCommentsMetadata({ isLoading: true });
const response = await fetch(
`/api/tasks/${taskId}/comments?limit=20&before=${oldestCursor}`
);
const { comments, has_more, cursor } = await response.json();
prependComments(comments);
setCommentsMetadata({
hasMore: has_more,
oldestCursor: cursor,
isLoading: false
});
}, [taskId, hasMore, oldestCursor, isLoading]);
return { loadMore, hasMore, isLoading };
}
Phase 2: Smart Auto-Scroll + Lazy Load Detection (Week 1)
2.1 Enhance Existing Virtual List
Update: task-chat-panel.tsx (already has TanStack Virtual at line 256)
Add Lazy Load Detection:
// In TaskChatPanel component, after virtualizer setup (line 261)
const { loadMore, hasMore, isLoading } = useLazyLoadComments(taskId);
// Detect scroll to top for lazy loading
useEffect(() => {
const [firstItem] = virtualizer.getVirtualItems();
if (!firstItem) return;
// If first visible item is index 0 and we have more
if (firstItem.index === 0 && hasMore && !isLoading) {
loadMore();
}
}, [virtualizer.getVirtualItems(), hasMore, isLoading]);
// Show loading indicator at top when loading more
const showLoadingIndicator = isLoading && hasMore;
2.2 Smart Auto-Scroll
Replace Existing Auto-Scroll Logic (currently at line 263-267 in task-chat-panel.tsx):
// Add ref to track if user was at bottom
const wasAtBottomRef = useRef(true);
// Check if at bottom on scroll
const checkAtBottom = useCallback(() => {
const element = messagesContainerRef.current;
if (!element) return;
const { scrollTop, scrollHeight, clientHeight } = element;
wasAtBottomRef.current = scrollHeight - scrollTop - clientHeight < 50;
}, []);
// Add scroll listener
useEffect(() => {
const element = messagesContainerRef.current;
if (!element) return;
element.addEventListener('scroll', checkAtBottom);
return () => element.removeEventListener('scroll', checkAtBottom);
}, [checkAtBottom]);
// REPLACE existing auto-scroll effect (line 264-267)
// OLD: Always scrolls to bottom
// NEW: Only scroll if user was at bottom
useEffect(() => {
if (itemCount === 0) return;
if (wasAtBottomRef.current) {
virtualizer.scrollToIndex(itemCount - 1, {
align: 'end',
behavior: 'smooth',
});
}
}, [itemCount, virtualizer]);
2.3 Maintain Scroll Position on Prepend
Strategy:
// Before prepending new messages
const scrollElement = parentRef.current;
const oldScrollHeight = scrollElement.scrollHeight;
const oldScrollTop = scrollElement.scrollTop;
// Prepend messages to store
prependComments(newComments);
// After render, restore scroll position
requestAnimationFrame(() => {
const newScrollHeight = scrollElement.scrollHeight;
scrollElement.scrollTop = oldScrollTop + (newScrollHeight - oldScrollHeight);
});
Phase 3: Component Architecture (Week 1-2)
3.1 Component Hierarchy
TaskChatPanel (refactored)
├── TaskChatHeader
│ ├── Agent selector
│ ├── Settings (thinking, diff view)
│ └── Loading indicator
├── TaskChatMessages (TanStack Virtual)
│ ├── Lazy load indicator (top)
│ └── MessageRenderer (type router)
│ ├── UserMessage
│ ├── AssistantMessage + ToolCallList
│ ├── ThinkingMessage
│ ├── ToolCallCard (recursive for sub-agents)
│ ├── TodoListCard
│ ├── DiffCard
│ ├── ErrorCard
│ └── SystemMessage
└── TaskChatInput (CodeMirror with commands)
3.2 Message Components
Key Components:
- MessageRenderer - Type router based on
comment.type - UserMessage - Avatar, timestamp, markdown content
- AssistantMessage - Avatar, duration, tokens, copy button, markdown + tool calls
- ToolCallCard - Collapsible, status badge, nested sub-agents, specialized renderers
- TodoListCard - Progress bar, status icons, animated updates
- DiffCard - File stats, expandable, uses @git-diff-view/react
- ErrorCard - Error icon, message, stack trace
- ThinkingMessage - Italic, bordered, toggle visibility
- SystemMessage - Session info, warnings, status updates
Shared Components:
MessageHeader- Avatar, name, timestamp, metadataCopyButton- Copy message contentExpandButton- Expand/collapse long contentStatusBadge- Status indicators with animations
3.3 State Management
Expansion State (Zustand):
type UIState = {
expandedTools: Set<string>;
expandedMessages: Set<string>;
showThinking: boolean;
diffViewMode: 'unified' | 'split';
toggleToolExpanded(id: string): void;
toggleMessageExpanded(id: string): void;
};
Persistence:
- Store expansion state in localStorage via Zustand persist
- Restore on mount
- Clear on task change
Phase 4: CodeMirror Input with Commands (Week 2)
4.1 CodeMirror Setup
Chat Input Configuration:
import { EditorView, basicSetup } from '@codemirror/basic-setup';
import { markdown } from '@codemirror/lang-markdown';
import { autocompletion } from '@codemirror/autocomplete';
import { keymap } from '@codemirror/view';
const chatExtensions = [
basicSetup({ lineNumbers: false }), // Minimal for chat
markdown(),
autocompletion({ override: [commandCompletions, mentionCompletions] }),
keymap.of([{ key: "Mod-Enter", run: handleSubmit }]),
EditorView.theme({
"&": { maxHeight: "300px", minHeight: "60px" },
".cm-content": { padding: "12px" },
".cm-scroller": { overflow: "auto" }
})
];
Features:
- Markdown mode with syntax awareness
- Slash commands (/read, /edit, /bash, etc.)
- File mentions (@filename.ts)
- Syntax highlighting for inline code
- Cmd+Enter to submit
- Auto-grow up to 300px height
4.2 Command Autocomplete
Command Completion Source:
import { CompletionContext } from '@codemirror/autocomplete';
const COMMANDS = [
{ label: '/read', detail: 'Read a file', info: 'Usage: /read <file_path>' },
{ label: '/edit', detail: 'Edit a file', info: 'Usage: /edit <file_path>' },
{ label: '/bash', detail: 'Run bash command', info: 'Usage: /bash <command>' },
{ label: '/grep', detail: 'Search in files', info: 'Usage: /grep <pattern>' },
{ label: '/help', detail: 'Show commands', info: 'Display all available commands' },
];
function commandCompletions(context: CompletionContext) {
const word = context.matchBefore(/\/\w*/);
if (!word || (word.from === word.to && !context.explicit)) return null;
return {
from: word.from,
options: COMMANDS.map(cmd => ({
label: cmd.label,
detail: cmd.detail,
info: cmd.info,
apply: cmd.label + ' ', // Add space after command
}))
};
}
File Mention Completion:
function mentionCompletions(context: CompletionContext) {
const word = context.matchBefore(/@[\w\/\-\.]*/);
if (!word) return null;
// Get available files from git status or file tree
const files = getChangedFiles(); // From store or context
return {
from: word.from,
options: files.map(file => ({
label: `@${file.path}`,
detail: file.type,
info: `Reference ${file.path}`,
apply: `@${file.path}`,
}))
};
}
Keyboard Navigation:
- Built-in: Arrow up/down, Enter, Escape
- Tab to accept completion
- Fuzzy matching included
4.3 Submit Handler
Extract Content and Send:
function handleSubmit(view: EditorView): boolean {
const content = view.state.doc.toString();
if (!content.trim()) return false;
// Parse commands (optional - backend can also parse)
const commands = extractCommands(content);
// Send to backend
client.request('comment.add', {
task_id: taskId,
content,
metadata: commands.length > 0 ? { commands } : undefined
});
// Clear editor
view.dispatch({
changes: { from: 0, to: view.state.doc.length, insert: '' }
});
return true;
}
4.4 File Editing Integration
Unified Editor for Multiple Contexts:
1. Chat Input (Minimal):
const chatExtensions = [
basicSetup({ lineNumbers: false }),
markdown(),
autocompletion({ override: [commandCompletions, mentionCompletions] }),
];
2. File Viewer (Read-only):
const viewerExtensions = [
basicSetup(),
EditorView.editable.of(false),
EditorState.readOnly.of(true),
languageForFile(filename),
syntaxHighlighting(oneDarkHighlightStyle),
];
3. File Editor (Full-featured):
const editorExtensions = [
basicSetup(),
languageForFile(filename),
syntaxHighlighting(oneDarkHighlightStyle),
lintGutter(),
autocompletion(),
EditorView.updateListener.of((update) => {
if (update.docChanged) {
debouncedSave(update.state.doc.toString());
}
}),
];
Language Detection:
function languageForFile(filename: string) {
const ext = filename.split('.').pop();
switch (ext) {
case 'js':
case 'jsx':
case 'ts':
case 'tsx':
return javascript({ jsx: true, typescript: ext.includes('ts') });
case 'py':
return python();
case 'go':
return go();
case 'md':
return markdown();
// ... 100+ languages supported
default:
return [];
}
}
Shared Configuration:
// Reusable theme and settings
export const baseTheme = EditorView.theme({
"&": { height: "100%" },
".cm-scroller": { fontFamily: "var(--font-mono)" },
".cm-gutters": { backgroundColor: "var(--muted)" },
});
export const baseExtensions = [
baseTheme,
EditorState.tabSize.of(2),
EditorState.allowMultipleSelections.of(true),
];
Phase 5: Rich Content Rendering (Week 2-3)
5.1 Tool Call Enhancements
Features:
- Recursive rendering of sub-agent calls (Task tool)
- Tool-specific summaries (Read: filename, Bash: command preview)
- Status badges with animations (pending, running, success, error)
- Collapsible params and results
- Copy button for results
- Specialized renderers (Bash output with ANSI colors, JSON pretty-print)
Metadata Structure:
type ToolCallMetadata = {
tool: {
id: string;
name: string;
args: Record<string, any>;
result: string | null;
status: 'pending' | 'running' | 'success' | 'error';
duration_ms?: number;
sub_agent_calls?: ToolCallMetadata[];
};
};
5.2 Todo List Visualization
Features:
- Progress bar (completed / total)
- Status icons (Circle → Loader → CheckCircle)
- Active form vs content based on status
- Animated transitions
- Group by status (optional)
Backend Integration:
- Agent sends
progresstype comments - Metadata contains todos array
- Updates sent as new comments (immutable)
- Frontend shows latest state
5.3 Diff Integration
Features:
- Extract diffs from tool call results (Edit, Write tools)
- Show file stats (additions, deletions)
- Expandable diff viewer using @git-diff-view/react
- Syntax highlighting per language
- "Open in Changes" button → navigate to Changes tab
Diff Sources:
- Tool result with
difffield - Tool result with
old_contentandnew_content - Unified diff string in result
5.4 Thinking Display
Features:
- Toggle via settings
- Italic text with left border
- Markdown rendering
- Collapsed by default for long thoughts
- Visual distinction from regular messages
Backend:
- Agent sends
thinkingtype comments - Or
metadata.thinkingin content comments
5.5 Error Handling
Features:
- Error cards with red border
- Error icon and title
- Error message
- Collapsible stack trace
- Retry button (if applicable)
- Copy error details
Phase 6: Performance & Polish (Week 3-4)
6.1 Performance Optimizations
Memoization:
// Memoize message components
const MessageItem = memo(MessageRenderer, (prev, next) => {
return prev.comment.id === next.comment.id &&
prev.comment.updated_at === next.comment.updated_at;
});
// Memoize filtered comments
const visibleComments = useMemo(() => {
return comments.filter(c => {
if (c.type === 'thinking' && !showThinking) return false;
return true;
});
}, [comments, showThinking]);
Debouncing:
- Scroll position checks: 100ms
- Lazy load triggers: 200ms
- Auto-scroll: 50ms
- Input changes: 300ms (for autocomplete)
Code Splitting:
// Lazy load heavy components
const MonacoDiffEditor = lazy(() => import('./MonacoDiffEditor'));
const MermaidRenderer = lazy(() => import('./MermaidRenderer'));
Bundle Size:
- Target: ~150KB increase (CodeMirror core + essential languages)
- Use tree-shaking
- Import only needed language modes
- Lazy load additional languages on demand
6.2 UX Polish
Interactions:
- Smooth height transitions (CSS transitions)
- Hover states on interactive elements
- Loading skeletons for lazy load
- Copy feedback (checkmark animation)
- Keyboard shortcuts (j/k navigation, / for command palette)
Visual:
- Consistent spacing and alignment
- Color-coded message types
- Status indicators with meaningful colors
- Avatar fallbacks with initials
- Timestamp formatting (relative times)
- Duration formatting (ms, s, m)
Accessibility:
- Keyboard navigation
- Focus management in autocomplete
6.3 Error Boundaries
Wrap Critical Components:
<ErrorBoundary
fallback={<ErrorFallback />}
onError={(error) => console.error('Message render error:', error)}
>
<MessageRenderer comment={comment} />
</ErrorBoundary>
Graceful Degradation:
- If markdown fails, show plain text
- If diff parsing fails, show unified diff string
- If tool metadata is invalid, show raw JSON
Backend Changes
API Enhancements
New Endpoints:
GET /api/tasks/:id/comments?limit=10&before=:cursor&sort=desc
POST /api/tasks/:id/comments (existing, no changes)
Response Format:
{
"comments": [
{
"id": "cmt_123",
"task_id": "tsk_456",
"author_type": "agent",
"content": "...",
"type": "content",
"metadata": {...},
"created_at": "2025-01-14T12:00:00Z"
}
],
"has_more": true,
"cursor": "cmt_100"
}
Indexes:
CREATE INDEX IF NOT EXISTS idx_comments_task_created
ON task_comments(task_id, created_at DESC);
WebSocket (No Changes)
Existing comment.added notification works as-is.
Comment Type Usage
Agent Integration:
message- User messagescontent- Agent text responsestool_call- Tool executionsprogress- Todo list updatesthinking- Agent reasoningerror- Error messagesstatus- System notifications
File Structure
apps/web/
├── app/task/[id]/
│ ├── page.tsx (SSR: fetch initial comments)
│ └── page-client.tsx (Client: hydrate + subscribe)
├── components/task/
│ ├── task-chat-panel.tsx (Main container, refactored)
│ └── chat/
│ ├── TaskChatHeader.tsx
│ ├── TaskChatMessages.tsx (TanStack Virtual)
│ ├── TaskChatInput.tsx (Lexical + commands)
│ ├── messages/
│ │ ├── MessageRenderer.tsx
│ │ ├── UserMessage.tsx
│ │ ├── AssistantMessage.tsx
│ │ ├── ThinkingMessage.tsx
│ │ ├── SystemMessage.tsx
│ │ └── ErrorCard.tsx
│ ├── tools/
│ │ ├── ToolCallCard.tsx
│ │ ├── ToolStatusBadge.tsx
│ │ └── ToolIcon.tsx
│ ├── content/
│ │ ├── TodoListCard.tsx
│ │ ├── TodoItem.tsx
│ │ ├── DiffCard.tsx
│ │ └── CopyButton.tsx
│ ├── input/
│ │ ├── CodeMirrorEditor.tsx
│ │ ├── commandCompletions.ts
│ │ ├── mentionCompletions.ts
│ │ └── editorExtensions.ts
│ └── hooks/
│ ├── useLazyLoadComments.ts
│ ├── useSmartAutoScroll.ts
│ └── useChatVirtualizer.ts
└── lib/
├── state/store.ts (Enhanced with pagination)
└── api/comments.ts (New: fetch comments API)
apps/backend/
└── internal/task/
├── handlers/
│ └── comment_handlers.go (Add pagination support)
└── repository/
└── sqlite.go (Cursor-based queries)
Total new files: ~25 Modified files: 4 (page.tsx, page-client.tsx, task-chat-panel.tsx, store.ts, comment_handlers.go, sqlite.go)
Implementation Timeline
Week 1: Pagination & Smart Scroll
SSR setup(Already done)TanStack Virtual(Already done v3.13.18)Basic tool calls(Already done)- Backend pagination API (limit, before, cursor)
- Update SSR to limit to 10 messages
- Lazy loading on scroll up
- Smart auto-scroll (only if at bottom)
- Scroll position preservation on prepend
Week 2: Rich Content
- Enhanced message components (copy, timestamps)
- Nested tool call rendering (sub-agents)
- Todo list visualization with progress bars
- Inline diff viewer integration
- Thinking message display
- Error card enhancements
Week 3: CodeMirror Input
- Replace Textarea with CodeMirror
- Markdown mode with syntax highlighting
- Slash command autocomplete (/read, /edit, /bash)
- File mention autocomplete (@file.ts)
- Keyboard navigation (arrows, enter, escape)
- Command parsing & execution
- File viewer/editor config (bonus)
Week 4: Polish & Testing
- Performance optimization (memoization audit)
- Error boundaries for message rendering
- Accessibility (keyboard nav)
- Documentation updates