From b126288797ed17c4eec14264c26eb0fc741c1883 Mon Sep 17 00:00:00 2001 From: Mufeed VH Date: Wed, 2 Jul 2025 19:49:00 +0530 Subject: [PATCH] feat(ui): add prompt queuing system and improve session management - Add prompt queue to handle multiple prompts when Claude is processing - Improve session reconnection with better event listener management - Fix race conditions in session initialization and cleanup - Replace Loader2 with rotating symbol for consistent loading states - Remove TokenCounter integration and loading-disabled input restrictions - Enhance cancellation logic with proper state cleanup - Update thinking mode phrase formatting in FloatingPromptInput - Improve UI layout with better spacing and error positioning This enables users to queue multiple prompts without waiting for the current one to complete, providing a smoother interaction experience. --- src/components/ClaudeCodeSession.tsx | 630 +++++++++++++++++-------- src/components/FloatingPromptInput.tsx | 16 +- 2 files changed, 451 insertions(+), 195 deletions(-) diff --git a/src/components/ClaudeCodeSession.tsx b/src/components/ClaudeCodeSession.tsx index 67a5b20..89f554c 100644 --- a/src/components/ClaudeCodeSession.tsx +++ b/src/components/ClaudeCodeSession.tsx @@ -3,13 +3,15 @@ import { motion, AnimatePresence } from "framer-motion"; import { ArrowLeft, Terminal, - Loader2, FolderOpen, Copy, ChevronDown, GitBranch, Settings, - Globe + Globe, + ChevronUp, + X, + Hash } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; @@ -22,7 +24,6 @@ import { listen, type UnlistenFn } from "@tauri-apps/api/event"; import { StreamMessage } from "./StreamMessage"; import { FloatingPromptInput, type FloatingPromptInputRef } 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"; @@ -84,7 +85,9 @@ export const ClaudeCodeSession: React.FC = ({ const [showForkDialog, setShowForkDialog] = useState(false); const [forkCheckpointId, setForkCheckpointId] = useState(null); const [forkSessionName, setForkSessionName] = useState(""); - const [isCancelling, setIsCancelling] = useState(false); + + // Queued prompts state + const [queuedPrompts, setQueuedPrompts] = useState>([]); // New state for preview feature const [showPreview, setShowPreview] = useState(false); @@ -93,10 +96,21 @@ export const ClaudeCodeSession: React.FC = ({ const [splitPosition, setSplitPosition] = useState(50); const [isPreviewMaximized, setIsPreviewMaximized] = useState(false); + // Add collapsed state for queued prompts + const [queuedPromptsCollapsed, setQueuedPromptsCollapsed] = useState(false); + const parentRef = useRef(null); const unlistenRefs = useRef([]); const hasActiveSessionRef = useRef(false); const floatingPromptRef = useRef(null); + const queuedPromptsRef = useRef>([]); + const isMountedRef = useRef(true); + const isListeningRef = useRef(false); + + // Keep ref in sync with state + useEffect(() => { + queuedPromptsRef.current = queuedPrompts; + }, [queuedPrompts]); // Get effective session info (from prop or extracted) - use useMemo to ensure it updates const effectiveSession = useMemo(() => { @@ -197,21 +211,27 @@ export const ClaudeCodeSession: React.FC = ({ // Load session history if resuming useEffect(() => { if (session) { - loadSessionHistory(); + // Set the claudeSessionId immediately when we have a session + setClaudeSessionId(session.id); + + // Load session history first, then check for active session + const initializeSession = async () => { + await loadSessionHistory(); + // After loading history, check if the session is still active + if (isMountedRef.current) { + await checkForActiveSession(); + } + }; + + initializeSession(); } - }, [session]); + }, [session]); // Remove hasLoadedSession dependency to ensure it runs on mount // Report streaming state changes useEffect(() => { onStreamingChange?.(isLoading, claudeSessionId); }, [isLoading, claudeSessionId, onStreamingChange]); - // Check for active Claude sessions on mount - useEffect(() => { - checkForActiveSession(); - }, []); - - // Auto-scroll to bottom when new messages arrive useEffect(() => { if (displayableMessages.length > 0) { @@ -276,23 +296,11 @@ export const ClaudeCodeSession: React.FC = ({ if (activeSession) { // Session is still active, reconnect to its stream console.log('[ClaudeCodeSession] Found active session, reconnecting:', session.id); + // IMPORTANT: Set claudeSessionId before reconnecting setClaudeSessionId(session.id); - // Get any buffered output - const bufferedOutput = await api.getClaudeSessionOutput(session.id); - if (bufferedOutput) { - // Parse and add buffered messages - const lines = bufferedOutput.split('\n').filter((line: string) => line.trim()); - for (const line of lines) { - try { - const message = JSON.parse(line) as ClaudeStreamMessage; - setMessages(prev => [...prev, message]); - setRawJsonlOutput(prev => [...prev, line]); - } catch (err) { - console.error('Failed to parse buffered message:', err); - } - } - } + // Don't add buffered messages here - they've already been loaded by loadSessionHistory + // Just set up listeners for new messages // Set up listeners for the active session reconnectToSession(session.id); @@ -306,15 +314,29 @@ export const ClaudeCodeSession: React.FC = ({ const reconnectToSession = async (sessionId: string) => { console.log('[ClaudeCodeSession] Reconnecting to session:', sessionId); + // Prevent duplicate listeners + if (isListeningRef.current) { + console.log('[ClaudeCodeSession] Already listening to session, skipping reconnect'); + return; + } + // Clean up previous listeners unlistenRefs.current.forEach(unlisten => unlisten()); unlistenRefs.current = []; + // IMPORTANT: Set the session ID before setting up listeners + setClaudeSessionId(sessionId); + + // Mark as listening + isListeningRef.current = true; + // Set up session-specific listeners const outputUnlisten = await listen(`claude-output:${sessionId}`, async (event) => { try { console.log('[ClaudeCodeSession] Received claude-output on reconnect:', event.payload); + if (!isMountedRef.current) return; + // Store raw JSONL setRawJsonlOutput(prev => [...prev, event.payload]); @@ -328,20 +350,26 @@ export const ClaudeCodeSession: React.FC = ({ const errorUnlisten = await listen(`claude-error:${sessionId}`, (event) => { console.error("Claude error:", event.payload); - setError(event.payload); + if (isMountedRef.current) { + setError(event.payload); + } }); const completeUnlisten = await listen(`claude-complete:${sessionId}`, async (event) => { console.log('[ClaudeCodeSession] Received claude-complete on reconnect:', event.payload); - setIsLoading(false); - hasActiveSessionRef.current = false; + if (isMountedRef.current) { + setIsLoading(false); + hasActiveSessionRef.current = false; + } }); unlistenRefs.current = [outputUnlisten, errorUnlisten, completeUnlisten]; // Mark as loading to show the session is active - setIsLoading(true); - hasActiveSessionRef.current = true; + if (isMountedRef.current) { + setIsLoading(true); + hasActiveSessionRef.current = true; + } }; const handleSelectPath = async () => { @@ -364,126 +392,208 @@ export const ClaudeCodeSession: React.FC = ({ }; const handleSendPrompt = async (prompt: string, model: "sonnet" | "opus") => { - console.log('[ClaudeCodeSession] handleSendPrompt called with:', { prompt, model, projectPath }); + console.log('[ClaudeCodeSession] handleSendPrompt called with:', { prompt, model, projectPath, claudeSessionId, effectiveSession }); if (!projectPath) { setError("Please select a project directory first"); return; } + // If already loading, queue the prompt + if (isLoading) { + const newPrompt = { + id: `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`, + prompt, + model + }; + setQueuedPrompts(prev => [...prev, newPrompt]); + return; + } + try { setIsLoading(true); setError(null); hasActiveSessionRef.current = true; - // Clean up previous listeners - unlistenRefs.current.forEach(unlisten => unlisten()); - unlistenRefs.current = []; + // For resuming sessions, ensure we have the session ID + if (effectiveSession && !claudeSessionId) { + setClaudeSessionId(effectiveSession.id); + } - // Set up event listeners before executing - console.log('[ClaudeCodeSession] Setting up event listeners...'); - - // If we already have a Claude session ID, use isolated listeners - const eventSuffix = claudeSessionId ? `:${claudeSessionId}` : ''; - - const outputUnlisten = await listen(`claude-output${eventSuffix}`, 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) { - 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, '-'); - - // Set both claudeSessionId and extractedSessionInfo - if (!claudeSessionId) { - setClaudeSessionId(message.session_id); - } - - if (!extractedSessionInfo) { - setExtractedSessionInfo({ - sessionId: message.session_id, - projectId: projectId - }); - } - } - } catch (err) { - console.error("Failed to parse message:", err, event.payload); - } - }); - - const errorUnlisten = await listen(`claude-error${eventSuffix}`, (event) => { - console.error("Claude error:", event.payload); - setError(event.payload); - }); - - const completeUnlisten = await listen(`claude-complete${eventSuffix}`, async (event) => { - console.log('[ClaudeCodeSession] Received claude-complete:', event.payload); - setIsLoading(false); - hasActiveSessionRef.current = false; + // Only clean up and set up new listeners if not already listening + if (!isListeningRef.current) { + // Clean up previous listeners + unlistenRefs.current.forEach(unlisten => unlisten()); + unlistenRefs.current = []; - // Check if we should create an auto checkpoint after completion - if (effectiveSession && event.payload) { + // Mark as setting up listeners + isListeningRef.current = true; + + // -------------------------------------------------------------------- + // 1️⃣ Event Listener Setup Strategy + // -------------------------------------------------------------------- + // Claude Code may emit a *new* session_id even when we pass --resume. If + // we listen only on the old session-scoped channel we will miss the + // stream until the user navigates away & back. To avoid this we: + // • Always start with GENERIC listeners (no suffix) so we catch the + // very first "system:init" message regardless of the session id. + // • Once that init message provides the *actual* session_id, we + // dynamically switch to session-scoped listeners and stop the + // generic ones to prevent duplicate handling. + // -------------------------------------------------------------------- + + console.log('[ClaudeCodeSession] Setting up generic event listeners first'); + + let currentSessionId: string | null = claudeSessionId || effectiveSession?.id || null; + + // Helper to attach session-specific listeners **once we are sure** + const attachSessionSpecificListeners = async (sid: string) => { + console.log('[ClaudeCodeSession] Attaching session-specific listeners for', sid); + + const specificOutputUnlisten = await listen(`claude-output:${sid}`, (evt) => { + handleStreamMessage(evt.payload); + }); + + const specificErrorUnlisten = await listen(`claude-error:${sid}`, (evt) => { + console.error('Claude error (scoped):', evt.payload); + setError(evt.payload); + }); + + const specificCompleteUnlisten = await listen(`claude-complete:${sid}`, (evt) => { + console.log('[ClaudeCodeSession] Received claude-complete (scoped):', evt.payload); + processComplete(evt.payload); + }); + + // Replace existing unlisten refs with these new ones (after cleaning up) + unlistenRefs.current.forEach((u) => u()); + unlistenRefs.current = [specificOutputUnlisten, specificErrorUnlisten, specificCompleteUnlisten]; + }; + + // Generic listeners (catch-all) + const genericOutputUnlisten = await listen('claude-output', async (event) => { + handleStreamMessage(event.payload); + + // Attempt to extract session_id on the fly (for the very first init) try { - const settings = await api.getCheckpointSettings( - effectiveSession.id, - effectiveSession.project_id, - projectPath - ); + const msg = JSON.parse(event.payload) as ClaudeStreamMessage; + if (msg.type === 'system' && msg.subtype === 'init' && msg.session_id) { + if (!currentSessionId || currentSessionId !== msg.session_id) { + console.log('[ClaudeCodeSession] Detected new session_id from generic listener:', msg.session_id); + currentSessionId = msg.session_id; + setClaudeSessionId(msg.session_id); + + // If we haven't extracted session info before, do it now + if (!extractedSessionInfo) { + const projectId = projectPath.replace(/[^a-zA-Z0-9]/g, '-'); + setExtractedSessionInfo({ sessionId: msg.session_id, projectId }); + } + + // Switch to session-specific listeners + await attachSessionSpecificListeners(msg.session_id); + } + } + } catch { + /* ignore parse errors */ + } + }); + + // Helper to process any JSONL stream message string + function handleStreamMessage(payload: string) { + try { + // Don't process if component unmounted + if (!isMountedRef.current) return; - if (settings.auto_checkpoint_enabled) { - await api.checkAutoCheckpoint( + // Store raw JSONL + setRawJsonlOutput((prev) => [...prev, payload]); + + const message = JSON.parse(payload) as ClaudeStreamMessage; + setMessages((prev) => [...prev, message]); + } catch (err) { + console.error('Failed to parse message:', err, payload); + } + } + + // Helper to handle completion events (both generic and scoped) + const processComplete = async (success: boolean) => { + setIsLoading(false); + hasActiveSessionRef.current = false; + isListeningRef.current = false; // Reset listening state + + if (effectiveSession && success) { + try { + const settings = await api.getCheckpointSettings( effectiveSession.id, effectiveSession.project_id, - projectPath, - prompt + projectPath ); - // Reload timeline to show new checkpoint - setTimelineVersion((v) => v + 1); + + if (settings.auto_checkpoint_enabled) { + await api.checkAutoCheckpoint( + effectiveSession.id, + effectiveSession.project_id, + projectPath, + prompt + ); + // Reload timeline to show new checkpoint + setTimelineVersion((v) => v + 1); + } + } catch (err) { + console.error('Failed to check auto checkpoint:', err); } - } catch (err) { - console.error('Failed to check auto checkpoint:', err); } - } - }); - unlistenRefs.current = [outputUnlisten, errorUnlisten, completeUnlisten]; - - // Add the user message immediately to the UI (after setting up listeners) - const userMessage: ClaudeStreamMessage = { - type: "user", - message: { - content: [ - { - type: "text", - text: prompt - } - ] - } - }; - setMessages(prev => [...prev, userMessage]); + // Process queued prompts after completion + if (queuedPromptsRef.current.length > 0) { + const [nextPrompt, ...remainingPrompts] = queuedPromptsRef.current; + setQueuedPrompts(remainingPrompts); + + // Small delay to ensure UI updates + setTimeout(() => { + handleSendPrompt(nextPrompt.prompt, nextPrompt.model); + }, 100); + } + }; - // Execute the appropriate command - if (effectiveSession && !isFirstPrompt) { - console.log('[ClaudeCodeSession] Resuming session:', effectiveSession.id); - await api.resumeClaudeCode(projectPath, effectiveSession.id, prompt, model); - } else { - console.log('[ClaudeCodeSession] Starting new session'); - setIsFirstPrompt(false); - await api.executeClaudeCode(projectPath, prompt, model); + const genericErrorUnlisten = await listen('claude-error', (evt) => { + console.error('Claude error:', evt.payload); + setError(evt.payload); + }); + + const genericCompleteUnlisten = await listen('claude-complete', (evt) => { + console.log('[ClaudeCodeSession] Received claude-complete (generic):', evt.payload); + processComplete(evt.payload); + }); + + // Store the generic unlisteners for now; they may be replaced later. + unlistenRefs.current = [genericOutputUnlisten, genericErrorUnlisten, genericCompleteUnlisten]; + + // -------------------------------------------------------------------- + // 2️⃣ Auto-checkpoint logic moved after listener setup (unchanged) + // -------------------------------------------------------------------- + + // Add the user message immediately to the UI (after setting up listeners) + const userMessage: ClaudeStreamMessage = { + type: "user", + message: { + content: [ + { + type: "text", + text: prompt + } + ] + } + }; + setMessages(prev => [...prev, userMessage]); + + // Execute the appropriate command + if (effectiveSession && !isFirstPrompt) { + console.log('[ClaudeCodeSession] Resuming session:', effectiveSession.id); + await api.resumeClaudeCode(projectPath, effectiveSession.id, prompt, model); + } else { + console.log('[ClaudeCodeSession] Starting new session'); + setIsFirstPrompt(false); + await api.executeClaudeCode(projectPath, prompt, model); + } } } catch (err) { console.error("Failed to send prompt:", err); @@ -579,31 +689,32 @@ export const ClaudeCodeSession: React.FC = ({ }; const handleCancelExecution = async () => { - if (!isLoading || isCancelling) return; + if (!claudeSessionId || !isLoading) return; try { - setIsCancelling(true); - - // Cancel the Claude execution with session ID if available - await api.cancelClaudeExecution(claudeSessionId || undefined); + await api.cancelClaudeExecution(claudeSessionId); // Clean up listeners unlistenRefs.current.forEach(unlisten => unlisten()); unlistenRefs.current = []; - // Add a system message indicating cancellation - const cancelMessage: ClaudeStreamMessage = { - type: "system", - subtype: "cancelled", - result: "Execution cancelled by user", - timestamp: new Date().toISOString() - }; - setMessages(prev => [...prev, cancelMessage]); - // Reset states setIsLoading(false); hasActiveSessionRef.current = false; + isListeningRef.current = false; setError(null); + + // Clear queued prompts + setQueuedPrompts([]); + + // Add a message indicating the session was cancelled + const cancelMessage: ClaudeStreamMessage = { + type: "system", + subtype: "info", + result: "Session cancelled by user", + timestamp: new Date().toISOString() + }; + setMessages(prev => [...prev, cancelMessage]); } catch (err) { console.error("Failed to cancel execution:", err); @@ -624,9 +735,8 @@ export const ClaudeCodeSession: React.FC = ({ // Reset states to allow user to continue setIsLoading(false); hasActiveSessionRef.current = false; + isListeningRef.current = false; setError(null); - } finally { - setIsCancelling(false); } }; @@ -707,10 +817,19 @@ export const ClaudeCodeSession: React.FC = ({ } }; - // Clean up listeners on component unmount + // Cleanup event listeners and track mount state useEffect(() => { + isMountedRef.current = true; + return () => { + console.log('[ClaudeCodeSession] Component unmounting, cleaning up listeners'); + isMountedRef.current = false; + isListeningRef.current = false; + + // Clean up listeners unlistenRefs.current.forEach(unlisten => unlisten()); + unlistenRefs.current = []; + // Clear checkpoint manager when session ends if (effectiveSession) { api.clearCheckpointManager(effectiveSession.id).catch(err => { @@ -718,20 +837,21 @@ export const ClaudeCodeSession: React.FC = ({ }); } }; - }, []); + }, [effectiveSession]); const messagesList = (
@@ -762,28 +882,27 @@ export const ClaudeCodeSession: React.FC = ({
- {/* Loading and Error indicators positioned relative to the scroll container */} -
- {isLoading && ( - - - - )} - - {error && ( - - {error} - - )} -
+ {/* Loading indicator under the latest message */} + {isLoading && ( + +
+ + )} + + {/* Error indicator */} + {error && ( + + {error} + + )}
); @@ -859,7 +978,6 @@ export const ClaudeCodeSession: React.FC = ({ size="icon" onClick={onBack} className="h-8 w-8" - disabled={isLoading} > @@ -965,8 +1083,6 @@ export const ClaudeCodeSession: React.FC = ({ onOpenChange={setCopyPopoverOpen} /> )} - -
@@ -1002,23 +1118,141 @@ export const ClaudeCodeSession: React.FC = ({
{projectPathInput} {messagesList} -
- )} - - {isLoading && messages.length === 0 && ( -
-
- - - {session ? "Loading session history..." : "Initializing Claude Code..."} - -
+ + {isLoading && messages.length === 0 && ( +
+
+
+ + {session ? "Loading session history..." : "Initializing Claude Code..."} + +
+
+ )}
)}
{/* Floating Prompt Input - Always visible */} + {/* Queued Prompts Display */} + + {queuedPrompts.length > 0 && ( + +
+
+
+ Queued Prompts ({queuedPrompts.length}) +
+ +
+ {!queuedPromptsCollapsed && queuedPrompts.map((queuedPrompt, index) => ( + +
+
+ #{index + 1} + + {queuedPrompt.model === "opus" ? "Opus" : "Sonnet"} + +
+

{queuedPrompt.prompt}

+
+ +
+ ))} +
+
+ )} +
+ + {/* Navigation Arrows - positioned above prompt bar with spacing */} + {displayableMessages.length > 5 && ( + +
+ +
+ +
+ + )} + = ({ disabled={!projectPath} projectPath={projectPath} /> + + {/* Token Counter - positioned under the Send button */} + {totalTokens > 0 && ( +
+
+
+ +
+ + {totalTokens.toLocaleString()} + tokens +
+
+
+
+
+ )} {/* Timeline */} diff --git a/src/components/FloatingPromptInput.tsx b/src/components/FloatingPromptInput.tsx index 1681909..893919a 100644 --- a/src/components/FloatingPromptInput.tsx +++ b/src/components/FloatingPromptInput.tsx @@ -335,13 +335,13 @@ const FloatingPromptInputInner = ( }, [isExpanded]); const handleSend = () => { - if (prompt.trim() && !isLoading && !disabled) { + if (prompt.trim() && !disabled) { let finalPrompt = prompt.trim(); // Append thinking phrase if not auto mode const thinkingMode = THINKING_MODES.find(m => m.id === selectedThinkingMode); if (thinkingMode && thinkingMode.phrase) { - finalPrompt = `${finalPrompt}\n\n${thinkingMode.phrase}.`; + finalPrompt = `${finalPrompt}.\n\n${thinkingMode.phrase}.`; } onSend(finalPrompt, selectedModel); @@ -516,7 +516,7 @@ const FloatingPromptInputInner = ( onChange={handleTextChange} placeholder="Type your prompt here..." className="min-h-[200px] resize-none" - disabled={isLoading || disabled} + disabled={disabled} onDragEnter={handleDrag} onDragLeave={handleDrag} onDragOver={handleDrag} @@ -603,7 +603,7 @@ const FloatingPromptInputInner = (