diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 91288d4f..56e43d43 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -5,6 +5,7 @@ description = "GUI app and Toolkit for Claude Code" authors = ["mufeedvh", "123vviekr"] license = "AGPL-3.0" edition = "2021" +default-run = "opcode" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html diff --git a/src/components/ClaudeCodeSession.tsx b/src/components/ClaudeCodeSession.tsx index f0f164f2..78b6e7b3 100644 --- a/src/components/ClaudeCodeSession.tsx +++ b/src/components/ClaudeCodeSession.tsx @@ -16,50 +16,34 @@ import { Popover } from "@/components/ui/popover"; import { api, type Session } from "@/lib/api"; import { cn } from "@/lib/utils"; -// Conditional imports for Tauri APIs -let tauriListen: any; -type UnlistenFn = () => void; - -try { - if (typeof window !== 'undefined' && window.__TAURI__) { - tauriListen = require("@tauri-apps/api/event").listen; - } -} catch (e) { - console.log('[ClaudeCodeSession] Tauri APIs not available, using web mode'); -} - -// Web-compatible replacements -const listen = tauriListen || ((eventName: string, callback: (event: any) => void) => { - console.log('[ClaudeCodeSession] Setting up DOM event listener for:', eventName); - - // In web mode, listen for DOM events - const domEventHandler = (event: any) => { - console.log('[ClaudeCodeSession] DOM event received:', eventName, event.detail); - // Simulate Tauri event structure - callback({ payload: event.detail }); - }; - - window.addEventListener(eventName, domEventHandler); - - // Return unlisten function - return Promise.resolve(() => { - console.log('[ClaudeCodeSession] Removing DOM event listener for:', eventName); - window.removeEventListener(eventName, domEventHandler); - }); -}); +import { listen, type UnlistenFn } from "@tauri-apps/api/event"; import { StreamMessage } from "./StreamMessage"; -import { FloatingPromptInput, type FloatingPromptInputRef } from "./FloatingPromptInput"; +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 { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, + DialogFooter, +} from "@/components/ui/dialog"; import { TooltipProvider, TooltipSimple } from "@/components/ui/tooltip-modern"; import { SplitPane } from "@/components/ui/split-pane"; import { WebviewPreview } from "./WebviewPreview"; import type { ClaudeStreamMessage } from "./AgentExecution"; import { useVirtualizer } from "@tanstack/react-virtual"; -import { useTrackEvent, useComponentMetrics, useWorkflowTracking } from "@/hooks"; +import { + useTrackEvent, + useComponentMetrics, + useWorkflowTracking, +} from "@/hooks"; import { SessionPersistenceService } from "@/services/sessionPersistence"; interface ClaudeCodeSessionProps { @@ -95,7 +79,7 @@ interface ClaudeCodeSessionProps { /** * ClaudeCodeSession component for interactive Claude Code sessions - * + * * @example * setView('projects')} /> */ @@ -227,17 +211,33 @@ export const ClaudeCodeSession: React.FC = ({ // Look for the matching tool_use in previous assistant messages for (let i = index - 1; i >= 0; i--) { const prevMsg = messages[i]; - if (prevMsg.type === 'assistant' && prevMsg.message?.content && Array.isArray(prevMsg.message.content)) { - const toolUse = prevMsg.message.content.find((c: any) => - c.type === 'tool_use' && c.id === content.tool_use_id + if ( + prevMsg.type === "assistant" && + prevMsg.message?.content && + Array.isArray(prevMsg.message.content) + ) { + const toolUse = prevMsg.message.content.find( + (c: any) => + c.type === "tool_use" && c.id === content.tool_use_id ); if (toolUse) { const toolName = toolUse.name?.toLowerCase(); const toolsWithWidgets = [ - 'task', 'edit', 'multiedit', 'todowrite', 'ls', 'read', - 'glob', 'bash', 'write', 'grep' + "task", + "edit", + "multiedit", + "todowrite", + "ls", + "read", + "glob", + "bash", + "write", + "grep", ]; - if (toolsWithWidgets.includes(toolName) || toolUse.name?.startsWith('mcp__')) { + if ( + toolsWithWidgets.includes(toolName) || + toolUse.name?.startsWith("mcp__") + ) { willBeSkipped = true; } break; @@ -293,7 +293,7 @@ export const ClaudeCodeSession: React.FC = ({ await checkForActiveSession(); } }; - + initializeSession(); } }, [session]); // Remove hasLoadedSession dependency to ensure it runs on mount @@ -311,13 +311,16 @@ export const ClaudeCodeSession: React.FC = ({ const scrollElement = parentRef.current; if (scrollElement) { // First, scroll using virtualizer to get close to the bottom - rowVirtualizer.scrollToIndex(displayableMessages.length - 1, { align: 'end', behavior: 'auto' }); + rowVirtualizer.scrollToIndex(displayableMessages.length - 1, { + align: "end", + behavior: "auto", + }); // Then use direct scroll to ensure we reach the absolute bottom requestAnimationFrame(() => { scrollElement.scrollTo({ top: scrollElement.scrollHeight, - behavior: 'smooth' + behavior: "smooth", }); }); } @@ -400,84 +403,110 @@ export const ClaudeCodeSession: React.FC = ({ try { const activeSessions = await api.listRunningClaudeSessions(); const activeSession = activeSessions.find((s: any) => { - if ('process_type' in s && s.process_type && 'ClaudeSession' in s.process_type) { - return (s.process_type as any).ClaudeSession.session_id === session.id; + if ( + "process_type" in s && + s.process_type && + "ClaudeSession" in s.process_type + ) { + return ( + (s.process_type as any).ClaudeSession.session_id === session.id + ); } return false; }); - + if (activeSession) { // Session is still active, reconnect to its stream - console.log('[ClaudeCodeSession] Found active session, reconnecting:', session.id); + console.log( + "[ClaudeCodeSession] Found active session, reconnecting:", + session.id + ); // IMPORTANT: Set claudeSessionId before reconnecting setClaudeSessionId(session.id); - + // 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); } } catch (err) { - console.error('Failed to check for active sessions:', err); + console.error("Failed to check for active sessions:", err); } } }; const reconnectToSession = async (sessionId: string) => { - console.log('[ClaudeCodeSession] Reconnecting to session:', sessionId); - + console.log("[ClaudeCodeSession] Reconnecting to session:", sessionId); + // Prevent duplicate listeners if (isListeningRef.current) { - console.log('[ClaudeCodeSession] Already listening to session, skipping reconnect'); + console.log( + "[ClaudeCodeSession] Already listening to session, skipping reconnect" + ); return; } - + // Clean up previous listeners - unlistenRefs.current.forEach(unlisten => unlisten()); + 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: any) => { - try { - console.log('[ClaudeCodeSession] Received claude-output on reconnect:', event.payload); - - if (!isMountedRef.current) return; - - // Store raw JSONL - setRawJsonlOutput(prev => [...prev, event.payload]); - - // Parse and display - const message = JSON.parse(event.payload) as ClaudeStreamMessage; - setMessages(prev => [...prev, message]); - } catch (err) { - console.error("Failed to parse message:", err, event.payload); + const outputUnlisten = await listen( + `claude-output:${sessionId}`, + async (event: any) => { + try { + console.log( + "[ClaudeCodeSession] Received claude-output on reconnect:", + event.payload + ); + + if (!isMountedRef.current) return; + + // Store raw JSONL + setRawJsonlOutput((prev) => [...prev, event.payload]); + + // Parse and display + const message = JSON.parse(event.payload) as ClaudeStreamMessage; + setMessages((prev) => [...prev, message]); + } catch (err) { + console.error("Failed to parse message:", err, event.payload); + } } - }); + ); - const errorUnlisten = await listen(`claude-error:${sessionId}`, (event: any) => { - console.error("Claude error:", event.payload); - if (isMountedRef.current) { - setError(event.payload); + const errorUnlisten = await listen( + `claude-error:${sessionId}`, + (event: any) => { + console.error("Claude error:", event.payload); + if (isMountedRef.current) { + setError(event.payload); + } } - }); + ); - const completeUnlisten = await listen(`claude-complete:${sessionId}`, async (event: any) => { - console.log('[ClaudeCodeSession] Received claude-complete on reconnect:', event.payload); - if (isMountedRef.current) { - setIsLoading(false); - hasActiveSessionRef.current = false; + const completeUnlisten = await listen( + `claude-complete:${sessionId}`, + async (event: any) => { + console.log( + "[ClaudeCodeSession] Received claude-complete on reconnect:", + event.payload + ); + if (isMountedRef.current) { + setIsLoading(false); + hasActiveSessionRef.current = false; + } } - }); + ); unlistenRefs.current = [outputUnlisten, errorUnlisten, completeUnlisten]; - + // Mark as loading to show the session is active if (isMountedRef.current) { setIsLoading(true); @@ -488,8 +517,14 @@ export const ClaudeCodeSession: React.FC = ({ // Project path selection handled by parent tab controls const handleSendPrompt = async (prompt: string, model: "sonnet" | "opus") => { - console.log('[ClaudeCodeSession] handleSendPrompt called with:', { prompt, model, projectPath, claudeSessionId, effectiveSession }); - + console.log("[ClaudeCodeSession] handleSendPrompt called with:", { + prompt, + model, + projectPath, + claudeSessionId, + effectiveSession, + }); + if (!projectPath) { setError("Please select a project directory first"); return; @@ -500,9 +535,9 @@ export const ClaudeCodeSession: React.FC = ({ const newPrompt = { id: `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`, prompt, - model + model, }; - setQueuedPrompts(prev => [...prev, newPrompt]); + setQueuedPrompts((prev) => [...prev, newPrompt]); return; } @@ -510,21 +545,21 @@ export const ClaudeCodeSession: React.FC = ({ setIsLoading(true); setError(null); hasActiveSessionRef.current = true; - + // For resuming sessions, ensure we have the session ID if (effectiveSession && !claudeSessionId) { setClaudeSessionId(effectiveSession.id); } - + // 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.forEach((unlisten) => unlisten()); unlistenRefs.current = []; - + // Mark as setting up listeners isListeningRef.current = true; - + // -------------------------------------------------------------------- // 1️⃣ Event Listener Setup Strategy // -------------------------------------------------------------------- @@ -538,79 +573,114 @@ export const ClaudeCodeSession: React.FC = ({ // generic ones to prevent duplicate handling. // -------------------------------------------------------------------- - console.log('[ClaudeCodeSession] Setting up generic event listeners first'); + console.log( + "[ClaudeCodeSession] Setting up generic event listeners first" + ); - let currentSessionId: string | null = claudeSessionId || effectiveSession?.id || null; + 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: any) => { - handleStreamMessage(evt.payload); - }); - - const specificErrorUnlisten = await listen(`claude-error:${sid}`, (evt: any) => { - console.error('Claude error (scoped):', evt.payload); - setError(evt.payload); - }); + console.log( + "[ClaudeCodeSession] Attaching session-specific listeners for", + sid + ); + + const specificOutputUnlisten = await listen( + `claude-output:${sid}`, + (evt: any) => { + handleStreamMessage(evt.payload); + } + ); - const specificCompleteUnlisten = await listen(`claude-complete:${sid}`, (evt: any) => { - console.log('[ClaudeCodeSession] Received claude-complete (scoped):', evt.payload); - processComplete(evt.payload); - }); + const specificErrorUnlisten = await listen( + `claude-error:${sid}`, + (evt: any) => { + console.error("Claude error (scoped):", evt.payload); + setError(evt.payload); + } + ); + + const specificCompleteUnlisten = await listen( + `claude-complete:${sid}`, + (evt: any) => { + 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]; + unlistenRefs.current = [ + specificOutputUnlisten, + specificErrorUnlisten, + specificCompleteUnlisten, + ]; }; // Generic listeners (catch-all) - const genericOutputUnlisten = await listen('claude-output', async (event: any) => { - handleStreamMessage(event.payload); + const genericOutputUnlisten = await listen( + "claude-output", + async (event: any) => { + handleStreamMessage(event.payload); - // Attempt to extract session_id on the fly (for the very first init) - try { - 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 }); - - // Save session data for restoration - SessionPersistenceService.saveSession( - msg.session_id, - projectId, - projectPath, - messages.length + // Attempt to extract session_id on the fly (for the very first init) + try { + 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, + }); + + // Save session data for restoration + SessionPersistenceService.saveSession( + msg.session_id, + projectId, + projectPath, + messages.length + ); + } - // Switch to session-specific listeners - await attachSessionSpecificListeners(msg.session_id); + // Switch to session-specific listeners + await attachSessionSpecificListeners(msg.session_id); + } } + } catch { + /* ignore parse errors */ } - } catch { - /* ignore parse errors */ } - }); + ); // Helper to process any JSONL stream message string or object function handleStreamMessage(payload: string | ClaudeStreamMessage) { try { // Don't process if component unmounted if (!isMountedRef.current) return; - + let message: ClaudeStreamMessage; let rawPayload: string; - - if (typeof payload === 'string') { + + if (typeof payload === "string") { // Tauri mode: payload is a JSON string rawPayload = payload; message = JSON.parse(payload) as ClaudeStreamMessage; @@ -619,27 +689,36 @@ export const ClaudeCodeSession: React.FC = ({ message = payload; rawPayload = JSON.stringify(payload); } - - console.log('[ClaudeCodeSession] handleStreamMessage - message type:', message.type); + + console.log( + "[ClaudeCodeSession] handleStreamMessage - message type:", + message.type + ); // Store raw JSONL setRawJsonlOutput((prev) => [...prev, rawPayload]); // Track enhanced tool execution - if (message.type === 'assistant' && message.message?.content) { - const toolUses = message.message.content.filter((c: any) => c.type === 'tool_use'); + if (message.type === "assistant" && message.message?.content) { + const toolUses = message.message.content.filter( + (c: any) => c.type === "tool_use" + ); toolUses.forEach((toolUse: any) => { // Increment tools executed counter sessionMetrics.current.toolsExecuted += 1; sessionMetrics.current.lastActivityTime = Date.now(); // Track file operations - const toolName = toolUse.name?.toLowerCase() || ''; - if (toolName.includes('create') || toolName.includes('write')) { + const toolName = toolUse.name?.toLowerCase() || ""; + if (toolName.includes("create") || toolName.includes("write")) { sessionMetrics.current.filesCreated += 1; - } else if (toolName.includes('edit') || toolName.includes('multiedit') || toolName.includes('search_replace')) { + } else if ( + toolName.includes("edit") || + toolName.includes("multiedit") || + toolName.includes("search_replace") + ) { sessionMetrics.current.filesModified += 1; - } else if (toolName.includes('delete')) { + } else if (toolName.includes("delete")) { sessionMetrics.current.filesDeleted += 1; } @@ -649,8 +728,10 @@ export const ClaudeCodeSession: React.FC = ({ } // Track tool results - if (message.type === 'user' && message.message?.content) { - const toolResults = message.message.content.filter((c: any) => c.type === 'tool_result'); + if (message.type === "user" && message.message?.content) { + const toolResults = message.message.content.filter( + (c: any) => c.type === "tool_result" + ); toolResults.forEach((result: any) => { const isError = result.is_error || false; // Note: We don't have execution time here, but we can track success/failure @@ -659,42 +740,47 @@ export const ClaudeCodeSession: React.FC = ({ sessionMetrics.current.errorsEncountered += 1; trackEvent.enhancedError({ - error_type: 'tool_execution', - error_code: 'tool_failed', + error_type: "tool_execution", + error_code: "tool_failed", error_message: result.content, context: `Tool execution failed`, - user_action_before_error: 'executing_tool', + user_action_before_error: "executing_tool", recovery_attempted: false, recovery_successful: false, error_frequency: 1, - stack_trace_hash: undefined + stack_trace_hash: undefined, }); } }); } // Track code blocks generated - if (message.type === 'assistant' && message.message?.content) { - const codeBlocks = message.message.content.filter((c: any) => - c.type === 'text' && c.text?.includes('```') + if (message.type === "assistant" && message.message?.content) { + const codeBlocks = message.message.content.filter( + (c: any) => c.type === "text" && c.text?.includes("```") ); if (codeBlocks.length > 0) { // Count code blocks in text content codeBlocks.forEach((block: any) => { const matches = (block.text.match(/```/g) || []).length; - sessionMetrics.current.codeBlocksGenerated += Math.floor(matches / 2); + sessionMetrics.current.codeBlocksGenerated += Math.floor( + matches / 2 + ); }); } } // Track errors in system messages - if (message.type === 'system' && (message.subtype === 'error' || message.error)) { + if ( + message.type === "system" && + (message.subtype === "error" || message.error) + ) { sessionMetrics.current.errorsEncountered += 1; } setMessages((prev) => [...prev, message]); } catch (err) { - console.error('Failed to parse message:', err, payload); + console.error("Failed to parse message:", err, payload); } } @@ -703,31 +789,36 @@ export const ClaudeCodeSession: React.FC = ({ setIsLoading(false); hasActiveSessionRef.current = false; isListeningRef.current = false; // Reset listening state - + // Track enhanced session stopped metrics when session completes if (effectiveSession && claudeSessionId) { - const sessionStartTimeValue = messages.length > 0 ? messages[0].timestamp || Date.now() : Date.now(); + const sessionStartTimeValue = + messages.length > 0 + ? messages[0].timestamp || Date.now() + : Date.now(); const duration = Date.now() - sessionStartTimeValue; const metrics = sessionMetrics.current; - const timeToFirstMessage = metrics.firstMessageTime - ? metrics.firstMessageTime - sessionStartTime.current + const timeToFirstMessage = metrics.firstMessageTime + ? metrics.firstMessageTime - sessionStartTime.current : undefined; const idleTime = Date.now() - metrics.lastActivityTime; - const avgResponseTime = metrics.toolExecutionTimes.length > 0 - ? metrics.toolExecutionTimes.reduce((a, b) => a + b, 0) / metrics.toolExecutionTimes.length - : undefined; - + const avgResponseTime = + metrics.toolExecutionTimes.length > 0 + ? metrics.toolExecutionTimes.reduce((a, b) => a + b, 0) / + metrics.toolExecutionTimes.length + : undefined; + trackEvent.enhancedSessionStopped({ // Basic metrics duration_ms: duration, messages_count: messages.length, - reason: success ? 'completed' : 'error', - + reason: success ? "completed" : "error", + // Timing metrics time_to_first_message_ms: timeToFirstMessage, average_response_time_ms: avgResponseTime, idle_time_ms: idleTime, - + // Interaction metrics prompts_sent: metrics.promptsSent, tools_executed: metrics.toolsExecuted, @@ -735,28 +826,29 @@ export const ClaudeCodeSession: React.FC = ({ files_created: metrics.filesCreated, files_modified: metrics.filesModified, files_deleted: metrics.filesDeleted, - + // Content metrics total_tokens_used: totalTokens, code_blocks_generated: metrics.codeBlocksGenerated, errors_encountered: metrics.errorsEncountered, - + // Session context - model: metrics.modelChanges.length > 0 - ? metrics.modelChanges[metrics.modelChanges.length - 1].to - : 'sonnet', + model: + metrics.modelChanges.length > 0 + ? metrics.modelChanges[metrics.modelChanges.length - 1].to + : "sonnet", has_checkpoints: metrics.checkpointCount > 0, checkpoint_count: metrics.checkpointCount, was_resumed: metrics.wasResumed, - + // Agent context (if applicable) agent_type: undefined, // TODO: Pass from agent execution agent_name: undefined, // TODO: Pass from agent execution agent_success: success, - + // Stop context - stop_source: 'completed', - final_state: success ? 'success' : 'failed', + stop_source: "completed", + final_state: success ? "success" : "failed", has_pending_prompts: queuedPrompts.length > 0, pending_prompts_count: queuedPrompts.length, }); @@ -781,7 +873,7 @@ export const ClaudeCodeSession: React.FC = ({ setTimelineVersion((v) => v + 1); } } catch (err) { - console.error('Failed to check auto checkpoint:', err); + console.error("Failed to check auto checkpoint:", err); } } @@ -789,7 +881,7 @@ export const ClaudeCodeSession: React.FC = ({ if (queuedPromptsRef.current.length > 0) { const [nextPrompt, ...remainingPrompts] = queuedPromptsRef.current; setQueuedPrompts(remainingPrompts); - + // Small delay to ensure UI updates setTimeout(() => { handleSendPrompt(nextPrompt.prompt, nextPrompt.model); @@ -797,18 +889,31 @@ export const ClaudeCodeSession: React.FC = ({ } }; - const genericErrorUnlisten = await listen('claude-error', (evt: any) => { - console.error('Claude error:', evt.payload); - setError(evt.payload); - }); + const genericErrorUnlisten = await listen( + "claude-error", + (evt: any) => { + console.error("Claude error:", evt.payload); + setError(evt.payload); + } + ); - const genericCompleteUnlisten = await listen('claude-complete', (evt: any) => { - console.log('[ClaudeCodeSession] Received claude-complete (generic):', evt.payload); - processComplete(evt.payload); - }); + const genericCompleteUnlisten = await listen( + "claude-complete", + (evt: any) => { + 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]; + unlistenRefs.current = [ + genericOutputUnlisten, + genericErrorUnlisten, + genericCompleteUnlisten, + ]; // -------------------------------------------------------------------- // 2️⃣ Auto-checkpoint logic moved after listener setup (unchanged) @@ -821,63 +926,87 @@ export const ClaudeCodeSession: React.FC = ({ content: [ { type: "text", - text: prompt - } - ] - } + text: prompt, + }, + ], + }, }; - setMessages(prev => [...prev, userMessage]); - + setMessages((prev) => [...prev, userMessage]); + // Update session metrics sessionMetrics.current.promptsSent += 1; sessionMetrics.current.lastActivityTime = Date.now(); if (!sessionMetrics.current.firstMessageTime) { sessionMetrics.current.firstMessageTime = Date.now(); } - + // Track model changes - const lastModel = sessionMetrics.current.modelChanges.length > 0 - ? sessionMetrics.current.modelChanges[sessionMetrics.current.modelChanges.length - 1].to - : (sessionMetrics.current.wasResumed ? 'sonnet' : model); // Default to sonnet if resumed - + const lastModel = + sessionMetrics.current.modelChanges.length > 0 + ? sessionMetrics.current.modelChanges[ + sessionMetrics.current.modelChanges.length - 1 + ].to + : sessionMetrics.current.wasResumed + ? "sonnet" + : model; // Default to sonnet if resumed + if (lastModel !== model) { sessionMetrics.current.modelChanges.push({ from: lastModel, to: model, - timestamp: Date.now() + timestamp: Date.now(), }); } - + // Track enhanced prompt submission const codeBlockMatches = prompt.match(/```[\s\S]*?```/g) || []; const hasCode = codeBlockMatches.length > 0; - const conversationDepth = messages.filter(m => m.user_message).length; - const sessionAge = sessionStartTime.current ? Date.now() - sessionStartTime.current : 0; - const wordCount = prompt.split(/\s+/).filter(word => word.length > 0).length; - + const conversationDepth = messages.filter((m) => m.user_message).length; + const sessionAge = sessionStartTime.current + ? Date.now() - sessionStartTime.current + : 0; + const wordCount = prompt + .split(/\s+/) + .filter((word) => word.length > 0).length; + trackEvent.enhancedPromptSubmitted({ prompt_length: prompt.length, model: model, has_attachments: false, // TODO: Add attachment support when implemented - source: 'keyboard', // TODO: Track actual source (keyboard vs button) + source: "keyboard", // TODO: Track actual source (keyboard vs button) word_count: wordCount, conversation_depth: conversationDepth, - prompt_complexity: wordCount < 20 ? 'simple' : wordCount < 100 ? 'moderate' : 'complex', + prompt_complexity: + wordCount < 20 + ? "simple" + : wordCount < 100 + ? "moderate" + : "complex", contains_code: hasCode, - language_detected: hasCode ? codeBlockMatches?.[0]?.match(/```(\w+)/)?.[1] : undefined, - session_age_ms: sessionAge + language_detected: hasCode + ? codeBlockMatches?.[0]?.match(/```(\w+)/)?.[1] + : undefined, + session_age_ms: sessionAge, }); // Execute the appropriate command if (effectiveSession && !isFirstPrompt) { - console.log('[ClaudeCodeSession] Resuming session:', effectiveSession.id); + console.log( + "[ClaudeCodeSession] Resuming session:", + effectiveSession.id + ); trackEvent.sessionResumed(effectiveSession.id); trackEvent.modelSelected(model); - await api.resumeClaudeCode(projectPath, effectiveSession.id, prompt, model); + await api.resumeClaudeCode( + projectPath, + effectiveSession.id, + prompt, + model + ); } else { - console.log('[ClaudeCodeSession] Starting new session'); + console.log("[ClaudeCodeSession] Starting new session"); setIsFirstPrompt(false); - trackEvent.sessionCreated(model, 'prompt_input'); + trackEvent.sessionCreated(model, "prompt_input"); trackEvent.modelSelected(model); await api.executeClaudeCode(projectPath, prompt, model); } @@ -891,7 +1020,7 @@ export const ClaudeCodeSession: React.FC = ({ }; const handleCopyAsJsonl = async () => { - const jsonl = rawJsonlOutput.join('\n'); + const jsonl = rawJsonlOutput.join("\n"); await navigator.clipboard.writeText(jsonl); setCopyPopoverOpen(false); }; @@ -905,22 +1034,27 @@ export const ClaudeCodeSession: React.FC = ({ 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`; + 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`; + 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)); + 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`; + markdown += `\`\`\`json\n${JSON.stringify( + content.input, + null, + 2 + )}\n\`\`\`\n\n`; } } if (msg.message.usage) { @@ -930,22 +1064,25 @@ export const ClaudeCodeSession: React.FC = ({ 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)); + 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') { + let contentText = ""; + if (typeof content.content === "string") { contentText = content.content; - } else if (content.content && typeof content.content === 'object') { + } 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'); + .map((c: any) => + typeof c === "string" ? c : c.text || JSON.stringify(c) + ) + .join("\n"); } else { contentText = JSON.stringify(content.content, null, 2); } @@ -974,7 +1111,7 @@ export const ClaudeCodeSession: React.FC = ({ // Ensure timeline reloads to highlight current checkpoint setTimelineVersion((v) => v + 1); }; - + const handleCheckpointCreated = () => { // Update checkpoint count in session metrics sessionMetrics.current.checkpointCount += 1; @@ -982,35 +1119,38 @@ export const ClaudeCodeSession: React.FC = ({ const handleCancelExecution = async () => { if (!claudeSessionId || !isLoading) return; - + try { - const sessionStartTime = messages.length > 0 ? messages[0].timestamp || Date.now() : Date.now(); + const sessionStartTime = + messages.length > 0 ? messages[0].timestamp || Date.now() : Date.now(); const duration = Date.now() - sessionStartTime; - + await api.cancelClaudeExecution(claudeSessionId); - + // Calculate metrics for enhanced analytics const metrics = sessionMetrics.current; - const timeToFirstMessage = metrics.firstMessageTime - ? metrics.firstMessageTime - sessionStartTime.current + const timeToFirstMessage = metrics.firstMessageTime + ? metrics.firstMessageTime - sessionStartTime.current : undefined; const idleTime = Date.now() - metrics.lastActivityTime; - const avgResponseTime = metrics.toolExecutionTimes.length > 0 - ? metrics.toolExecutionTimes.reduce((a, b) => a + b, 0) / metrics.toolExecutionTimes.length - : undefined; - + const avgResponseTime = + metrics.toolExecutionTimes.length > 0 + ? metrics.toolExecutionTimes.reduce((a, b) => a + b, 0) / + metrics.toolExecutionTimes.length + : undefined; + // Track enhanced session stopped trackEvent.enhancedSessionStopped({ // Basic metrics duration_ms: duration, messages_count: messages.length, - reason: 'user_stopped', - + reason: "user_stopped", + // Timing metrics time_to_first_message_ms: timeToFirstMessage, average_response_time_ms: avgResponseTime, idle_time_ms: idleTime, - + // Interaction metrics prompts_sent: metrics.promptsSent, tools_executed: metrics.toolsExecuted, @@ -1018,70 +1158,73 @@ export const ClaudeCodeSession: React.FC = ({ files_created: metrics.filesCreated, files_modified: metrics.filesModified, files_deleted: metrics.filesDeleted, - + // Content metrics total_tokens_used: totalTokens, code_blocks_generated: metrics.codeBlocksGenerated, errors_encountered: metrics.errorsEncountered, - + // Session context - model: metrics.modelChanges.length > 0 - ? metrics.modelChanges[metrics.modelChanges.length - 1].to - : 'sonnet', // Default to sonnet + model: + metrics.modelChanges.length > 0 + ? metrics.modelChanges[metrics.modelChanges.length - 1].to + : "sonnet", // Default to sonnet has_checkpoints: metrics.checkpointCount > 0, checkpoint_count: metrics.checkpointCount, was_resumed: metrics.wasResumed, - + // Agent context (if applicable) agent_type: undefined, // TODO: Pass from agent execution agent_name: undefined, // TODO: Pass from agent execution agent_success: undefined, // TODO: Pass from agent execution - + // Stop context - stop_source: 'user_button', - final_state: 'cancelled', + stop_source: "user_button", + final_state: "cancelled", has_pending_prompts: queuedPrompts.length > 0, pending_prompts_count: queuedPrompts.length, }); - + // Clean up listeners - unlistenRefs.current.forEach(unlisten => unlisten()); + unlistenRefs.current.forEach((unlisten) => unlisten()); unlistenRefs.current = []; - + // 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() + timestamp: new Date().toISOString(), }; - setMessages(prev => [...prev, cancelMessage]); + setMessages((prev) => [...prev, cancelMessage]); } catch (err) { console.error("Failed to cancel execution:", err); - + // Even if backend fails, we should update UI to reflect stopped state // Add error message but still stop the UI loading state const errorMessage: ClaudeStreamMessage = { type: "system", subtype: "error", - result: `Failed to cancel execution: ${err instanceof Error ? err.message : 'Unknown error'}. The process may still be running in the background.`, - timestamp: new Date().toISOString() + result: `Failed to cancel execution: ${ + err instanceof Error ? err.message : "Unknown error" + }. The process may still be running in the background.`, + timestamp: new Date().toISOString(), }; - setMessages(prev => [...prev, errorMessage]); - + setMessages((prev) => [...prev, errorMessage]); + // Clean up listeners anyway - unlistenRefs.current.forEach(unlisten => unlisten()); + unlistenRefs.current.forEach((unlisten) => unlisten()); unlistenRefs.current = []; - + // Reset states to allow user to continue setIsLoading(false); hasActiveSessionRef.current = false; @@ -1107,13 +1250,16 @@ export const ClaudeCodeSession: React.FC = ({ }; const handleConfirmFork = async () => { - if (!forkCheckpointId || !forkSessionName.trim() || !effectiveSession) return; - + if (!forkCheckpointId || !forkSessionName.trim() || !effectiveSession) + return; + try { setIsLoading(true); setError(null); - - const newSessionId = `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; + + const newSessionId = `${Date.now()}-${Math.random() + .toString(36) + .substr(2, 9)}`; await api.forkFromCheckpoint( forkCheckpointId, effectiveSession.id, @@ -1122,11 +1268,11 @@ export const ClaudeCodeSession: React.FC = ({ 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(""); @@ -1153,7 +1299,7 @@ export const ClaudeCodeSession: React.FC = ({ }; const handlePreviewUrlChange = (url: string) => { - console.log('[ClaudeCodeSession] Preview URL changed to:', url); + console.log("[ClaudeCodeSession] Preview URL changed to:", url); setPreviewUrl(url); }; @@ -1168,50 +1314,57 @@ export const ClaudeCodeSession: React.FC = ({ // Cleanup event listeners and track mount state useEffect(() => { isMountedRef.current = true; - + return () => { - console.log('[ClaudeCodeSession] Component unmounting, cleaning up listeners'); + console.log( + "[ClaudeCodeSession] Component unmounting, cleaning up listeners" + ); isMountedRef.current = false; isListeningRef.current = false; - + // Track session completion with engagement metrics if (effectiveSession) { trackEvent.sessionCompleted(); - + // Track session engagement - const sessionDuration = sessionStartTime.current ? Date.now() - sessionStartTime.current : 0; - const messageCount = messages.filter(m => m.user_message).length; + const sessionDuration = sessionStartTime.current + ? Date.now() - sessionStartTime.current + : 0; + const messageCount = messages.filter((m) => m.user_message).length; const toolsUsed = new Set(); - messages.forEach(msg => { - if (msg.type === 'assistant' && msg.message?.content) { - const tools = msg.message.content.filter((c: any) => c.type === 'tool_use'); + messages.forEach((msg) => { + if (msg.type === "assistant" && msg.message?.content) { + const tools = msg.message.content.filter( + (c: any) => c.type === "tool_use" + ); tools.forEach((tool: any) => toolsUsed.add(tool.name)); } }); - + // Calculate engagement score (0-100) - const engagementScore = Math.min(100, - (messageCount * 10) + - (toolsUsed.size * 5) + - (sessionDuration > 300000 ? 20 : sessionDuration / 15000) // 5+ min session gets 20 points + const engagementScore = Math.min( + 100, + messageCount * 10 + + toolsUsed.size * 5 + + (sessionDuration > 300000 ? 20 : sessionDuration / 15000) // 5+ min session gets 20 points ); - + trackEvent.sessionEngagement({ session_duration_ms: sessionDuration, messages_sent: messageCount, tools_used: Array.from(toolsUsed), files_modified: 0, // TODO: Track file modifications - engagement_score: Math.round(engagementScore) + engagement_score: Math.round(engagementScore), }); } - + // Clean up listeners - unlistenRefs.current.forEach(unlisten => unlisten()); + unlistenRefs.current.forEach((unlisten) => unlisten()); unlistenRefs.current = []; - + // Clear checkpoint manager when session ends if (effectiveSession) { - api.clearCheckpointManager(effectiveSession.id).catch(err => { + api.clearCheckpointManager(effectiveSession.id).catch((err) => { console.error("Failed to clear checkpoint manager:", err); }); } @@ -1223,14 +1376,14 @@ export const ClaudeCodeSession: React.FC = ({ ref={parentRef} className="flex-1 overflow-y-auto relative pb-20" style={{ - contain: 'strict', + contain: "strict", }} >
@@ -1250,8 +1403,8 @@ export const ClaudeCodeSession: React.FC = ({ top: virtualItem.start, }} > - @@ -1293,7 +1446,7 @@ export const ClaudeCodeSession: React.FC = ({ if (showPreview && isPreviewMaximized) { return ( - = ({
+ {/* Main Content Area */} +
+ {showPreview ? ( + // Split pane layout when preview is active + + {projectPathInput} + {messagesList} +
+ } + right={ + + } + initialSplit={splitPosition} + onSplitChange={setSplitPosition} + minLeftWidth={400} + minRightWidth={400} + className="h-full" + /> + ) : ( + // Original layout when no preview +
+ {projectPathInput} + {messagesList} + + {isLoading && messages.length === 0 && ( +
+
+
+ + {session + ? "Loading session history..." + : "Initializing Claude Code..."} + +
+
+ )} +
+ )} +
- {/* Main Content Area */} -
- {showPreview ? ( - // Split pane layout when preview is active - - {projectPathInput} - {messagesList} -
- } - right={ - - } - initialSplit={splitPosition} - onSplitChange={setSplitPosition} - minLeftWidth={400} - minRightWidth={400} - className="h-full" - /> - ) : ( - // Original layout when no preview -
- {projectPathInput} - {messagesList} - - {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} +

+
+ + + +
+ ))}
-
+ )} -
- )} -
+ - {/* Floating Prompt Input - Always visible */} - - {/* Queued Prompts Display */} - - {queuedPrompts.length > 0 && ( + {/* Navigation Arrows - positioned above prompt bar with spacing */} + {displayableMessages.length > 5 && ( -
-
-
- Queued Prompts ({queuedPrompts.length}) -
- - + + + - - -
- {!queuedPromptsCollapsed && queuedPrompts.map((queuedPrompt, index) => ( + + + + +
+ -
-
- #{index + 1} - - {queuedPrompt.model === "opus" ? "Opus" : "Sonnet"} - -
-

{queuedPrompt.prompt}

-
- { + // Use the improved scrolling method for manual scroll to bottom + if (displayableMessages.length > 0) { + const scrollElement = parentRef.current; + if (scrollElement) { + // First, scroll using virtualizer to get close to the bottom + rowVirtualizer.scrollToIndex( + displayableMessages.length - 1, + { align: "end", behavior: "auto" } + ); + + // Then use direct scroll to ensure we reach the absolute bottom + requestAnimationFrame(() => { + scrollElement.scrollTo({ + top: scrollElement.scrollHeight, + behavior: "smooth", + }); + }); + } + } + }} + className="px-3 py-2 hover:bg-accent rounded-none" > - - + +
- ))} +
)} - - {/* Navigation Arrows - positioned above prompt bar with spacing */} - {displayableMessages.length > 5 && ( - -
- - - - - -
- - - + + + )} + {messages.length > 0 && ( + + + + + } - }} - className="px-3 py-2 hover:bg-accent rounded-none" - > - - - - -
- - )} - -
- - {effectiveSession && ( - + content={ +
+ + +
+ } + open={copyPopoverOpen} + onOpenChange={setCopyPopoverOpen} + side="top" + align="end" + /> + )} + = ({ - )} - {messages.length > 0 && ( - - - - -
- } - content={ -
- - -
- } - open={copyPopoverOpen} - onOpenChange={setCopyPopoverOpen} - side="top" - align="end" - /> - )} - + + } + /> +
+ + {/* Token Counter - positioned under the Send button */} + {totalTokens > 0 && ( +
+
+
- +
+ + + {totalTokens.toLocaleString()} + + tokens +
- - - } - /> -
- - {/* Token Counter - positioned under the Send button */} - {totalTokens > 0 && ( -
-
-
- -
- - {totalTokens.toLocaleString()} - tokens -
-
+
-
- )} - + )} + - {/* Timeline */} - - {showTimeline && effectiveSession && ( - -
- {/* Timeline Header */} -
-

Session Timeline

- -
- - {/* Timeline Content */} -
- -
-
-
- )} -
-
+ {/* Timeline */} + + {showTimeline && effectiveSession && ( + +
+ {/* Timeline Header */} +
+

Session Timeline

+ +
- {/* Fork Dialog */} - - - - Fork Session - - Create a new session branch from the selected checkpoint. - - - -
-
- - setForkSessionName(e.target.value)} - onKeyDown={(e) => { - if (e.key === "Enter" && !isLoading) { - if (e.nativeEvent.isComposing || isIMEComposingRef.current) { - return; - } - handleConfirmFork(); - } - }} - onCompositionStart={handleCompositionStart} - onCompositionEnd={handleCompositionEnd} - /> -
-
- - - - - -
-
- - {/* Settings Dialog */} - {showSettings && effectiveSession && ( - - - setShowSettings(false)} - /> - - - )} + {/* Timeline Content */} +
+ +
+
+
+ )} +
+
- {/* Slash Commands Settings Dialog */} - {showSlashCommandsSettings && ( - - + {/* Fork Dialog */} + + - Slash Commands + Fork Session - Manage project-specific slash commands for {projectPath} + Create a new session branch from the selected checkpoint. -
- + +
+
+ + setForkSessionName(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter" && !isLoading) { + if ( + e.nativeEvent.isComposing || + isIMEComposingRef.current + ) { + return; + } + handleConfirmFork(); + } + }} + onCompositionStart={handleCompositionStart} + onCompositionEnd={handleCompositionEnd} + /> +
+ + + + +
- )} + + {/* Settings Dialog */} + {showSettings && effectiveSession && ( + + + setShowSettings(false)} + /> + + + )} + + {/* Slash Commands Settings Dialog */} + {showSlashCommandsSettings && ( + + + + Slash Commands + + Manage project-specific slash commands for {projectPath} + + +
+ +
+
+
+ )}
); diff --git a/src/components/EnvGroupSelector.tsx b/src/components/EnvGroupSelector.tsx new file mode 100644 index 00000000..797d91f9 --- /dev/null +++ b/src/components/EnvGroupSelector.tsx @@ -0,0 +1,154 @@ +import React, { useState, useEffect } from "react"; +import { Layers, ChevronUp, Check } from "lucide-react"; +import { cn } from "@/lib/utils"; +import { Button } from "@/components/ui/button"; +import { Popover } from "@/components/ui/popover"; +import { + Tooltip, + TooltipTrigger, + TooltipContent, +} from "@/components/ui/tooltip-modern"; +import { api, type ClaudeSettings, type EnvGroup } from "@/lib/api"; + +interface EnvGroupSelectorProps { + disabled?: boolean; +} + +/** + * Environment Group Selector Component + * Allows users to quickly switch between different environment variable groups + */ +export const EnvGroupSelector: React.FC = ({ + disabled = false, +}) => { + const [isOpen, setIsOpen] = useState(false); + const [envGroups, setEnvGroups] = useState>({}); + const [activeGroupId, setActiveGroupId] = useState("default"); + const [isLoading, setIsLoading] = useState(true); + + // Load environment groups on mount + useEffect(() => { + const loadEnvGroups = async () => { + try { + setIsLoading(true); + const settings = await api.getClaudeSettings(); + + if (settings.envGroups && typeof settings.envGroups === "object") { + setEnvGroups(settings.envGroups); + setActiveGroupId(settings.activeEnvGroup || "default"); + } else { + // Initialize with default group if none exist + const defaultGroup: EnvGroup = { + name: "Default", + variables: settings.env || {}, + }; + setEnvGroups({ default: defaultGroup }); + setActiveGroupId("default"); + } + } catch (error) { + console.error("Failed to load environment groups:", error); + } finally { + setIsLoading(false); + } + }; + + loadEnvGroups(); + }, []); + + const handleSelectGroup = async (groupId: string) => { + try { + setActiveGroupId(groupId); + setIsOpen(false); + + // Save the active group selection + const currentSettings = await api.getClaudeSettings(); + const updatedSettings: ClaudeSettings = { + ...currentSettings, + activeEnvGroup: groupId, + // Also update the env field for backward compatibility + env: envGroups[groupId]?.variables || {}, + }; + await api.saveClaudeSettings(updatedSettings); + } catch (error) { + console.error("Failed to save active environment group:", error); + } + }; + + const activeGroup = envGroups[activeGroupId]; + const groupCount = Object.keys(envGroups).length; + const varCount = activeGroup + ? Object.keys(activeGroup.variables || {}).length + : 0; + + // Don't render if still loading or no groups + if (isLoading) { + return null; + } + + return ( + + + + + +

+ Environment: {activeGroup?.name || "Default"} +

+

+ {varCount} variable{varCount !== 1 ? "s" : ""} • {groupCount}{" "} + group + {groupCount !== 1 ? "s" : ""} +

+
+ + } + content={ +
+
+ Environment Groups +
+ {Object.entries(envGroups).map(([groupId, group]) => ( + + ))} +
+ } + open={isOpen} + onOpenChange={setIsOpen} + align="start" + side="top" + /> + ); +}; diff --git a/src/components/FloatingPromptInput.tsx b/src/components/FloatingPromptInput.tsx index 1f042b2c..c43f1b03 100644 --- a/src/components/FloatingPromptInput.tsx +++ b/src/components/FloatingPromptInput.tsx @@ -12,30 +12,25 @@ import { Lightbulb, Cpu, Rocket, - } from "lucide-react"; import { cn } from "@/lib/utils"; import { Button } from "@/components/ui/button"; import { Popover } from "@/components/ui/popover"; import { Textarea } from "@/components/ui/textarea"; -import { TooltipProvider, TooltipSimple, Tooltip, TooltipTrigger, TooltipContent } from "@/components/ui/tooltip-modern"; +import { + TooltipProvider, + TooltipSimple, + Tooltip, + TooltipTrigger, + TooltipContent, +} from "@/components/ui/tooltip-modern"; import { FilePicker } from "./FilePicker"; import { SlashCommandPicker } from "./SlashCommandPicker"; import { ImagePreview } from "./ImagePreview"; +import { EnvGroupSelector } from "./EnvGroupSelector"; import { type FileEntry, type SlashCommand } from "@/lib/api"; -// Conditional import for Tauri webview window -let tauriGetCurrentWebviewWindow: any; -try { - if (typeof window !== 'undefined' && window.__TAURI__) { - tauriGetCurrentWebviewWindow = require("@tauri-apps/api/webviewWindow").getCurrentWebviewWindow; - } -} catch (e) { - console.log('[FloatingPromptInput] Tauri webview API not available, using web mode'); -} - -// Web-compatible replacement -const getCurrentWebviewWindow = tauriGetCurrentWebviewWindow || (() => ({ listen: () => Promise.resolve(() => {}) })); +import { getCurrentWebviewWindow } from "@tauri-apps/api/webviewWindow"; interface FloatingPromptInputProps { /** @@ -79,7 +74,12 @@ export interface FloatingPromptInputRef { /** * Thinking mode type definition */ -type ThinkingMode = "auto" | "think" | "think_hard" | "think_harder" | "ultrathink"; +type ThinkingMode = + | "auto" + | "think" + | "think_hard" + | "think_harder" + | "ultrathink"; /** * Thinking mode configuration @@ -103,7 +103,7 @@ const THINKING_MODES: ThinkingModeConfig[] = [ level: 0, icon: , color: "text-muted-foreground", - shortName: "A" + shortName: "A", }, { id: "think", @@ -113,7 +113,7 @@ const THINKING_MODES: ThinkingModeConfig[] = [ phrase: "think", icon: , color: "text-primary", - shortName: "T" + shortName: "T", }, { id: "think_hard", @@ -123,7 +123,7 @@ const THINKING_MODES: ThinkingModeConfig[] = [ phrase: "think hard", icon: , color: "text-primary", - shortName: "T+" + shortName: "T+", }, { id: "think_harder", @@ -133,7 +133,7 @@ const THINKING_MODES: ThinkingModeConfig[] = [ phrase: "think harder", icon: , color: "text-primary", - shortName: "T++" + shortName: "T++", }, { id: "ultrathink", @@ -143,19 +143,22 @@ const THINKING_MODES: ThinkingModeConfig[] = [ phrase: "ultrathink", icon: , color: "text-primary", - shortName: "Ultra" - } + shortName: "Ultra", + }, ]; /** * ThinkingModeIndicator component - Shows visual indicator bars for thinking level */ -const ThinkingModeIndicator: React.FC<{ level: number; color?: string }> = ({ level, color: _color }) => { +const ThinkingModeIndicator: React.FC<{ level: number; color?: string }> = ({ + level, + color: _color, +}) => { const getBarColor = (barIndex: number) => { if (barIndex > level) return "bg-muted"; return "bg-primary"; }; - + return (
{[1, 2, 3, 4].map((i) => ( @@ -188,7 +191,7 @@ const MODELS: Model[] = [ description: "Faster, efficient for most tasks", icon: , shortName: "S", - color: "text-primary" + color: "text-primary", }, { id: "opus", @@ -196,13 +199,13 @@ const MODELS: Model[] = [ description: "More capable, better for complex tasks", icon: , shortName: "O", - color: "text-primary" - } + color: "text-primary", + }, ]; /** * FloatingPromptInput component - Fixed position prompt input with model picker - * + * * @example * const promptRef = useRef(null); * , + ref: React.Ref ) => { const [prompt, setPrompt] = useState(""); - const [selectedModel, setSelectedModel] = useState<"sonnet" | "opus">(defaultModel); - const [selectedThinkingMode, setSelectedThinkingMode] = useState("auto"); + const [selectedModel, setSelectedModel] = useState<"sonnet" | "opus">( + defaultModel + ); + const [selectedThinkingMode, setSelectedThinkingMode] = + useState("auto"); const [isExpanded, setIsExpanded] = useState(false); const [modelPickerOpen, setModelPickerOpen] = useState(false); const [thinkingModePickerOpen, setThinkingModePickerOpen] = useState(false); @@ -249,26 +255,34 @@ const FloatingPromptInputInner = ( ref, () => ({ addImage: (imagePath: string) => { - setPrompt(currentPrompt => { + setPrompt((currentPrompt) => { const existingPaths = extractImagePaths(currentPrompt); if (existingPaths.includes(imagePath)) { return currentPrompt; // Image already added } // Wrap path in quotes if it contains spaces - const mention = imagePath.includes(' ') ? `@"${imagePath}"` : `@${imagePath}`; - const newPrompt = currentPrompt + (currentPrompt.endsWith(' ') || currentPrompt === '' ? '' : ' ') + mention + ' '; + const mention = imagePath.includes(" ") + ? `@"${imagePath}"` + : `@${imagePath}`; + const newPrompt = + currentPrompt + + (currentPrompt.endsWith(" ") || currentPrompt === "" ? "" : " ") + + mention + + " "; // Focus the textarea setTimeout(() => { - const target = isExpanded ? expandedTextareaRef.current : textareaRef.current; + const target = isExpanded + ? expandedTextareaRef.current + : textareaRef.current; target?.focus(); target?.setSelectionRange(newPrompt.length, newPrompt.length); }, 0); return newPrompt; }); - } + }, }), [isExpanded] ); @@ -276,81 +290,97 @@ const FloatingPromptInputInner = ( // Helper function to check if a file is an image const isImageFile = (path: string): boolean => { // Check if it's a data URL - if (path.startsWith('data:image/')) { + if (path.startsWith("data:image/")) { return true; } // Otherwise check file extension - const ext = path.split('.').pop()?.toLowerCase(); - return ['png', 'jpg', 'jpeg', 'gif', 'svg', 'webp', 'ico', 'bmp'].includes(ext || ''); + const ext = path.split(".").pop()?.toLowerCase(); + return ["png", "jpg", "jpeg", "gif", "svg", "webp", "ico", "bmp"].includes( + ext || "" + ); }; // Extract image paths from prompt text const extractImagePaths = (text: string): string[] => { - console.log('[extractImagePaths] Input text length:', text.length); - + console.log("[extractImagePaths] Input text length:", text.length); + // Updated regex to handle both quoted and unquoted paths // Pattern 1: @"path with spaces or data URLs" - quoted paths // Pattern 2: @path - unquoted paths (continues until @ or end) const quotedRegex = /@"([^"]+)"/g; const unquotedRegex = /@([^@\n\s]+)/g; - + const pathsSet = new Set(); // Use Set to ensure uniqueness - + // First, extract quoted paths (including data URLs) let matches = Array.from(text.matchAll(quotedRegex)); - console.log('[extractImagePaths] Quoted matches:', matches.length); - + console.log("[extractImagePaths] Quoted matches:", matches.length); + for (const match of matches) { const path = match[1]; // No need to trim, quotes preserve exact path - console.log('[extractImagePaths] Processing quoted path:', path.startsWith('data:') ? 'data URL' : path); - + console.log( + "[extractImagePaths] Processing quoted path:", + path.startsWith("data:") ? "data URL" : path + ); + // For data URLs, use as-is; for file paths, convert to absolute - const fullPath = path.startsWith('data:') - ? path - : (path.startsWith('/') ? path : (projectPath ? `${projectPath}/${path}` : path)); - + const fullPath = path.startsWith("data:") + ? path + : path.startsWith("/") + ? path + : projectPath + ? `${projectPath}/${path}` + : path; + if (isImageFile(fullPath)) { pathsSet.add(fullPath); } } - + // Remove quoted mentions from text to avoid double-matching - let textWithoutQuoted = text.replace(quotedRegex, ''); - + let textWithoutQuoted = text.replace(quotedRegex, ""); + // Then extract unquoted paths (typically file paths) matches = Array.from(textWithoutQuoted.matchAll(unquotedRegex)); - console.log('[extractImagePaths] Unquoted matches:', matches.length); - + console.log("[extractImagePaths] Unquoted matches:", matches.length); + for (const match of matches) { const path = match[1].trim(); // Skip if it looks like a data URL fragment (shouldn't happen with proper quoting) - if (path.includes('data:')) continue; - - console.log('[extractImagePaths] Processing unquoted path:', path); - + if (path.includes("data:")) continue; + + console.log("[extractImagePaths] Processing unquoted path:", path); + // Convert relative path to absolute if needed - const fullPath = path.startsWith('/') ? path : (projectPath ? `${projectPath}/${path}` : path); - + const fullPath = path.startsWith("/") + ? path + : projectPath + ? `${projectPath}/${path}` + : path; + if (isImageFile(fullPath)) { pathsSet.add(fullPath); } } const uniquePaths = Array.from(pathsSet); - console.log('[extractImagePaths] Final extracted paths (unique):', uniquePaths.length); + console.log( + "[extractImagePaths] Final extracted paths (unique):", + uniquePaths.length + ); return uniquePaths; }; // Update embedded images when prompt changes useEffect(() => { - console.log('[useEffect] Prompt changed:', prompt); + console.log("[useEffect] Prompt changed:", prompt); const imagePaths = extractImagePaths(prompt); - console.log('[useEffect] Setting embeddedImages to:', imagePaths); + console.log("[useEffect] Setting embeddedImages to:", imagePaths); setEmbeddedImages(imagePaths); - + // Auto-resize on prompt change (handles paste, programmatic changes, etc.) if (textareaRef.current && !isExpanded) { - textareaRef.current.style.height = 'auto'; + textareaRef.current.style.height = "auto"; const scrollHeight = textareaRef.current.scrollHeight; const newHeight = Math.min(Math.max(scrollHeight, 48), 240); setTextareaHeight(newHeight); @@ -371,57 +401,77 @@ const FloatingPromptInputInner = ( } const webview = getCurrentWebviewWindow(); - unlistenDragDropRef.current = await webview.onDragDropEvent((event: any) => { - if (event.payload.type === 'enter' || event.payload.type === 'over') { - setDragActive(true); - } else if (event.payload.type === 'leave') { - setDragActive(false); - } else if (event.payload.type === 'drop' && event.payload.paths) { - setDragActive(false); - - const currentTime = Date.now(); - if (currentTime - lastDropTime < 200) { - // This debounce is crucial to handle the storm of drop events - // that Tauri/OS can fire for a single user action. - return; - } - lastDropTime = currentTime; - - const droppedPaths = event.payload.paths as string[]; - const imagePaths = droppedPaths.filter(isImageFile); - - if (imagePaths.length > 0) { - setPrompt(currentPrompt => { - const existingPaths = extractImagePaths(currentPrompt); - const newPaths = imagePaths.filter(p => !existingPaths.includes(p)); - - if (newPaths.length === 0) { - return currentPrompt; // All dropped images are already in the prompt - } - - // Wrap paths with spaces in quotes for clarity - const mentionsToAdd = newPaths.map(p => { - // If path contains spaces, wrap in quotes - if (p.includes(' ')) { - return `@"${p}"`; + unlistenDragDropRef.current = await webview.onDragDropEvent( + (event: any) => { + if ( + event.payload.type === "enter" || + event.payload.type === "over" + ) { + setDragActive(true); + } else if (event.payload.type === "leave") { + setDragActive(false); + } else if (event.payload.type === "drop" && event.payload.paths) { + setDragActive(false); + + const currentTime = Date.now(); + if (currentTime - lastDropTime < 200) { + // This debounce is crucial to handle the storm of drop events + // that Tauri/OS can fire for a single user action. + return; + } + lastDropTime = currentTime; + + const droppedPaths = event.payload.paths as string[]; + const imagePaths = droppedPaths.filter(isImageFile); + + if (imagePaths.length > 0) { + setPrompt((currentPrompt) => { + const existingPaths = extractImagePaths(currentPrompt); + const newPaths = imagePaths.filter( + (p) => !existingPaths.includes(p) + ); + + if (newPaths.length === 0) { + return currentPrompt; // All dropped images are already in the prompt } - return `@${p}`; - }).join(' '); - const newPrompt = currentPrompt + (currentPrompt.endsWith(' ') || currentPrompt === '' ? '' : ' ') + mentionsToAdd + ' '; - - setTimeout(() => { - const target = isExpanded ? expandedTextareaRef.current : textareaRef.current; - target?.focus(); - target?.setSelectionRange(newPrompt.length, newPrompt.length); - }, 0); - - return newPrompt; - }); + + // Wrap paths with spaces in quotes for clarity + const mentionsToAdd = newPaths + .map((p) => { + // If path contains spaces, wrap in quotes + if (p.includes(" ")) { + return `@"${p}"`; + } + return `@${p}`; + }) + .join(" "); + const newPrompt = + currentPrompt + + (currentPrompt.endsWith(" ") || currentPrompt === "" + ? "" + : " ") + + mentionsToAdd + + " "; + + setTimeout(() => { + const target = isExpanded + ? expandedTextareaRef.current + : textareaRef.current; + target?.focus(); + target?.setSelectionRange( + newPrompt.length, + newPrompt.length + ); + }, 0); + + return newPrompt; + }); + } } } - }); + ); } catch (error) { - console.error('Failed to set up Tauri drag-drop listener:', error); + console.error("Failed to set up Tauri drag-drop listener:", error); } }; @@ -448,11 +498,11 @@ const FloatingPromptInputInner = ( const handleTextChange = (e: React.ChangeEvent) => { const newValue = e.target.value; const newCursorPosition = e.target.selectionStart || 0; - + // Auto-resize textarea based on content if (textareaRef.current && !isExpanded) { // Reset height to auto to get the actual scrollHeight - textareaRef.current.style.height = 'auto'; + textareaRef.current.style.height = "auto"; const scrollHeight = textareaRef.current.scrollHeight; // Set min height to 48px and max to 240px (about 10 lines) const newHeight = Math.min(Math.max(scrollHeight, 48), 240); @@ -461,13 +511,17 @@ const FloatingPromptInputInner = ( } // Check if / was just typed at the beginning of input or after whitespace - if (newValue.length > prompt.length && newValue[newCursorPosition - 1] === '/') { + if ( + newValue.length > prompt.length && + newValue[newCursorPosition - 1] === "/" + ) { // Check if it's at the start or after whitespace - const isStartOfCommand = newCursorPosition === 1 || + const isStartOfCommand = + newCursorPosition === 1 || (newCursorPosition > 1 && /\s/.test(newValue[newCursorPosition - 2])); - + if (isStartOfCommand) { - console.log('[FloatingPromptInput] / detected for slash command'); + console.log("[FloatingPromptInput] / detected for slash command"); setShowSlashCommandPicker(true); setSlashCommandQuery(""); setCursorPosition(newCursorPosition); @@ -475,8 +529,15 @@ const FloatingPromptInputInner = ( } // Check if @ was just typed - if (projectPath?.trim() && newValue.length > prompt.length && newValue[newCursorPosition - 1] === '@') { - console.log('[FloatingPromptInput] @ detected, projectPath:', projectPath); + if ( + projectPath?.trim() && + newValue.length > prompt.length && + newValue[newCursorPosition - 1] === "@" + ) { + console.log( + "[FloatingPromptInput] @ detected, projectPath:", + projectPath + ); setShowFilePicker(true); setFilePickerQuery(""); setCursorPosition(newCursorPosition); @@ -487,12 +548,12 @@ const FloatingPromptInputInner = ( // Find the / position before cursor let slashPosition = -1; for (let i = newCursorPosition - 1; i >= 0; i--) { - if (newValue[i] === '/') { + if (newValue[i] === "/") { slashPosition = i; break; } // Stop if we hit whitespace (new word) - if (newValue[i] === ' ' || newValue[i] === '\n') { + if (newValue[i] === " " || newValue[i] === "\n") { break; } } @@ -512,12 +573,12 @@ const FloatingPromptInputInner = ( // Find the @ position before cursor let atPosition = -1; for (let i = newCursorPosition - 1; i >= 0; i--) { - if (newValue[i] === '@') { + if (newValue[i] === "@") { atPosition = i; break; } // Stop if we hit whitespace (new word) - if (newValue[i] === ' ' || newValue[i] === '\n') { + if (newValue[i] === " " || newValue[i] === "\n") { break; } } @@ -541,19 +602,19 @@ const FloatingPromptInputInner = ( // Find the @ position before cursor let atPosition = -1; for (let i = cursorPosition - 1; i >= 0; i--) { - if (prompt[i] === '@') { + if (prompt[i] === "@") { atPosition = i; break; } // Stop if we hit whitespace (new word) - if (prompt[i] === ' ' || prompt[i] === '\n') { + if (prompt[i] === " " || prompt[i] === "\n") { break; } } if (atPosition === -1) { // @ not found, this shouldn't happen but handle gracefully - console.error('[FloatingPromptInput] @ position not found'); + console.error("[FloatingPromptInput] @ position not found"); return; } @@ -561,8 +622,8 @@ const FloatingPromptInputInner = ( const textarea = textareaRef.current; const beforeAt = prompt.substring(0, atPosition); const afterCursor = prompt.substring(cursorPosition); - const relativePath = entry.path.startsWith(projectPath || '') - ? entry.path.slice((projectPath || '').length + 1) + const relativePath = entry.path.startsWith(projectPath || "") + ? entry.path.slice((projectPath || "").length + 1) : entry.path; const newPrompt = `${beforeAt}@${relativePath} ${afterCursor}`; @@ -589,31 +650,33 @@ const FloatingPromptInputInner = ( }; const handleSlashCommandSelect = (command: SlashCommand) => { - const textarea = isExpanded ? expandedTextareaRef.current : textareaRef.current; + const textarea = isExpanded + ? expandedTextareaRef.current + : textareaRef.current; if (!textarea) return; // Find the / position before cursor let slashPosition = -1; for (let i = cursorPosition - 1; i >= 0; i--) { - if (prompt[i] === '/') { + if (prompt[i] === "/") { slashPosition = i; break; } // Stop if we hit whitespace (new word) - if (prompt[i] === ' ' || prompt[i] === '\n') { + if (prompt[i] === " " || prompt[i] === "\n") { break; } } if (slashPosition === -1) { - console.error('[FloatingPromptInput] / position not found'); + console.error("[FloatingPromptInput] / position not found"); return; } // Simply insert the command syntax const beforeSlash = prompt.substring(0, slashPosition); const afterCursor = prompt.substring(cursorPosition); - + if (command.accepts_arguments) { // Insert command with placeholder for arguments const newPrompt = `${beforeSlash}${command.full_command} `; @@ -624,7 +687,8 @@ const FloatingPromptInputInner = ( // Focus and position cursor after the command setTimeout(() => { textarea.focus(); - const newCursorPos = beforeSlash.length + command.full_command.length + 1; + const newCursorPos = + beforeSlash.length + command.full_command.length + 1; textarea.setSelectionRange(newCursorPos, newCursorPos); }, 0); } else { @@ -637,7 +701,8 @@ const FloatingPromptInputInner = ( // Focus and position cursor after the command setTimeout(() => { textarea.focus(); - const newCursorPos = beforeSlash.length + command.full_command.length + 1; + const newCursorPos = + beforeSlash.length + command.full_command.length + 1; textarea.setSelectionRange(newCursorPos, newCursorPos); }, 0); } @@ -648,7 +713,9 @@ const FloatingPromptInputInner = ( setSlashCommandQuery(""); // Return focus to textarea setTimeout(() => { - const textarea = isExpanded ? expandedTextareaRef.current : textareaRef.current; + const textarea = isExpanded + ? expandedTextareaRef.current + : textareaRef.current; textarea?.focus(); }, 0); }; @@ -679,12 +746,14 @@ const FloatingPromptInputInner = ( } const key = nativeEvent.key; - if (key === 'Process' || key === 'Unidentified') { + if (key === "Process" || key === "Unidentified") { return true; } const keyboardEvent = nativeEvent as unknown as KeyboardEvent; - const keyCode = keyboardEvent.keyCode ?? (keyboardEvent as unknown as { which?: number }).which; + const keyCode = + keyboardEvent.keyCode ?? + (keyboardEvent as unknown as { which?: number }).which; if (keyCode === 229) { return true; } @@ -701,7 +770,9 @@ const FloatingPromptInputInner = ( let finalPrompt = prompt.trim(); // Append thinking phrase if not auto mode - const thinkingMode = THINKING_MODES.find(m => m.id === selectedThinkingMode); + const thinkingMode = THINKING_MODES.find( + (m) => m.id === selectedThinkingMode + ); if (thinkingMode && thinkingMode.phrase) { finalPrompt = `${finalPrompt}.\n\n${thinkingMode.phrase}.`; } @@ -714,14 +785,14 @@ const FloatingPromptInputInner = ( }; const handleKeyDown = (e: React.KeyboardEvent) => { - if (showFilePicker && e.key === 'Escape') { + if (showFilePicker && e.key === "Escape") { e.preventDefault(); setShowFilePicker(false); setFilePickerQuery(""); return; } - if (showSlashCommandPicker && e.key === 'Escape') { + if (showSlashCommandPicker && e.key === "Escape") { e.preventDefault(); setShowSlashCommandPicker(false); setSlashCommandQuery(""); @@ -729,7 +800,7 @@ const FloatingPromptInputInner = ( } // Add keyboard shortcut for expanding - if (e.key === 'e' && (e.ctrlKey || e.metaKey) && e.shiftKey) { + if (e.key === "e" && (e.ctrlKey || e.metaKey) && e.shiftKey) { e.preventDefault(); setIsExpanded(true); return; @@ -755,9 +826,9 @@ const FloatingPromptInputInner = ( if (!items) return; for (const item of items) { - if (item.type.startsWith('image/')) { + if (item.type.startsWith("image/")) { e.preventDefault(); - + // Get the image blob const blob = item.getAsFile(); if (!blob) continue; @@ -767,16 +838,24 @@ const FloatingPromptInputInner = ( const reader = new FileReader(); reader.onload = () => { const base64Data = reader.result as string; - + // Add the base64 data URL directly to the prompt - setPrompt(currentPrompt => { + setPrompt((currentPrompt) => { // Use the data URL directly as the image reference const mention = `@"${base64Data}"`; - const newPrompt = currentPrompt + (currentPrompt.endsWith(' ') || currentPrompt === '' ? '' : ' ') + mention + ' '; - + const newPrompt = + currentPrompt + + (currentPrompt.endsWith(" ") || currentPrompt === "" + ? "" + : " ") + + mention + + " "; + // Focus the textarea and move cursor to end setTimeout(() => { - const target = isExpanded ? expandedTextareaRef.current : textareaRef.current; + const target = isExpanded + ? expandedTextareaRef.current + : textareaRef.current; target?.focus(); target?.setSelectionRange(newPrompt.length, newPrompt.length); }, 0); @@ -784,10 +863,10 @@ const FloatingPromptInputInner = ( return newPrompt; }); }; - + reader.readAsDataURL(blob); } catch (error) { - console.error('Failed to paste image:', error); + console.error("Failed to paste image:", error); } } } @@ -810,284 +889,322 @@ const FloatingPromptInputInner = ( const handleRemoveImage = (index: number) => { // Remove the corresponding @mention from the prompt const imagePath = embeddedImages[index]; - + // For data URLs, we need to handle them specially since they're always quoted - if (imagePath.startsWith('data:')) { + if (imagePath.startsWith("data:")) { // Simply remove the exact quoted data URL const quotedPath = `@"${imagePath}"`; - const newPrompt = prompt.replace(quotedPath, '').trim(); + const newPrompt = prompt.replace(quotedPath, "").trim(); setPrompt(newPrompt); return; } - + // For file paths, use the original logic - const escapedPath = imagePath.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); - const escapedRelativePath = imagePath.replace(projectPath + '/', '').replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); - + const escapedPath = imagePath.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const escapedRelativePath = imagePath + .replace(projectPath + "/", "") + .replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + // Create patterns for both quoted and unquoted mentions const patterns = [ // Quoted full path - new RegExp(`@"${escapedPath}"\\s?`, 'g'), + new RegExp(`@"${escapedPath}"\\s?`, "g"), // Unquoted full path - new RegExp(`@${escapedPath}\\s?`, 'g'), + new RegExp(`@${escapedPath}\\s?`, "g"), // Quoted relative path - new RegExp(`@"${escapedRelativePath}"\\s?`, 'g'), + new RegExp(`@"${escapedRelativePath}"\\s?`, "g"), // Unquoted relative path - new RegExp(`@${escapedRelativePath}\\s?`, 'g') + new RegExp(`@${escapedRelativePath}\\s?`, "g"), ]; let newPrompt = prompt; for (const pattern of patterns) { - newPrompt = newPrompt.replace(pattern, ''); + newPrompt = newPrompt.replace(pattern, ""); } setPrompt(newPrompt.trim()); }; - const selectedModelData = MODELS.find(m => m.id === selectedModel) || MODELS[0]; + const selectedModelData = + MODELS.find((m) => m.id === selectedModel) || MODELS[0]; return ( - <> - {/* Expanded Modal */} - - {isExpanded && ( - setIsExpanded(false)} - > + <> + {/* Expanded Modal */} + + {isExpanded && ( e.stopPropagation()} + initial={{ opacity: 0 }} + animate={{ opacity: 1 }} + exit={{ opacity: 0 }} + className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-background/80 backdrop-blur-sm" + onClick={() => setIsExpanded(false)} > -
-

Compose your prompt

- - - - - -
+ +
+ +
+ + {/* Image previews in expanded mode */} + {embeddedImages.length > 0 && ( + + )} - {/* Image previews in expanded mode */} - {embeddedImages.length > 0 && ( - - )} - -