Files
claudia/src/components/ClaudeCodeSession.tsx

745 lines
26 KiB
TypeScript

import React, { useState, useEffect, useRef, useMemo } from "react";
import { motion, AnimatePresence } from "framer-motion";
import {
ArrowLeft,
Terminal,
Loader2,
FolderOpen,
Copy,
ChevronDown,
GitBranch,
Settings
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Popover } from "@/components/ui/popover";
import { api, type Session } from "@/lib/api";
import { cn } from "@/lib/utils";
import { open } from "@tauri-apps/plugin-dialog";
import { listen, type UnlistenFn } from "@tauri-apps/api/event";
import { StreamMessage } from "./StreamMessage";
import { FloatingPromptInput } from "./FloatingPromptInput";
import { ErrorBoundary } from "./ErrorBoundary";
import { TokenCounter } from "./TokenCounter";
import { TimelineNavigator } from "./TimelineNavigator";
import { CheckpointSettings } from "./CheckpointSettings";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from "@/components/ui/dialog";
import type { ClaudeStreamMessage } from "./AgentExecution";
import { enhanceMessages, type EnhancedMessage } from "@/types/enhanced-messages";
interface ClaudeCodeSessionProps {
/**
* Optional session to resume (when clicking from SessionList)
*/
session?: Session;
/**
* Initial project path (for new sessions)
*/
initialProjectPath?: string;
/**
* Callback to go back
*/
onBack: () => void;
/**
* Optional className for styling
*/
className?: string;
}
/**
* ClaudeCodeSession component for interactive Claude Code sessions
*
* @example
* <ClaudeCodeSession onBack={() => setView('projects')} />
*/
export const ClaudeCodeSession: React.FC<ClaudeCodeSessionProps> = ({
session,
initialProjectPath = "",
onBack,
className,
}) => {
const [projectPath, setProjectPath] = useState(initialProjectPath || session?.project_path || "");
const [messages, setMessages] = useState<ClaudeStreamMessage[]>([]);
const [enhancedMessages, setEnhancedMessages] = useState<EnhancedMessage[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [rawJsonlOutput, setRawJsonlOutput] = useState<string[]>([]);
const [copyPopoverOpen, setCopyPopoverOpen] = useState(false);
const [isFirstPrompt, setIsFirstPrompt] = useState(!session);
const [currentModel, setCurrentModel] = useState<"sonnet" | "opus">("sonnet");
const [totalTokens, setTotalTokens] = useState(0);
const [extractedSessionInfo, setExtractedSessionInfo] = useState<{
sessionId: string;
projectId: string;
} | null>(null);
const [showTimeline, setShowTimeline] = useState(false);
const [timelineVersion, setTimelineVersion] = useState(0);
const [showSettings, setShowSettings] = useState(false);
const [showForkDialog, setShowForkDialog] = useState(false);
const [forkCheckpointId, setForkCheckpointId] = useState<string | null>(null);
const [forkSessionName, setForkSessionName] = useState("");
const messagesEndRef = useRef<HTMLDivElement>(null);
const unlistenRefs = useRef<UnlistenFn[]>([]);
const hasActiveSessionRef = useRef(false);
// Get effective session info (from prop or extracted) - use useMemo to ensure it updates
const effectiveSession = useMemo(() => {
if (session) return session;
if (extractedSessionInfo) {
return {
id: extractedSessionInfo.sessionId,
project_id: extractedSessionInfo.projectId,
project_path: projectPath,
created_at: Date.now(),
} as Session;
}
return null;
}, [session, extractedSessionInfo, projectPath]);
// Debug logging
useEffect(() => {
console.log('[ClaudeCodeSession] State update:', {
projectPath,
session,
extractedSessionInfo,
effectiveSession,
messagesCount: messages.length,
isLoading
});
}, [projectPath, session, extractedSessionInfo, effectiveSession, messages.length, isLoading]);
// Load session history if resuming
useEffect(() => {
if (session) {
loadSessionHistory();
}
}, [session]);
// Enhance messages whenever they change
useEffect(() => {
const enhanced = enhanceMessages(messages);
setEnhancedMessages(enhanced);
}, [messages]);
// Auto-scroll to bottom when new messages arrive
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
}, [enhancedMessages]);
// Calculate total tokens from messages
useEffect(() => {
const tokens = messages.reduce((total, msg) => {
if (msg.message?.usage) {
return total + msg.message.usage.input_tokens + msg.message.usage.output_tokens;
}
if (msg.usage) {
return total + msg.usage.input_tokens + msg.usage.output_tokens;
}
return total;
}, 0);
setTotalTokens(tokens);
}, [messages]);
const loadSessionHistory = async () => {
if (!session) return;
try {
setIsLoading(true);
setError(null);
const history = await api.loadSessionHistory(session.id, session.project_id);
// Convert history to messages format
const loadedMessages: ClaudeStreamMessage[] = history.map(entry => ({
...entry,
type: entry.type || "assistant"
}));
setMessages(loadedMessages);
setRawJsonlOutput(history.map(h => JSON.stringify(h)));
// After loading history, we're continuing a conversation
setIsFirstPrompt(false);
} catch (err) {
console.error("Failed to load session history:", err);
setError("Failed to load session history");
} finally {
setIsLoading(false);
}
};
const handleSelectPath = async () => {
try {
const selected = await open({
directory: true,
multiple: false,
title: "Select Project Directory"
});
if (selected) {
setProjectPath(selected as string);
setError(null);
}
} catch (err) {
console.error("Failed to select directory:", err);
const errorMessage = err instanceof Error ? err.message : String(err);
setError(`Failed to select directory: ${errorMessage}`);
}
};
const handleSendPrompt = async (prompt: string, model: "sonnet" | "opus") => {
if (!projectPath || !prompt.trim() || isLoading) return;
try {
setIsLoading(true);
setError(null);
setCurrentModel(model);
hasActiveSessionRef.current = true;
// Add the user message immediately to the UI
const userMessage: ClaudeStreamMessage = {
type: "user",
message: {
content: [
{
type: "text",
text: prompt
}
]
}
};
setMessages(prev => [...prev, userMessage]);
// Clean up any existing listeners before creating new ones
unlistenRefs.current.forEach(unlisten => unlisten());
unlistenRefs.current = [];
// Set up event listeners
const outputUnlisten = await listen<string>("claude-output", async (event) => {
try {
console.log('[ClaudeCodeSession] Received claude-output:', event.payload);
// Store raw JSONL
setRawJsonlOutput(prev => [...prev, event.payload]);
// Parse and display
const message = JSON.parse(event.payload) as ClaudeStreamMessage;
console.log('[ClaudeCodeSession] Parsed message:', message);
setMessages(prev => {
console.log('[ClaudeCodeSession] Adding message to state. Previous count:', prev.length);
return [...prev, message];
});
// Extract session info from system init message
if (message.type === "system" && message.subtype === "init" && message.session_id && !extractedSessionInfo) {
console.log('[ClaudeCodeSession] Extracting session info from init message');
// Extract project ID from the project path
const projectId = projectPath.replace(/[^a-zA-Z0-9]/g, '-');
setExtractedSessionInfo({
sessionId: message.session_id,
projectId: projectId
});
}
} catch (err) {
console.error("Failed to parse message:", err, event.payload);
}
});
const errorUnlisten = await listen<string>("claude-error", (event) => {
console.error("Claude error:", event.payload);
setError(event.payload);
});
const completeUnlisten = await listen<boolean>("claude-complete", async (event) => {
console.log('[ClaudeCodeSession] Received claude-complete:', event.payload);
setIsLoading(false);
hasActiveSessionRef.current = false;
if (!event.payload) {
setError("Claude execution failed");
}
// Track all messages at once after completion (batch operation)
if (effectiveSession && rawJsonlOutput.length > 0) {
console.log('[ClaudeCodeSession] Tracking all messages in batch:', rawJsonlOutput.length);
api.trackSessionMessages(
effectiveSession.id,
effectiveSession.project_id,
projectPath,
rawJsonlOutput
).catch(err => {
console.error("Failed to track session messages:", err);
});
}
// Check if we should auto-checkpoint
if (effectiveSession && messages.length > 0) {
try {
const lastMessage = messages[messages.length - 1];
const shouldCheckpoint = await api.checkAutoCheckpoint(
effectiveSession.id,
effectiveSession.project_id,
projectPath,
JSON.stringify(lastMessage)
);
if (shouldCheckpoint) {
await api.createCheckpoint(
effectiveSession.id,
effectiveSession.project_id,
projectPath,
messages.length - 1,
"Auto-checkpoint after tool use"
);
console.log("Auto-checkpoint created");
// Trigger timeline reload if it's currently visible
setTimelineVersion((v) => v + 1);
}
} catch (err) {
console.error("Failed to check/create auto-checkpoint:", err);
}
}
// Clean up listeners after completion
unlistenRefs.current.forEach(unlisten => unlisten());
unlistenRefs.current = [];
});
unlistenRefs.current = [outputUnlisten, errorUnlisten, completeUnlisten];
// Execute the appropriate command
if (isFirstPrompt && !session) {
// New session
await api.executeClaudeCode(projectPath, prompt, model);
setIsFirstPrompt(false);
} else if (session && isFirstPrompt) {
// Resuming a session
await api.resumeClaudeCode(projectPath, session.id, prompt, model);
setIsFirstPrompt(false);
} else {
// Continuing conversation
await api.continueClaudeCode(projectPath, prompt, model);
}
} catch (err) {
console.error("Failed to send prompt:", err);
setError("Failed to execute Claude Code");
setIsLoading(false);
hasActiveSessionRef.current = false;
}
};
const handleCopyAsJsonl = async () => {
const jsonl = rawJsonlOutput.join('\n');
await navigator.clipboard.writeText(jsonl);
setCopyPopoverOpen(false);
};
const handleCopyAsMarkdown = async () => {
let markdown = `# Claude Code Session\n\n`;
markdown += `**Project:** ${projectPath}\n`;
markdown += `**Date:** ${new Date().toISOString()}\n\n`;
markdown += `---\n\n`;
for (const msg of messages) {
if (msg.type === "system" && msg.subtype === "init") {
markdown += `## System Initialization\n\n`;
markdown += `- Session ID: \`${msg.session_id || 'N/A'}\`\n`;
markdown += `- Model: \`${msg.model || 'default'}\`\n`;
if (msg.cwd) markdown += `- Working Directory: \`${msg.cwd}\`\n`;
if (msg.tools?.length) markdown += `- Tools: ${msg.tools.join(', ')}\n`;
markdown += `\n`;
} else if (msg.type === "assistant" && msg.message) {
markdown += `## Assistant\n\n`;
for (const content of msg.message.content || []) {
if (content.type === "text") {
const textContent = typeof content.text === 'string'
? content.text
: (content.text?.text || JSON.stringify(content.text || content));
markdown += `${textContent}\n\n`;
} else if (content.type === "tool_use") {
markdown += `### Tool: ${content.name}\n\n`;
markdown += `\`\`\`json\n${JSON.stringify(content.input, null, 2)}\n\`\`\`\n\n`;
}
}
if (msg.message.usage) {
markdown += `*Tokens: ${msg.message.usage.input_tokens} in, ${msg.message.usage.output_tokens} out*\n\n`;
}
} else if (msg.type === "user" && msg.message) {
markdown += `## User\n\n`;
for (const content of msg.message.content || []) {
if (content.type === "text") {
const textContent = typeof content.text === 'string'
? content.text
: (content.text?.text || JSON.stringify(content.text));
markdown += `${textContent}\n\n`;
} else if (content.type === "tool_result") {
markdown += `### Tool Result\n\n`;
let contentText = '';
if (typeof content.content === 'string') {
contentText = content.content;
} else if (content.content && typeof content.content === 'object') {
if (content.content.text) {
contentText = content.content.text;
} else if (Array.isArray(content.content)) {
contentText = content.content
.map((c: any) => (typeof c === 'string' ? c : c.text || JSON.stringify(c)))
.join('\n');
} else {
contentText = JSON.stringify(content.content, null, 2);
}
}
markdown += `\`\`\`\n${contentText}\n\`\`\`\n\n`;
}
}
} else if (msg.type === "result") {
markdown += `## Execution Result\n\n`;
if (msg.result) {
markdown += `${msg.result}\n\n`;
}
if (msg.error) {
markdown += `**Error:** ${msg.error}\n\n`;
}
}
}
await navigator.clipboard.writeText(markdown);
setCopyPopoverOpen(false);
};
const handleCheckpointSelect = async () => {
// Reload messages from the checkpoint
await loadSessionHistory();
// Ensure timeline reloads to highlight current checkpoint
setTimelineVersion((v) => v + 1);
};
const handleFork = (checkpointId: string) => {
setForkCheckpointId(checkpointId);
setForkSessionName(`Fork-${new Date().toISOString().slice(0, 10)}`);
setShowForkDialog(true);
};
const handleConfirmFork = async () => {
if (!forkCheckpointId || !forkSessionName.trim() || !effectiveSession) return;
try {
setIsLoading(true);
setError(null);
const newSessionId = `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
await api.forkFromCheckpoint(
forkCheckpointId,
effectiveSession.id,
effectiveSession.project_id,
projectPath,
newSessionId,
forkSessionName
);
// Open the new forked session
// You would need to implement navigation to the new session
console.log("Forked to new session:", newSessionId);
setShowForkDialog(false);
setForkCheckpointId(null);
setForkSessionName("");
} catch (err) {
console.error("Failed to fork checkpoint:", err);
setError("Failed to fork checkpoint");
} finally {
setIsLoading(false);
}
};
// Clean up listeners on component unmount
useEffect(() => {
return () => {
unlistenRefs.current.forEach(unlisten => unlisten());
// Clear checkpoint manager when session ends
if (effectiveSession) {
api.clearCheckpointManager(effectiveSession.id).catch(err => {
console.error("Failed to clear checkpoint manager:", err);
});
}
};
}, []);
return (
<div className={cn("flex flex-col h-full bg-background", className)}>
<div className="w-full max-w-5xl mx-auto h-full flex flex-col">
{/* Header */}
<motion.div
initial={{ opacity: 0, y: -20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3 }}
className="flex items-center justify-between p-4 border-b border-border"
>
<div className="flex items-center space-x-3">
<Button
variant="ghost"
size="icon"
onClick={onBack}
className="h-8 w-8"
disabled={isLoading}
>
<ArrowLeft className="h-4 w-4" />
</Button>
<div className="flex items-center gap-2">
<Terminal className="h-5 w-5" />
<div>
<h2 className="text-lg font-semibold">Claude Code Session</h2>
<p className="text-xs text-muted-foreground">
{session ? `Resuming session ${session.id.slice(0, 8)}...` : 'Interactive session'}
</p>
</div>
</div>
</div>
<div className="flex items-center gap-2">
{effectiveSession && (
<>
<Button
variant="outline"
size="sm"
onClick={() => setShowSettings(!showSettings)}
className="flex items-center gap-2"
>
<Settings className="h-4 w-4" />
Settings
</Button>
<Button
variant="outline"
size="sm"
onClick={() => setShowTimeline(!showTimeline)}
className="flex items-center gap-2"
>
<GitBranch className="h-4 w-4" />
Timeline
</Button>
</>
)}
{enhancedMessages.length > 0 && (
<Popover
trigger={
<Button
variant="ghost"
size="sm"
className="flex items-center gap-2"
>
<Copy className="h-4 w-4" />
Copy Output
<ChevronDown className="h-3 w-3" />
</Button>
}
content={
<div className="w-44 p-1">
<Button
variant="ghost"
size="sm"
className="w-full justify-start"
onClick={handleCopyAsJsonl}
>
Copy as JSONL
</Button>
<Button
variant="ghost"
size="sm"
className="w-full justify-start"
onClick={handleCopyAsMarkdown}
>
Copy as Markdown
</Button>
</div>
}
open={copyPopoverOpen}
onOpenChange={setCopyPopoverOpen}
align="end"
/>
)}
</div>
</motion.div>
{/* Timeline Navigator */}
{showTimeline && effectiveSession && (
<div className="border-b border-border">
<div className="p-4">
<TimelineNavigator
sessionId={effectiveSession.id}
projectId={effectiveSession.project_id}
projectPath={projectPath}
currentMessageIndex={messages.length - 1}
onCheckpointSelect={handleCheckpointSelect}
refreshVersion={timelineVersion}
onFork={handleFork}
/>
</div>
</div>
)}
{/* Project Path Selection (only for new sessions) */}
{!session && (
<div className="p-4 border-b border-border space-y-4">
{/* Error display */}
{error && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
className="rounded-lg border border-destructive/50 bg-destructive/10 p-3 text-xs text-destructive"
>
{error}
</motion.div>
)}
{/* Project Path */}
<div className="space-y-2">
<Label>Project Path</Label>
<div className="flex gap-2">
<Input
value={projectPath}
onChange={(e) => setProjectPath(e.target.value)}
placeholder="Select or enter project path"
disabled={hasActiveSessionRef.current}
className="flex-1"
/>
<Button
variant="outline"
size="icon"
onClick={handleSelectPath}
disabled={hasActiveSessionRef.current}
>
<FolderOpen className="h-4 w-4" />
</Button>
</div>
</div>
</div>
)}
{/* Messages Display */}
<div className="flex-1 overflow-y-auto p-4 space-y-2 pb-40">
{enhancedMessages.length === 0 && !isLoading && (
<div className="flex flex-col items-center justify-center h-full text-center">
<Terminal className="h-16 w-16 text-muted-foreground mb-4" />
<h3 className="text-lg font-medium mb-2">Ready to Start</h3>
<p className="text-sm text-muted-foreground">
{session
? "Send a message to continue this conversation"
: "Select a project path and send your first prompt"
}
</p>
</div>
)}
{isLoading && enhancedMessages.length === 0 && (
<div className="flex items-center justify-center h-full">
<div className="flex items-center gap-3">
<Loader2 className="h-6 w-6 animate-spin" />
<span className="text-sm text-muted-foreground">
{session ? "Loading session history..." : "Initializing Claude Code..."}
</span>
</div>
</div>
)}
<AnimatePresence>
{enhancedMessages.map((message, index) => (
<motion.div
key={index}
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.2 }}
>
<ErrorBoundary>
<StreamMessage message={message} streamMessages={enhancedMessages} />
</ErrorBoundary>
</motion.div>
))}
</AnimatePresence>
{/* Show loading indicator when processing, even if there are messages */}
{isLoading && enhancedMessages.length > 0 && (
<div className="flex items-center gap-2 p-4">
<Loader2 className="h-4 w-4 animate-spin" />
<span className="text-sm text-muted-foreground">Processing...</span>
</div>
)}
<div ref={messagesEndRef} />
</div>
</div>
{/* Floating Prompt Input */}
<FloatingPromptInput
onSend={handleSendPrompt}
isLoading={isLoading}
disabled={!projectPath && !session}
defaultModel={currentModel}
projectPath={projectPath}
/>
{/* Token Counter */}
<TokenCounter tokens={totalTokens} />
{/* Fork Dialog */}
<Dialog open={showForkDialog} onOpenChange={setShowForkDialog}>
<DialogContent>
<DialogHeader>
<DialogTitle>Fork Session</DialogTitle>
<DialogDescription>
Create a new session branch from the selected checkpoint.
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<Label htmlFor="fork-name">New Session Name</Label>
<Input
id="fork-name"
placeholder="e.g., Alternative approach"
value={forkSessionName}
onChange={(e) => setForkSessionName(e.target.value)}
onKeyPress={(e) => {
if (e.key === "Enter" && !isLoading) {
handleConfirmFork();
}
}}
/>
</div>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => setShowForkDialog(false)}
disabled={isLoading}
>
Cancel
</Button>
<Button
onClick={handleConfirmFork}
disabled={isLoading || !forkSessionName.trim()}
>
Create Fork
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Settings Dialog */}
{showSettings && effectiveSession && (
<Dialog open={showSettings} onOpenChange={setShowSettings}>
<DialogContent className="max-w-2xl">
<CheckpointSettings
sessionId={effectiveSession.id}
projectId={effectiveSession.project_id}
projectPath={projectPath}
onClose={() => setShowSettings(false)}
/>
</DialogContent>
</Dialog>
)}
</div>
);
};