From cb7599e7ef27be6654916a264b7ed1ff0072a5fa Mon Sep 17 00:00:00 2001 From: Vivek R <123vivekr@gmail.com> Date: Wed, 16 Jul 2025 20:01:55 +0530 Subject: [PATCH] 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 --- .../ClaudeCodeSession.refactored.tsx | 401 ++++++++++++++++++ .../claude-code-session/MessageList.tsx | 155 +++++++ .../claude-code-session/PromptQueue.tsx | 84 ++++ .../claude-code-session/SessionHeader.tsx | 181 ++++++++ .../claude-code-session/useCheckpoints.ts | 122 ++++++ .../claude-code-session/useClaudeMessages.ts | 134 ++++++ 6 files changed, 1077 insertions(+) create mode 100644 src/components/ClaudeCodeSession.refactored.tsx create mode 100644 src/components/claude-code-session/MessageList.tsx create mode 100644 src/components/claude-code-session/PromptQueue.tsx create mode 100644 src/components/claude-code-session/SessionHeader.tsx create mode 100644 src/components/claude-code-session/useCheckpoints.ts create mode 100644 src/components/claude-code-session/useClaudeMessages.ts diff --git a/src/components/ClaudeCodeSession.refactored.tsx b/src/components/ClaudeCodeSession.refactored.tsx new file mode 100644 index 0000000..e139ea6 --- /dev/null +++ b/src/components/ClaudeCodeSession.refactored.tsx @@ -0,0 +1,401 @@ +import React, { useState, useEffect, useRef, useCallback } from "react"; +import { motion } from "framer-motion"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { api, type Session } from "@/lib/api"; +import { cn } from "@/lib/utils"; +import { open } from "@tauri-apps/plugin-dialog"; +import { FloatingPromptInput, type FloatingPromptInputRef } from "./FloatingPromptInput"; +import { ErrorBoundary } from "./ErrorBoundary"; +import { TimelineNavigator } from "./TimelineNavigator"; +import { CheckpointSettings } from "./CheckpointSettings"; +import { SlashCommandsManager } from "./SlashCommandsManager"; +import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from "@/components/ui/dialog"; +import { SplitPane } from "@/components/ui/split-pane"; +import { WebviewPreview } from "./WebviewPreview"; + +// Import refactored components and hooks +import { useClaudeMessages } from "./claude-code-session/useClaudeMessages"; +import { useCheckpoints } from "./claude-code-session/useCheckpoints"; +import { SessionHeader } from "./claude-code-session/SessionHeader"; +import { MessageList } from "./claude-code-session/MessageList"; +import { PromptQueue } from "./claude-code-session/PromptQueue"; + +interface ClaudeCodeSessionProps { + session?: Session; + initialProjectPath?: string; + onBack: () => void; + onProjectSettings?: (projectPath: string) => void; + className?: string; + onStreamingChange?: (isStreaming: boolean, sessionId: string | null) => void; +} + +export const ClaudeCodeSession: React.FC = ({ + session, + initialProjectPath = "", + onBack, + onProjectSettings, + className, + onStreamingChange, +}) => { + const [projectPath, setProjectPath] = useState(initialProjectPath || session?.project_path || ""); + const [error, setError] = useState(null); + const [copyPopoverOpen, setCopyPopoverOpen] = useState(false); + const [isFirstPrompt, setIsFirstPrompt] = useState(!session); + const [totalTokens, setTotalTokens] = useState(0); + const [claudeSessionId, setClaudeSessionId] = useState(null); + const [showTimeline, setShowTimeline] = useState(false); + const [showSettings, setShowSettings] = useState(false); + const [showForkDialog, setShowForkDialog] = useState(false); + const [showSlashCommandsSettings, setShowSlashCommandsSettings] = useState(false); + const [forkCheckpointId, setForkCheckpointId] = useState(null); + const [forkSessionName, setForkSessionName] = useState(""); + const [queuedPrompts, setQueuedPrompts] = useState>([]); + const [showPreview, setShowPreview] = useState(false); + const [previewUrl, setPreviewUrl] = useState(null); + const [isPreviewMaximized, setIsPreviewMaximized] = useState(false); + const promptInputRef = useRef(null); + const processQueueTimeoutRef = useRef(null); + + // Use custom hooks + const { + messages, + rawJsonlOutput, + isStreaming, + currentSessionId: _currentSessionId, + clearMessages, + loadMessages + } = useClaudeMessages({ + onSessionInfo: (info) => { + setClaudeSessionId(info.sessionId); + }, + onTokenUpdate: setTotalTokens, + onStreamingChange + }); + + const { + checkpoints: _checkpoints, + timelineVersion, + loadCheckpoints, + createCheckpoint: _createCheckpoint, + restoreCheckpoint, + forkCheckpoint + } = useCheckpoints({ + sessionId: claudeSessionId, + projectId: session?.project_id || '', + projectPath: projectPath, + onToast: (message: string, type: 'success' | 'error') => { + console.log(`Toast: ${type} - ${message}`); + } + }); + + // Handle path selection + const handleSelectPath = async () => { + const selected = await open({ + directory: true, + multiple: false, + title: "Select Project Directory" + }); + + if (selected && typeof selected === 'string') { + setProjectPath(selected); + setError(null); + setIsFirstPrompt(true); + } + }; + + // Handle sending prompts + const handleSendPrompt = useCallback(async (prompt: string, model: "sonnet" | "opus") => { + if (!projectPath || !prompt.trim()) return; + + // Add to queue if streaming + if (isStreaming) { + const id = Date.now().toString(); + setQueuedPrompts(prev => [...prev, { id, prompt, model }]); + return; + } + + try { + setError(null); + + if (isFirstPrompt) { + await api.executeClaudeCode(projectPath, prompt, model); + setIsFirstPrompt(false); + } else if (claudeSessionId) { + await api.continueClaudeCode(projectPath, prompt, model); + } + } catch (error) { + console.error("Failed to send prompt:", error); + setError(error instanceof Error ? error.message : "Failed to send prompt"); + } + }, [projectPath, isStreaming, isFirstPrompt, claudeSessionId]); + + // Process queued prompts + const processQueuedPrompts = useCallback(async () => { + if (queuedPrompts.length === 0 || isStreaming) return; + + const nextPrompt = queuedPrompts[0]; + setQueuedPrompts(prev => prev.slice(1)); + + await handleSendPrompt(nextPrompt.prompt, nextPrompt.model); + }, [queuedPrompts, isStreaming, handleSendPrompt]); + + // Effect to process queue when streaming stops + useEffect(() => { + if (!isStreaming && queuedPrompts.length > 0) { + processQueueTimeoutRef.current = setTimeout(processQueuedPrompts, 500); + } + + return () => { + if (processQueueTimeoutRef.current) { + clearTimeout(processQueueTimeoutRef.current); + } + }; + }, [isStreaming, queuedPrompts.length, processQueuedPrompts]); + + // Copy handlers + const handleCopyAsJsonl = async () => { + try { + await navigator.clipboard.writeText(rawJsonlOutput.join('\n')); + setCopyPopoverOpen(false); + console.log("Session output copied as JSONL"); + } catch (error) { + console.error("Failed to copy:", error); + } + }; + + const handleCopyAsMarkdown = async () => { + try { + const markdown = messages + .filter(msg => msg.type === 'user' || msg.type === 'assistant') + .map(msg => { + if (msg.type === 'user') { + return `## User\n\n${msg.message || ''}`; + } else if (msg.type === 'assistant' && msg.message?.content) { + const content = Array.isArray(msg.message.content) + ? msg.message.content.map((item: any) => { + if (typeof item === 'string') return item; + if (item.type === 'text') return item.text; + return ''; + }).filter(Boolean).join('') + : msg.message.content; + return `## Assistant\n\n${content}`; + } + return ''; + }) + .filter(Boolean) + .join('\n\n---\n\n'); + + await navigator.clipboard.writeText(markdown); + setCopyPopoverOpen(false); + console.log("Session output copied as Markdown"); + } catch (error) { + console.error("Failed to copy:", error); + } + }; + + // Fork dialog handlers + const handleFork = (checkpointId: string) => { + setForkCheckpointId(checkpointId); + setForkSessionName(""); + setShowForkDialog(true); + }; + + const handleConfirmFork = async () => { + if (!forkCheckpointId || !forkSessionName.trim()) return; + + const forkedSession = await forkCheckpoint(forkCheckpointId, forkSessionName); + if (forkedSession) { + setShowForkDialog(false); + // Navigate to forked session + window.location.reload(); // Or use proper navigation + } + }; + + // Link detection handler + const handleLinkDetected = (url: string) => { + setPreviewUrl(url); + if (!showPreview) { + setShowPreview(true); + } + }; + + // Load session if provided + useEffect(() => { + if (session) { + setProjectPath(session.project_path); + setClaudeSessionId(session.id); + loadMessages(session.id); + loadCheckpoints(); + } + }, [session, loadMessages, loadCheckpoints]); + + return ( + +
+ {/* Header */} + 0} + showTimeline={showTimeline} + copyPopoverOpen={copyPopoverOpen} + onBack={onBack} + onSelectPath={handleSelectPath} + onCopyAsJsonl={handleCopyAsJsonl} + onCopyAsMarkdown={handleCopyAsMarkdown} + onToggleTimeline={() => setShowTimeline(!showTimeline)} + onProjectSettings={onProjectSettings ? () => onProjectSettings(projectPath) : undefined} + onSlashCommandsSettings={() => setShowSlashCommandsSettings(true)} + setCopyPopoverOpen={setCopyPopoverOpen} + /> + + {/* Main content area */} +
+ {showPreview ? ( + + + setQueuedPrompts(prev => prev.filter(p => p.id !== id))} + /> +
+ } + right={ + setShowPreview(false)} + onUrlChange={setPreviewUrl} + onToggleMaximize={() => setIsPreviewMaximized(!isPreviewMaximized)} + /> + } + initialSplit={60} + /> + ) : ( +
+ + setQueuedPrompts(prev => prev.filter(p => p.id !== id))} + /> +
+ )} +
+ + {/* Error display */} + {error && ( + +

{error}

+
+ )} + + {/* Floating prompt input */} + {projectPath && ( + { + if (claudeSessionId && isStreaming) { + await api.cancelClaudeExecution(claudeSessionId); + } + }} + /> + )} + + {/* Timeline Navigator */} + {showTimeline && claudeSessionId && session && ( + { + const success = await restoreCheckpoint(checkpoint.id); + if (success) { + clearMessages(); + loadMessages(claudeSessionId); + } + }} + onFork={handleFork} + refreshVersion={timelineVersion} + /> + )} + + {/* Settings dialogs */} + {showSettings && claudeSessionId && session && ( + setShowSettings(false)} + /> + )} + + {showSlashCommandsSettings && projectPath && ( + + )} + + {/* Fork dialog */} + + + + Fork Session from Checkpoint + + Create a new session branching from this checkpoint. The original session will remain unchanged. + + +
+
+ + setForkSessionName(e.target.value)} + placeholder="Enter a name for the forked session" + className="mt-2" + /> +
+
+ + + + +
+
+ +
+ ); +}; \ No newline at end of file diff --git a/src/components/claude-code-session/MessageList.tsx b/src/components/claude-code-session/MessageList.tsx new file mode 100644 index 0000000..c89912f --- /dev/null +++ b/src/components/claude-code-session/MessageList.tsx @@ -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 = React.memo(({ + messages, + projectPath, + isStreaming, + onLinkDetected, + className +}) => { + const scrollContainerRef = useRef(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 ( +
+ +
+ +
+
+

Ready to start coding

+

+ {projectPath + ? "Enter a prompt below to begin your Claude Code session" + : "Select a project folder to begin"} +

+
+
+
+ ); + } + + return ( +
+
+ + {virtualizer.getVirtualItems().map((virtualItem) => { + const message = messages[virtualItem.index]; + const key = `msg-${virtualItem.index}-${message.type}`; + + return ( + +
+ +
+
+ ); + })} +
+
+ + {/* Streaming indicator */} + {isStreaming && ( + +
+
+ Claude is thinking... +
+ + )} +
+ ); +}); \ No newline at end of file diff --git a/src/components/claude-code-session/PromptQueue.tsx b/src/components/claude-code-session/PromptQueue.tsx new file mode 100644 index 0000000..b2b2f54 --- /dev/null +++ b/src/components/claude-code-session/PromptQueue.tsx @@ -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 = React.memo(({ + queuedPrompts, + onRemove, + className +}) => { + if (queuedPrompts.length === 0) return null; + + return ( + +
+
+ + Queued Prompts + + {queuedPrompts.length} + +
+ +
+ + {queuedPrompts.map((queuedPrompt, index) => ( + +
+ {queuedPrompt.model === "opus" ? ( + + ) : ( + + )} +
+ +
+

{queuedPrompt.prompt}

+ + {queuedPrompt.model === "opus" ? "Opus" : "Sonnet"} + +
+ + +
+ ))} +
+
+
+
+ ); +}); \ No newline at end of file diff --git a/src/components/claude-code-session/SessionHeader.tsx b/src/components/claude-code-session/SessionHeader.tsx new file mode 100644 index 0000000..262f26c --- /dev/null +++ b/src/components/claude-code-session/SessionHeader.tsx @@ -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 = React.memo(({ + projectPath, + claudeSessionId, + totalTokens, + isStreaming, + hasMessages, + showTimeline, + copyPopoverOpen, + onBack, + onSelectPath, + onCopyAsJsonl, + onCopyAsMarkdown, + onToggleTimeline, + onProjectSettings, + onSlashCommandsSettings, + setCopyPopoverOpen +}) => { + return ( + +
+
+ + +
+ + Claude Code Session +
+ + {projectPath && ( +
+ + {projectPath} +
+ )} + + {!projectPath && ( + + )} +
+ +
+ {claudeSessionId && ( +
+ + + {claudeSessionId.slice(0, 8)} + + {totalTokens > 0 && ( + + {totalTokens.toLocaleString()} tokens + + )} +
+ )} + + {hasMessages && !isStreaming && ( + + + + } + content={ +
+ + +
+ } + className="w-48 p-2" + /> + )} + + + + + + + + + {onProjectSettings && projectPath && ( + + + Project Settings + + )} + {onSlashCommandsSettings && projectPath && ( + + + Slash Commands + + )} + + +
+
+
+ ); +}); \ No newline at end of file diff --git a/src/components/claude-code-session/useCheckpoints.ts b/src/components/claude-code-session/useCheckpoints.ts new file mode 100644 index 0000000..e7b85af --- /dev/null +++ b/src/components/claude-code-session/useCheckpoints.ts @@ -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([]); + 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 + }; +} \ No newline at end of file diff --git a/src/components/claude-code-session/useClaudeMessages.ts b/src/components/claude-code-session/useClaudeMessages.ts new file mode 100644 index 0000000..042deae --- /dev/null +++ b/src/components/claude-code-session/useClaudeMessages.ts @@ -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([]); + const [rawJsonlOutput, setRawJsonlOutput] = useState([]); + const [isStreaming, setIsStreaming] = useState(false); + const [currentSessionId, setCurrentSessionId] = useState(null); + + const eventListenerRef = useRef(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("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 + }; +} \ No newline at end of file