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:
401
src/components/ClaudeCodeSession.refactored.tsx
Normal file
401
src/components/ClaudeCodeSession.refactored.tsx
Normal file
@@ -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<ClaudeCodeSessionProps> = ({
|
||||||
|
session,
|
||||||
|
initialProjectPath = "",
|
||||||
|
onBack,
|
||||||
|
onProjectSettings,
|
||||||
|
className,
|
||||||
|
onStreamingChange,
|
||||||
|
}) => {
|
||||||
|
const [projectPath, setProjectPath] = useState(initialProjectPath || session?.project_path || "");
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [copyPopoverOpen, setCopyPopoverOpen] = useState(false);
|
||||||
|
const [isFirstPrompt, setIsFirstPrompt] = useState(!session);
|
||||||
|
const [totalTokens, setTotalTokens] = useState(0);
|
||||||
|
const [claudeSessionId, setClaudeSessionId] = useState<string | null>(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<string | null>(null);
|
||||||
|
const [forkSessionName, setForkSessionName] = useState("");
|
||||||
|
const [queuedPrompts, setQueuedPrompts] = useState<Array<{ id: string; prompt: string; model: "sonnet" | "opus" }>>([]);
|
||||||
|
const [showPreview, setShowPreview] = useState(false);
|
||||||
|
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
|
||||||
|
const [isPreviewMaximized, setIsPreviewMaximized] = useState(false);
|
||||||
|
const promptInputRef = useRef<FloatingPromptInputRef>(null);
|
||||||
|
const processQueueTimeoutRef = useRef<NodeJS.Timeout | null>(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 (
|
||||||
|
<ErrorBoundary>
|
||||||
|
<div className={cn("flex flex-col h-screen bg-background", className)}>
|
||||||
|
{/* Header */}
|
||||||
|
<SessionHeader
|
||||||
|
projectPath={projectPath}
|
||||||
|
claudeSessionId={claudeSessionId}
|
||||||
|
totalTokens={totalTokens}
|
||||||
|
isStreaming={isStreaming}
|
||||||
|
hasMessages={messages.length > 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 */}
|
||||||
|
<div className="flex-1 flex overflow-hidden">
|
||||||
|
{showPreview ? (
|
||||||
|
<SplitPane
|
||||||
|
left={
|
||||||
|
<div className="flex flex-col h-full">
|
||||||
|
<MessageList
|
||||||
|
messages={messages}
|
||||||
|
projectPath={projectPath}
|
||||||
|
isStreaming={isStreaming}
|
||||||
|
onLinkDetected={handleLinkDetected}
|
||||||
|
className="flex-1"
|
||||||
|
/>
|
||||||
|
<PromptQueue
|
||||||
|
queuedPrompts={queuedPrompts}
|
||||||
|
onRemove={(id) => setQueuedPrompts(prev => prev.filter(p => p.id !== id))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
right={
|
||||||
|
<WebviewPreview
|
||||||
|
initialUrl={previewUrl || ""}
|
||||||
|
isMaximized={isPreviewMaximized}
|
||||||
|
onClose={() => setShowPreview(false)}
|
||||||
|
onUrlChange={setPreviewUrl}
|
||||||
|
onToggleMaximize={() => setIsPreviewMaximized(!isPreviewMaximized)}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
initialSplit={60}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col flex-1">
|
||||||
|
<MessageList
|
||||||
|
messages={messages}
|
||||||
|
projectPath={projectPath}
|
||||||
|
isStreaming={isStreaming}
|
||||||
|
onLinkDetected={handleLinkDetected}
|
||||||
|
className="flex-1"
|
||||||
|
/>
|
||||||
|
<PromptQueue
|
||||||
|
queuedPrompts={queuedPrompts}
|
||||||
|
onRemove={(id) => setQueuedPrompts(prev => prev.filter(p => p.id !== id))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Error display */}
|
||||||
|
{error && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
className="mx-4 mb-4 p-3 bg-destructive/10 border border-destructive/20 rounded-md"
|
||||||
|
>
|
||||||
|
<p className="text-sm text-destructive">{error}</p>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Floating prompt input */}
|
||||||
|
{projectPath && (
|
||||||
|
<FloatingPromptInput
|
||||||
|
ref={promptInputRef}
|
||||||
|
onSend={handleSendPrompt}
|
||||||
|
disabled={!projectPath}
|
||||||
|
isLoading={isStreaming}
|
||||||
|
onCancel={async () => {
|
||||||
|
if (claudeSessionId && isStreaming) {
|
||||||
|
await api.cancelClaudeExecution(claudeSessionId);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Timeline Navigator */}
|
||||||
|
{showTimeline && claudeSessionId && session && (
|
||||||
|
<TimelineNavigator
|
||||||
|
sessionId={claudeSessionId}
|
||||||
|
projectId={session.project_id}
|
||||||
|
projectPath={projectPath}
|
||||||
|
currentMessageIndex={messages.length}
|
||||||
|
onCheckpointSelect={async (checkpoint) => {
|
||||||
|
const success = await restoreCheckpoint(checkpoint.id);
|
||||||
|
if (success) {
|
||||||
|
clearMessages();
|
||||||
|
loadMessages(claudeSessionId);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onFork={handleFork}
|
||||||
|
refreshVersion={timelineVersion}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Settings dialogs */}
|
||||||
|
{showSettings && claudeSessionId && session && (
|
||||||
|
<CheckpointSettings
|
||||||
|
sessionId={claudeSessionId}
|
||||||
|
projectId={session.project_id}
|
||||||
|
projectPath={projectPath}
|
||||||
|
onClose={() => setShowSettings(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showSlashCommandsSettings && projectPath && (
|
||||||
|
<SlashCommandsManager
|
||||||
|
projectPath={projectPath}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Fork dialog */}
|
||||||
|
<Dialog open={showForkDialog} onOpenChange={setShowForkDialog}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Fork Session from Checkpoint</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Create a new session branching from this checkpoint. The original session will remain unchanged.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="space-y-4 py-4">
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="fork-name">New Session Name</Label>
|
||||||
|
<Input
|
||||||
|
id="fork-name"
|
||||||
|
value={forkSessionName}
|
||||||
|
onChange={(e) => setForkSessionName(e.target.value)}
|
||||||
|
placeholder="Enter a name for the forked session"
|
||||||
|
className="mt-2"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setShowForkDialog(false)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={handleConfirmFork}
|
||||||
|
disabled={!forkSessionName.trim()}
|
||||||
|
>
|
||||||
|
Fork Session
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
</ErrorBoundary>
|
||||||
|
);
|
||||||
|
};
|
155
src/components/claude-code-session/MessageList.tsx
Normal file
155
src/components/claude-code-session/MessageList.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
});
|
84
src/components/claude-code-session/PromptQueue.tsx
Normal file
84
src/components/claude-code-session/PromptQueue.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
});
|
181
src/components/claude-code-session/SessionHeader.tsx
Normal file
181
src/components/claude-code-session/SessionHeader.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
});
|
122
src/components/claude-code-session/useCheckpoints.ts
Normal file
122
src/components/claude-code-session/useCheckpoints.ts
Normal 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
|
||||||
|
};
|
||||||
|
}
|
134
src/components/claude-code-session/useClaudeMessages.ts
Normal file
134
src/components/claude-code-session/useClaudeMessages.ts
Normal 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
|
||||||
|
};
|
||||||
|
}
|
Reference in New Issue
Block a user