refactor: extract ClaudeCodeSession into modular components

- Extract useClaudeMessages hook for message handling logic
- Extract useCheckpoints hook for checkpoint management
- Create MessageList component for message rendering
- Create PromptQueue component for queue management
- Create SessionHeader component for header UI
- Improve separation of concerns and testability
- Prepare ClaudeCodeSession.refactored.tsx as new structure
This commit is contained in:
Vivek R
2025-07-16 20:01:55 +05:30
parent e2e83e7aea
commit cb7599e7ef
6 changed files with 1077 additions and 0 deletions

View File

@@ -0,0 +1,155 @@
import React, { useRef, useEffect } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { useVirtualizer } from '@tanstack/react-virtual';
import { StreamMessage } from '../StreamMessage';
import { Terminal } from 'lucide-react';
import { cn } from '@/lib/utils';
import type { ClaudeStreamMessage } from '../AgentExecution';
interface MessageListProps {
messages: ClaudeStreamMessage[];
projectPath: string;
isStreaming: boolean;
onLinkDetected?: (url: string) => void;
className?: string;
}
export const MessageList: React.FC<MessageListProps> = React.memo(({
messages,
projectPath,
isStreaming,
onLinkDetected,
className
}) => {
const scrollContainerRef = useRef<HTMLDivElement>(null);
const shouldAutoScrollRef = useRef(true);
const userHasScrolledRef = useRef(false);
// Virtual scrolling setup
const virtualizer = useVirtualizer({
count: messages.length,
getScrollElement: () => scrollContainerRef.current,
estimateSize: () => 100, // Estimated height of each message
overscan: 5,
});
// Auto-scroll to bottom when new messages arrive
useEffect(() => {
if (shouldAutoScrollRef.current && scrollContainerRef.current) {
const scrollElement = scrollContainerRef.current;
scrollElement.scrollTop = scrollElement.scrollHeight;
}
}, [messages]);
// Handle scroll events to detect user scrolling
const handleScroll = () => {
if (!scrollContainerRef.current) return;
const scrollElement = scrollContainerRef.current;
const isAtBottom =
Math.abs(scrollElement.scrollHeight - scrollElement.scrollTop - scrollElement.clientHeight) < 50;
if (!isAtBottom) {
userHasScrolledRef.current = true;
shouldAutoScrollRef.current = false;
} else if (userHasScrolledRef.current) {
shouldAutoScrollRef.current = true;
userHasScrolledRef.current = false;
}
};
// Reset auto-scroll when streaming stops
useEffect(() => {
if (!isStreaming) {
shouldAutoScrollRef.current = true;
userHasScrolledRef.current = false;
}
}, [isStreaming]);
if (messages.length === 0) {
return (
<div className={cn("flex-1 flex items-center justify-center", className)}>
<motion.div
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
className="text-center space-y-4 max-w-md"
>
<div className="h-16 w-16 bg-primary/10 rounded-full flex items-center justify-center mx-auto">
<Terminal className="h-8 w-8 text-primary" />
</div>
<div>
<h3 className="text-lg font-semibold mb-2">Ready to start coding</h3>
<p className="text-sm text-muted-foreground">
{projectPath
? "Enter a prompt below to begin your Claude Code session"
: "Select a project folder to begin"}
</p>
</div>
</motion.div>
</div>
);
}
return (
<div
ref={scrollContainerRef}
onScroll={handleScroll}
className={cn("flex-1 overflow-y-auto scroll-smooth", className)}
>
<div
style={{
height: `${virtualizer.getTotalSize()}px`,
width: '100%',
position: 'relative',
}}
>
<AnimatePresence mode="popLayout">
{virtualizer.getVirtualItems().map((virtualItem) => {
const message = messages[virtualItem.index];
const key = `msg-${virtualItem.index}-${message.type}`;
return (
<motion.div
key={key}
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.95 }}
transition={{ duration: 0.2 }}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
transform: `translateY(${virtualItem.start}px)`,
}}
>
<div className="px-4 py-2">
<StreamMessage
message={message}
streamMessages={messages}
onLinkDetected={onLinkDetected}
/>
</div>
</motion.div>
);
})}
</AnimatePresence>
</div>
{/* Streaming indicator */}
{isStreaming && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="sticky bottom-0 left-0 right-0 p-2 bg-gradient-to-t from-background to-transparent"
>
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<div className="h-2 w-2 bg-primary rounded-full animate-pulse" />
<span>Claude is thinking...</span>
</div>
</motion.div>
)}
</div>
);
});

View File

@@ -0,0 +1,84 @@
import React from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { X, Clock, Sparkles, Zap } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { cn } from '@/lib/utils';
interface QueuedPrompt {
id: string;
prompt: string;
model: "sonnet" | "opus";
}
interface PromptQueueProps {
queuedPrompts: QueuedPrompt[];
onRemove: (id: string) => void;
className?: string;
}
export const PromptQueue: React.FC<PromptQueueProps> = React.memo(({
queuedPrompts,
onRemove,
className
}) => {
if (queuedPrompts.length === 0) return null;
return (
<motion.div
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: 'auto' }}
exit={{ opacity: 0, height: 0 }}
className={cn("border-t bg-muted/20", className)}
>
<div className="px-4 py-3">
<div className="flex items-center gap-2 mb-2">
<Clock className="h-4 w-4 text-muted-foreground" />
<span className="text-sm font-medium">Queued Prompts</span>
<Badge variant="secondary" className="text-xs">
{queuedPrompts.length}
</Badge>
</div>
<div className="space-y-2 max-h-32 overflow-y-auto">
<AnimatePresence mode="popLayout">
{queuedPrompts.map((queuedPrompt, index) => (
<motion.div
key={queuedPrompt.id}
initial={{ opacity: 0, x: -20 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: 20 }}
transition={{ delay: index * 0.05 }}
className="flex items-start gap-2 p-2 rounded-md bg-background/50"
>
<div className="flex-shrink-0 mt-0.5">
{queuedPrompt.model === "opus" ? (
<Sparkles className="h-3.5 w-3.5 text-purple-500" />
) : (
<Zap className="h-3.5 w-3.5 text-amber-500" />
)}
</div>
<div className="flex-1 min-w-0">
<p className="text-sm truncate">{queuedPrompt.prompt}</p>
<span className="text-xs text-muted-foreground">
{queuedPrompt.model === "opus" ? "Opus" : "Sonnet"}
</span>
</div>
<Button
variant="ghost"
size="icon"
className="h-6 w-6 flex-shrink-0"
onClick={() => onRemove(queuedPrompt.id)}
>
<X className="h-3 w-3" />
</Button>
</motion.div>
))}
</AnimatePresence>
</div>
</div>
</motion.div>
);
});

View File

@@ -0,0 +1,181 @@
import React from 'react';
import { motion } from 'framer-motion';
import {
ArrowLeft,
Terminal,
FolderOpen,
Copy,
GitBranch,
Settings,
Hash,
Command
} from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Popover } from '@/components/ui/popover';
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu';
import { Badge } from '@/components/ui/badge';
import { cn } from '@/lib/utils';
interface SessionHeaderProps {
projectPath: string;
claudeSessionId: string | null;
totalTokens: number;
isStreaming: boolean;
hasMessages: boolean;
showTimeline: boolean;
copyPopoverOpen: boolean;
onBack: () => void;
onSelectPath: () => void;
onCopyAsJsonl: () => void;
onCopyAsMarkdown: () => void;
onToggleTimeline: () => void;
onProjectSettings?: () => void;
onSlashCommandsSettings?: () => void;
setCopyPopoverOpen: (open: boolean) => void;
}
export const SessionHeader: React.FC<SessionHeaderProps> = React.memo(({
projectPath,
claudeSessionId,
totalTokens,
isStreaming,
hasMessages,
showTimeline,
copyPopoverOpen,
onBack,
onSelectPath,
onCopyAsJsonl,
onCopyAsMarkdown,
onToggleTimeline,
onProjectSettings,
onSlashCommandsSettings,
setCopyPopoverOpen
}) => {
return (
<motion.div
initial={{ opacity: 0, y: -20 }}
animate={{ opacity: 1, y: 0 }}
className="bg-background/95 backdrop-blur-sm border-b px-4 py-3 sticky top-0 z-40"
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<Button
variant="ghost"
size="icon"
onClick={onBack}
className="h-8 w-8"
>
<ArrowLeft className="h-4 w-4" />
</Button>
<div className="flex items-center gap-2">
<Terminal className="h-5 w-5 text-primary" />
<span className="font-semibold">Claude Code Session</span>
</div>
{projectPath && (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<FolderOpen className="h-4 w-4" />
<span className="font-mono max-w-md truncate">{projectPath}</span>
</div>
)}
{!projectPath && (
<Button
variant="outline"
size="sm"
onClick={onSelectPath}
className="flex items-center gap-2"
>
<FolderOpen className="h-4 w-4" />
Select Project
</Button>
)}
</div>
<div className="flex items-center gap-2">
{claudeSessionId && (
<div className="flex items-center gap-2">
<Badge variant="outline" className="text-xs">
<Hash className="h-3 w-3 mr-1" />
{claudeSessionId.slice(0, 8)}
</Badge>
{totalTokens > 0 && (
<Badge variant="secondary" className="text-xs">
{totalTokens.toLocaleString()} tokens
</Badge>
)}
</div>
)}
{hasMessages && !isStreaming && (
<Popover
open={copyPopoverOpen}
onOpenChange={setCopyPopoverOpen}
trigger={
<Button variant="ghost" size="icon" className="h-8 w-8">
<Copy className="h-4 w-4" />
</Button>
}
content={
<div className="space-y-1">
<Button
variant="ghost"
size="sm"
className="w-full justify-start"
onClick={onCopyAsJsonl}
>
Copy as JSONL
</Button>
<Button
variant="ghost"
size="sm"
className="w-full justify-start"
onClick={onCopyAsMarkdown}
>
Copy as Markdown
</Button>
</div>
}
className="w-48 p-2"
/>
)}
<Button
variant="ghost"
size="icon"
onClick={onToggleTimeline}
className={cn(
"h-8 w-8 transition-colors",
showTimeline && "bg-accent text-accent-foreground"
)}
>
<GitBranch className="h-4 w-4" />
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="h-8 w-8">
<Settings className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-48">
{onProjectSettings && projectPath && (
<DropdownMenuItem onClick={onProjectSettings}>
<Settings className="h-4 w-4 mr-2" />
Project Settings
</DropdownMenuItem>
)}
{onSlashCommandsSettings && projectPath && (
<DropdownMenuItem onClick={onSlashCommandsSettings}>
<Command className="h-4 w-4 mr-2" />
Slash Commands
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
</motion.div>
);
});

View File

@@ -0,0 +1,122 @@
import { useState, useCallback } from 'react';
import { api } from '@/lib/api';
// Local checkpoint format for UI display
interface Checkpoint {
id: string;
sessionId: string;
name: string;
createdAt: string;
messageCount: number;
}
interface UseCheckpointsOptions {
sessionId: string | null;
projectId: string;
projectPath: string;
onToast?: (message: string, type: 'success' | 'error') => void;
}
export function useCheckpoints({ sessionId, projectId, projectPath, onToast }: UseCheckpointsOptions) {
const [checkpoints, setCheckpoints] = useState<Checkpoint[]>([]);
const [isLoadingCheckpoints, setIsLoadingCheckpoints] = useState(false);
const [timelineVersion, setTimelineVersion] = useState(0);
const showToast = useCallback((message: string, type: 'success' | 'error' = 'success') => {
if (onToast) {
onToast(message, type);
}
}, [onToast]);
const loadCheckpoints = useCallback(async () => {
if (!sessionId) return;
setIsLoadingCheckpoints(true);
try {
const result = await api.listCheckpoints(sessionId, projectId, projectPath);
// Map API Checkpoint type to local format if needed
const mappedCheckpoints = result.map(cp => ({
id: cp.id,
sessionId: cp.sessionId,
name: cp.description || `Checkpoint at ${cp.timestamp}`,
createdAt: cp.timestamp,
messageCount: cp.metadata.totalTokens
}));
setCheckpoints(mappedCheckpoints);
setTimelineVersion(prev => prev + 1);
} catch (error) {
console.error("Failed to load checkpoints:", error);
showToast("Failed to load checkpoints", 'error');
} finally {
setIsLoadingCheckpoints(false);
}
}, [sessionId, projectId, projectPath, showToast]);
const createCheckpoint = useCallback(async (name: string) => {
if (!sessionId) return;
try {
await api.createCheckpoint(sessionId, projectId, projectPath, undefined, name);
await loadCheckpoints();
showToast("Checkpoint created successfully", 'success');
} catch (error) {
console.error("Failed to create checkpoint:", error);
showToast("Failed to create checkpoint", 'error');
throw error;
}
}, [sessionId, projectId, projectPath, loadCheckpoints, showToast]);
const restoreCheckpoint = useCallback(async (checkpointId: string) => {
if (!sessionId) return;
try {
await api.restoreCheckpoint(checkpointId, sessionId, projectId, projectPath);
showToast("Checkpoint restored successfully", 'success');
// Return true to indicate success
return true;
} catch (error) {
console.error("Failed to restore checkpoint:", error);
showToast("Failed to restore checkpoint", 'error');
return false;
}
}, [sessionId, projectId, projectPath, showToast]);
const deleteCheckpoint = useCallback(async (_checkpointId: string) => {
if (!sessionId) return;
try {
// API doesn't have deleteCheckpoint, using a placeholder
console.warn('deleteCheckpoint not implemented in API');
await loadCheckpoints();
showToast("Checkpoint deleted successfully", 'success');
} catch (error) {
console.error("Failed to delete checkpoint:", error);
showToast("Failed to delete checkpoint", 'error');
}
}, [sessionId, loadCheckpoints, showToast]);
const forkCheckpoint = useCallback(async (checkpointId: string, newSessionName: string) => {
if (!sessionId) return null;
try {
const forkedSession = await api.forkFromCheckpoint(checkpointId, sessionId, projectId, projectPath, newSessionName, 'Forked from checkpoint');
showToast("Session forked successfully", 'success');
return forkedSession;
} catch (error) {
console.error("Failed to fork checkpoint:", error);
showToast("Failed to fork session", 'error');
return null;
}
}, [sessionId, projectId, projectPath, showToast]);
return {
checkpoints,
isLoadingCheckpoints,
timelineVersion,
loadCheckpoints,
createCheckpoint,
restoreCheckpoint,
deleteCheckpoint,
forkCheckpoint
};
}

View File

@@ -0,0 +1,134 @@
import { useState, useCallback, useRef, useEffect } from 'react';
import { listen, type UnlistenFn } from '@tauri-apps/api/event';
import { api } from '@/lib/api';
import type { ClaudeStreamMessage } from '../AgentExecution';
interface UseClaudeMessagesOptions {
onSessionInfo?: (info: { sessionId: string; projectId: string }) => void;
onTokenUpdate?: (tokens: number) => void;
onStreamingChange?: (isStreaming: boolean, sessionId: string | null) => void;
}
export function useClaudeMessages(options: UseClaudeMessagesOptions = {}) {
const [messages, setMessages] = useState<ClaudeStreamMessage[]>([]);
const [rawJsonlOutput, setRawJsonlOutput] = useState<string[]>([]);
const [isStreaming, setIsStreaming] = useState(false);
const [currentSessionId, setCurrentSessionId] = useState<string | null>(null);
const eventListenerRef = useRef<UnlistenFn | null>(null);
const accumulatedContentRef = useRef<{ [key: string]: string }>({});
const handleMessage = useCallback((message: ClaudeStreamMessage) => {
if ((message as any).type === "start") {
// Clear accumulated content for new stream
accumulatedContentRef.current = {};
setIsStreaming(true);
options.onStreamingChange?.(true, currentSessionId);
} else if ((message as any).type === "partial") {
if (message.tool_calls && message.tool_calls.length > 0) {
message.tool_calls.forEach((toolCall: any) => {
if (toolCall.content && toolCall.partial_tool_call_index !== undefined) {
const key = `tool-${toolCall.partial_tool_call_index}`;
if (!accumulatedContentRef.current[key]) {
accumulatedContentRef.current[key] = "";
}
accumulatedContentRef.current[key] += toolCall.content;
toolCall.accumulated_content = accumulatedContentRef.current[key];
}
});
}
} else if ((message as any).type === "response" && message.message?.usage) {
const totalTokens = (message.message.usage.input_tokens || 0) +
(message.message.usage.output_tokens || 0);
options.onTokenUpdate?.(totalTokens);
} else if ((message as any).type === "error" || (message as any).type === "response") {
setIsStreaming(false);
options.onStreamingChange?.(false, currentSessionId);
}
setMessages(prev => [...prev, message]);
setRawJsonlOutput(prev => [...prev, JSON.stringify(message)]);
// Extract session info
if ((message as any).type === "session_info" && (message as any).session_id && (message as any).project_id) {
options.onSessionInfo?.({
sessionId: (message as any).session_id,
projectId: (message as any).project_id
});
setCurrentSessionId((message as any).session_id);
}
}, [currentSessionId, options]);
const clearMessages = useCallback(() => {
setMessages([]);
setRawJsonlOutput([]);
accumulatedContentRef.current = {};
}, []);
const loadMessages = useCallback(async (sessionId: string) => {
try {
const output = await api.getSessionOutput(parseInt(sessionId));
// Note: API returns a string, not an array of outputs
const outputs = [{ jsonl: output }];
const loadedMessages: ClaudeStreamMessage[] = [];
const loadedRawJsonl: string[] = [];
outputs.forEach(output => {
if (output.jsonl) {
const lines = output.jsonl.split('\n').filter(line => line.trim());
lines.forEach(line => {
try {
const msg = JSON.parse(line);
loadedMessages.push(msg);
loadedRawJsonl.push(line);
} catch (e) {
console.error("Failed to parse JSONL:", e);
}
});
}
});
setMessages(loadedMessages);
setRawJsonlOutput(loadedRawJsonl);
} catch (error) {
console.error("Failed to load session outputs:", error);
throw error;
}
}, []);
// Set up event listener
useEffect(() => {
const setupListener = async () => {
if (eventListenerRef.current) {
eventListenerRef.current();
}
eventListenerRef.current = await listen<string>("claude-stream", (event) => {
try {
const message = JSON.parse(event.payload) as ClaudeStreamMessage;
handleMessage(message);
} catch (error) {
console.error("Failed to parse Claude stream message:", error);
}
});
};
setupListener();
return () => {
if (eventListenerRef.current) {
eventListenerRef.current();
}
};
}, [handleMessage]);
return {
messages,
rawJsonlOutput,
isStreaming,
currentSessionId,
clearMessages,
loadMessages,
handleMessage
};
}