import React, { useState, useEffect, useRef, useCallback } from "react"; import { Message, Conversation, StreamResponse, LLMContent, ConversationListUpdate, } from "../types"; import { api } from "../services/api"; import { ThemeMode, getStoredTheme, setStoredTheme, applyTheme } from "../services/theme"; import { setFaviconStatus } from "../services/favicon"; import MessageComponent from "./Message"; import MessageInput from "./MessageInput"; import DiffViewer from "./DiffViewer"; import BashTool from "./BashTool"; import PatchTool from "./PatchTool"; import ScreenshotTool from "./ScreenshotTool"; import ThinkTool from "./ThinkTool"; import KeywordSearchTool from "./KeywordSearchTool"; import BrowserNavigateTool from "./BrowserNavigateTool"; import BrowserEvalTool from "./BrowserEvalTool"; import ReadImageTool from "./ReadImageTool"; import BrowserConsoleLogsTool from "./BrowserConsoleLogsTool"; import ChangeDirTool from "./ChangeDirTool"; import BrowserResizeTool from "./BrowserResizeTool"; import SubagentTool from "./SubagentTool"; import OutputIframeTool from "./OutputIframeTool"; import DirectoryPickerModal from "./DirectoryPickerModal"; import { useVersionChecker } from "./VersionChecker"; import TerminalWidget from "./TerminalWidget"; import ModelPicker from "./ModelPicker"; import SystemPromptView from "./SystemPromptView"; // Ephemeral terminal instance (not persisted to database) interface EphemeralTerminal { id: string; command: string; cwd: string; createdAt: Date; } interface ContextUsageBarProps { contextWindowSize: number; maxContextTokens: number; conversationId?: string | null; onContinueConversation?: () => void; } function ContextUsageBar({ contextWindowSize, maxContextTokens, conversationId, onContinueConversation, }: ContextUsageBarProps) { const [showPopup, setShowPopup] = useState(false); const [continuing, setContinuing] = useState(false); const barRef = useRef(null); const hasAutoOpenedRef = useRef(null); const percentage = maxContextTokens > 0 ? (contextWindowSize / maxContextTokens) * 100 : 0; const clampedPercentage = Math.min(percentage, 100); const showLongConversationWarning = contextWindowSize >= 100000; const getBarColor = () => { if (percentage >= 90) return "var(--error-text)"; if (percentage >= 70) return "var(--warning-text, #f59e0b)"; return "var(--blue-text)"; }; const formatTokens = (tokens: number) => { if (tokens >= 1000000) return `${(tokens / 1000000).toFixed(1)}M`; if (tokens >= 1000) return `${(tokens / 1000).toFixed(0)}k`; return tokens.toString(); }; const handleClick = () => { setShowPopup(!showPopup); }; // Auto-open popup when hitting 100k tokens (once per conversation) useEffect(() => { if ( showLongConversationWarning && conversationId && hasAutoOpenedRef.current !== conversationId ) { hasAutoOpenedRef.current = conversationId; setShowPopup(true); } }, [showLongConversationWarning, conversationId]); // Close popup when clicking outside useEffect(() => { if (!showPopup) return; const handleClickOutside = (e: MouseEvent) => { if (barRef.current && !barRef.current.contains(e.target as Node)) { setShowPopup(false); } }; document.addEventListener("click", handleClickOutside); return () => document.removeEventListener("click", handleClickOutside); }, [showPopup]); // Calculate fixed position when popup should be shown const [popupPosition, setPopupPosition] = useState<{ bottom: number; right: number } | null>( null, ); useEffect(() => { if (showPopup && barRef.current) { const rect = barRef.current.getBoundingClientRect(); setPopupPosition({ bottom: window.innerHeight - rect.top + 4, right: window.innerWidth - rect.right, }); } else { setPopupPosition(null); } }, [showPopup]); const handleContinue = async () => { if (continuing || !onContinueConversation) return; setContinuing(true); try { await onContinueConversation(); setShowPopup(false); } finally { setContinuing(false); } }; return (
{showPopup && popupPosition && (
{formatTokens(contextWindowSize)} / {formatTokens(maxContextTokens)} ( {percentage.toFixed(1)}%) tokens used {showLongConversationWarning && (
This conversation is getting long.
For best results, start a new conversation.
)} {onContinueConversation && conversationId && ( )}
)}
{showLongConversationWarning && ( ⚠️ )}
); } interface CoalescedToolCallProps { toolName: string; toolInput?: unknown; toolResult?: LLMContent[]; toolError?: boolean; toolStartTime?: string | null; toolEndTime?: string | null; hasResult?: boolean; display?: unknown; onCommentTextChange?: (text: string) => void; } // Map tool names to their specialized components. // IMPORTANT: When adding a new tool here, also add it to Message.tsx renderContent() // for both tool_use and tool_result cases. See AGENTS.md in this directory. // eslint-disable-next-line @typescript-eslint/no-explicit-any const TOOL_COMPONENTS: Record> = { bash: BashTool, patch: PatchTool, screenshot: ScreenshotTool, browser_take_screenshot: ScreenshotTool, think: ThinkTool, keyword_search: KeywordSearchTool, browser_navigate: BrowserNavigateTool, browser_eval: BrowserEvalTool, read_image: ReadImageTool, browser_recent_console_logs: BrowserConsoleLogsTool, browser_clear_console_logs: BrowserConsoleLogsTool, change_dir: ChangeDirTool, browser_resize: BrowserResizeTool, subagent: SubagentTool, output_iframe: OutputIframeTool, }; function CoalescedToolCall({ toolName, toolInput, toolResult, toolError, toolStartTime, toolEndTime, hasResult, display, onCommentTextChange, }: CoalescedToolCallProps) { // Calculate execution time if available let executionTime = ""; if (hasResult && toolStartTime && toolEndTime) { const start = new Date(toolStartTime).getTime(); const end = new Date(toolEndTime).getTime(); const diffMs = end - start; if (diffMs < 1000) { executionTime = `${diffMs}ms`; } else { executionTime = `${(diffMs / 1000).toFixed(1)}s`; } } // Look up the specialized component for this tool const ToolComponent = TOOL_COMPONENTS[toolName]; if (ToolComponent) { const props = { toolInput, isRunning: !hasResult, toolResult, hasError: toolError, executionTime, display, // BrowserConsoleLogsTool needs the toolName prop ...(toolName === "browser_recent_console_logs" || toolName === "browser_clear_console_logs" ? { toolName } : {}), // Patch tool can add comments ...(toolName === "patch" && onCommentTextChange ? { onCommentTextChange } : {}), }; return ; } const getToolResultSummary = (results: LLMContent[]) => { if (!results || results.length === 0) return "No output"; const firstResult = results[0]; if (firstResult.Type === 2 && firstResult.Text) { // text content const text = firstResult.Text.trim(); if (text.length <= 50) return text; return text.substring(0, 47) + "..."; } return `${results.length} result${results.length > 1 ? "s" : ""}`; }; const renderContent = (content: LLMContent) => { if (content.Type === 2) { // text return
{content.Text || ""}
; } return
[Content type {content.Type}]
; }; if (!hasResult) { // Show "running" state return (
Tool: {toolName} (running)
{typeof toolInput === "string" ? toolInput : JSON.stringify(toolInput, null, 2)}
); } // Show completed state with result const summary = toolResult ? getToolResultSummary(toolResult) : "No output"; return (
{toolName} {toolError ? "✗" : "✓"} {summary}
{executionTime && {executionTime}}
{/* Show tool input */}
Input:
{toolInput ? ( typeof toolInput === "string" ? ( toolInput ) : ( JSON.stringify(toolInput, null, 2) ) ) : ( No input data )}
{/* Show tool output with header */}
Output{toolError ? " (Error)" : ""}:
{toolResult?.map((result, idx) => (
{renderContent(result)}
))}
); } // Animated "Agent working..." with letter-by-letter bold animation function AnimatedWorkingStatus() { const text = "Agent working..."; const [boldIndex, setBoldIndex] = useState(0); useEffect(() => { const interval = setInterval(() => { setBoldIndex((prev) => (prev + 1) % text.length); }, 100); // 100ms per letter return () => clearInterval(interval); }, []); return ( {text.split("").map((char, idx) => ( {char} ))} ); } interface ConversationStateUpdate { conversation_id: string; working: boolean; model?: string; } interface ChatInterfaceProps { conversationId: string | null; onOpenDrawer: () => void; onNewConversation: () => void; currentConversation?: Conversation; onConversationUpdate?: (conversation: Conversation) => void; onConversationListUpdate?: (update: ConversationListUpdate) => void; onConversationStateUpdate?: (state: ConversationStateUpdate) => void; onFirstMessage?: (message: string, model: string, cwd?: string) => Promise; onContinueConversation?: ( sourceConversationId: string, model: string, cwd?: string, ) => Promise; mostRecentCwd?: string | null; isDrawerCollapsed?: boolean; onToggleDrawerCollapse?: () => void; openDiffViewerTrigger?: number; // increment to trigger opening diff viewer modelsRefreshTrigger?: number; // increment to trigger models list refresh onOpenModelsModal?: () => void; } function ChatInterface({ conversationId, onOpenDrawer, onNewConversation, currentConversation, onConversationUpdate, onConversationListUpdate, onConversationStateUpdate, onFirstMessage, onContinueConversation, mostRecentCwd, isDrawerCollapsed, onToggleDrawerCollapse, openDiffViewerTrigger, modelsRefreshTrigger, onOpenModelsModal, }: ChatInterfaceProps) { const [messages, setMessages] = useState([]); const [loading, setLoading] = useState(true); const [sending, setSending] = useState(false); const [error, setError] = useState(null); const [models, setModels] = useState< Array<{ id: string; display_name?: string; source?: string; ready: boolean; max_context_tokens?: number; }> >(window.__SHELLEY_INIT__?.models || []); const [selectedModel, setSelectedModelState] = useState(() => { // First check localStorage for a sticky model preference const storedModel = localStorage.getItem("shelley_selected_model"); const initModels = window.__SHELLEY_INIT__?.models || []; // Validate that the stored model exists and is ready if (storedModel) { const modelInfo = initModels.find((m) => m.id === storedModel); if (modelInfo?.ready) { return storedModel; } } // Fall back to server default or first ready model const defaultModel = window.__SHELLEY_INIT__?.default_model; if (defaultModel) { return defaultModel; } const firstReady = initModels.find((m) => m.ready); return firstReady?.id || "claude-sonnet-4.5"; }); // Wrapper to persist model selection to localStorage const setSelectedModel = (model: string) => { setSelectedModelState(model); localStorage.setItem("shelley_selected_model", model); }; const [selectedCwd, setSelectedCwdState] = useState(""); const [cwdInitialized, setCwdInitialized] = useState(false); // Wrapper to persist cwd selection to localStorage const setSelectedCwd = (cwd: string) => { setSelectedCwdState(cwd); localStorage.setItem("shelley_selected_cwd", cwd); }; // Reset cwdInitialized when switching to a new conversation so we re-read from localStorage useEffect(() => { if (conversationId === null) { setCwdInitialized(false); } }, [conversationId]); // Initialize CWD with priority: localStorage > mostRecentCwd > server default useEffect(() => { if (cwdInitialized) return; // First check localStorage for a sticky cwd preference const storedCwd = localStorage.getItem("shelley_selected_cwd"); if (storedCwd) { setSelectedCwdState(storedCwd); setCwdInitialized(true); return; } // Use most recent conversation's CWD if available if (mostRecentCwd) { setSelectedCwdState(mostRecentCwd); setCwdInitialized(true); return; } // Fall back to server default const defaultCwd = window.__SHELLEY_INIT__?.default_cwd || ""; if (defaultCwd) { setSelectedCwdState(defaultCwd); setCwdInitialized(true); } }, [mostRecentCwd, cwdInitialized]); // Refresh models list when triggered (e.g., after custom model changes) or when starting new conversation useEffect(() => { // Skip on initial mount with trigger=0, but always refresh when starting a new conversation if (modelsRefreshTrigger === undefined) return; if (modelsRefreshTrigger === 0 && conversationId !== null) return; api .getModels() .then((newModels) => { setModels(newModels); // Also update the global init data so other components see the change if (window.__SHELLEY_INIT__) { window.__SHELLEY_INIT__.models = newModels; } }) .catch((err) => { console.error("Failed to refresh models:", err); }); }, [modelsRefreshTrigger, conversationId]); const [cwdError, setCwdError] = useState(null); const [showDirectoryPicker, setShowDirectoryPicker] = useState(false); // Settings modal removed - configuration moved to status bar for empty conversations const [showOverflowMenu, setShowOverflowMenu] = useState(false); const [themeMode, setThemeMode] = useState(getStoredTheme); const [showDiffViewer, setShowDiffViewer] = useState(false); const [diffViewerInitialCommit, setDiffViewerInitialCommit] = useState( undefined, ); const [diffCommentText, setDiffCommentText] = useState(""); const [agentWorking, setAgentWorking] = useState(false); const [cancelling, setCancelling] = useState(false); const [contextWindowSize, setContextWindowSize] = useState(0); const terminalURL = window.__SHELLEY_INIT__?.terminal_url || null; const links = window.__SHELLEY_INIT__?.links || []; const hostname = window.__SHELLEY_INIT__?.hostname || "localhost"; const { hasUpdate, openModal: openVersionModal, VersionModal } = useVersionChecker(); const [, setReconnectAttempts] = useState(0); const [isDisconnected, setIsDisconnected] = useState(false); const [showScrollToBottom, setShowScrollToBottom] = useState(false); // Ephemeral terminals are local-only and not persisted to the database const [ephemeralTerminals, setEphemeralTerminals] = useState([]); const [terminalInjectedText, setTerminalInjectedText] = useState(null); const messagesEndRef = useRef(null); const messagesContainerRef = useRef(null); const eventSourceRef = useRef(null); const overflowMenuRef = useRef(null); const reconnectTimeoutRef = useRef(null); const periodicRetryRef = useRef(null); const heartbeatTimeoutRef = useRef(null); const lastSequenceIdRef = useRef(-1); const userScrolledRef = useRef(false); // Load messages and set up streaming useEffect(() => { // Clear ephemeral terminals when conversation changes setEphemeralTerminals([]); if (conversationId) { setAgentWorking(false); loadMessages(); setupMessageStream(); } else { // No conversation yet, show empty state setMessages([]); setContextWindowSize(0); setLoading(false); } return () => { if (eventSourceRef.current) { eventSourceRef.current.close(); } if (reconnectTimeoutRef.current) { clearTimeout(reconnectTimeoutRef.current); } if (periodicRetryRef.current) { clearInterval(periodicRetryRef.current); } if (heartbeatTimeoutRef.current) { clearTimeout(heartbeatTimeoutRef.current); } // Reset sequence ID when conversation changes lastSequenceIdRef.current = -1; }; }, [conversationId]); // Update favicon when agent working state changes useEffect(() => { setFaviconStatus(agentWorking ? "working" : "ready"); }, [agentWorking]); // Check scroll position and handle scroll-to-bottom button useEffect(() => { const container = messagesContainerRef.current; if (!container) return; const handleScroll = () => { const { scrollTop, scrollHeight, clientHeight } = container; const isNearBottom = scrollHeight - scrollTop - clientHeight < 100; setShowScrollToBottom(!isNearBottom); userScrolledRef.current = !isNearBottom; }; container.addEventListener("scroll", handleScroll); return () => container.removeEventListener("scroll", handleScroll); }, []); // Auto-scroll to bottom when new messages arrive (only if user is already at bottom) useEffect(() => { if (!userScrolledRef.current) { scrollToBottom(); } }, [messages]); // Close overflow menu when clicking outside useEffect(() => { const handleClickOutside = (event: MouseEvent) => { if (overflowMenuRef.current && !overflowMenuRef.current.contains(event.target as Node)) { setShowOverflowMenu(false); } }; if (showOverflowMenu) { document.addEventListener("mousedown", handleClickOutside); return () => { document.removeEventListener("mousedown", handleClickOutside); }; } }, [showOverflowMenu]); // Reconnect when page becomes visible, focused, or online // Store reconnect function in a ref so event listeners always have the latest version const reconnectRef = useRef<() => void>(() => {}); useEffect(() => { const handleVisibilityChange = () => { if (document.visibilityState === "visible") { reconnectRef.current(); } }; const handleFocus = () => { reconnectRef.current(); }; const handleOnline = () => { reconnectRef.current(); }; document.addEventListener("visibilitychange", handleVisibilityChange); window.addEventListener("focus", handleFocus); window.addEventListener("online", handleOnline); return () => { document.removeEventListener("visibilitychange", handleVisibilityChange); window.removeEventListener("focus", handleFocus); window.removeEventListener("online", handleOnline); }; }, []); const loadMessages = async () => { if (!conversationId) return; try { setLoading(true); setError(null); const response = await api.getConversation(conversationId); setMessages(response.messages ?? []); // ConversationState is sent via the streaming endpoint, not on initial load // We don't update agentWorking here - the stream will provide the current state // Always update context window size when loading a conversation. // If omitted from response (due to omitempty when 0), default to 0. setContextWindowSize(response.context_window_size ?? 0); if (onConversationUpdate) { onConversationUpdate(response.conversation); } } catch (err) { console.error("Failed to load messages:", err); setError("Failed to load messages"); } finally { // Always set loading to false, even if other operations fail setLoading(false); } }; // Reset heartbeat timeout - called on every message received const resetHeartbeatTimeout = () => { if (heartbeatTimeoutRef.current) { clearTimeout(heartbeatTimeoutRef.current); } // If we don't receive any message (including heartbeat) within 60 seconds, reconnect heartbeatTimeoutRef.current = window.setTimeout(() => { console.warn("No heartbeat received in 60 seconds, reconnecting..."); if (eventSourceRef.current) { eventSourceRef.current.close(); eventSourceRef.current = null; } setupMessageStream(); }, 60000); }; const setupMessageStream = () => { if (!conversationId) return; if (eventSourceRef.current) { eventSourceRef.current.close(); } // Clear any existing heartbeat timeout if (heartbeatTimeoutRef.current) { clearTimeout(heartbeatTimeoutRef.current); } // Use last_sequence_id to resume from where we left off (avoids resending all messages) const lastSeqId = lastSequenceIdRef.current; const eventSource = api.createMessageStream( conversationId, lastSeqId >= 0 ? lastSeqId : undefined, ); eventSourceRef.current = eventSource; eventSource.onmessage = (event) => { // Reset heartbeat timeout on every message resetHeartbeatTimeout(); try { const streamResponse: StreamResponse = JSON.parse(event.data); const incomingMessages = Array.isArray(streamResponse.messages) ? streamResponse.messages : []; // Track the latest sequence ID for reconnection if (incomingMessages.length > 0) { const maxSeqId = Math.max(...incomingMessages.map((m) => m.sequence_id)); if (maxSeqId > lastSequenceIdRef.current) { lastSequenceIdRef.current = maxSeqId; } } // Merge new messages without losing existing ones. // If no new messages (e.g., only conversation/slug update or heartbeat), keep existing list. if (incomingMessages.length > 0) { setMessages((prev) => { const byId = new Map(); for (const m of prev) byId.set(m.message_id, m); for (const m of incomingMessages) byId.set(m.message_id, m); // Preserve original order, then append truly new ones in the order received const result: Message[] = []; for (const m of prev) result.push(byId.get(m.message_id)!); for (const m of incomingMessages) { if (!prev.find((p) => p.message_id === m.message_id)) result.push(m); } return result; }); } // Update conversation data if provided if (onConversationUpdate && streamResponse.conversation) { onConversationUpdate(streamResponse.conversation); } // Handle conversation list updates (for other conversations) if (onConversationListUpdate && streamResponse.conversation_list_update) { onConversationListUpdate(streamResponse.conversation_list_update); } // Handle conversation state updates (explicit from server) if (streamResponse.conversation_state) { // Update the conversations list with new working state if (onConversationStateUpdate) { onConversationStateUpdate(streamResponse.conversation_state); } // Update local state if this is for our conversation if (streamResponse.conversation_state.conversation_id === conversationId) { setAgentWorking(streamResponse.conversation_state.working); // Update selected model from conversation (ensures consistency across sessions) if (streamResponse.conversation_state.model) { setSelectedModel(streamResponse.conversation_state.model); } } } if (typeof streamResponse.context_window_size === "number") { setContextWindowSize(streamResponse.context_window_size); } } catch (err) { console.error("Failed to parse message stream data:", err); } }; eventSource.onerror = (event) => { console.warn("Message stream error (will retry):", event); // Close and retry after a delay if (eventSourceRef.current) { eventSourceRef.current.close(); eventSourceRef.current = null; } // Clear heartbeat timeout on error if (heartbeatTimeoutRef.current) { clearTimeout(heartbeatTimeoutRef.current); heartbeatTimeoutRef.current = null; } // Backoff delays: 1s, 2s, 5s, then show disconnected but keep retrying periodically const delays = [1000, 2000, 5000]; setReconnectAttempts((prev) => { const attempts = prev + 1; if (attempts > delays.length) { // Show disconnected UI but start periodic retry every 30 seconds setIsDisconnected(true); if (!periodicRetryRef.current) { periodicRetryRef.current = window.setInterval(() => { if (eventSourceRef.current === null) { console.log("Periodic reconnect attempt"); setupMessageStream(); } }, 30000); } return attempts; } const delay = delays[attempts - 1]; console.log(`Reconnecting in ${delay}ms (attempt ${attempts}/${delays.length})`); reconnectTimeoutRef.current = window.setTimeout(() => { if (eventSourceRef.current === null) { setupMessageStream(); } }, delay); return attempts; }); }; eventSource.onopen = () => { console.log("Message stream connected"); // Reset reconnect attempts and clear periodic retry on successful connection setReconnectAttempts(0); setIsDisconnected(false); if (periodicRetryRef.current) { clearInterval(periodicRetryRef.current); periodicRetryRef.current = null; } // Start heartbeat timeout monitoring resetHeartbeatTimeout(); }; }; const sendMessage = async (message: string) => { if (!message.trim() || sending) return; // Check if this is a shell command (starts with "!") const trimmedMessage = message.trim(); if (trimmedMessage.startsWith("!")) { const shellCommand = trimmedMessage.slice(1).trim(); if (shellCommand) { // Create an ephemeral terminal const terminal: EphemeralTerminal = { id: `term-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`, command: shellCommand, cwd: selectedCwd || window.__SHELLEY_INIT__?.default_cwd || "/", createdAt: new Date(), }; setEphemeralTerminals((prev) => [...prev, terminal]); // Scroll to bottom to show the new terminal setTimeout(() => scrollToBottom(), 100); } return; } try { setSending(true); setError(null); setAgentWorking(true); // If no conversation ID, this is the first message - validate cwd first if (!conversationId && onFirstMessage) { // Validate cwd if provided if (selectedCwd) { const validation = await api.validateCwd(selectedCwd); if (!validation.valid) { throw new Error(`Invalid working directory: ${validation.error}`); } } await onFirstMessage(message.trim(), selectedModel, selectedCwd || undefined); } else if (conversationId) { await api.sendMessage(conversationId, { message: message.trim(), model: selectedModel, }); } } catch (err) { console.error("Failed to send message:", err); const message = err instanceof Error ? err.message : "Unknown error"; setError(message); setAgentWorking(false); throw err; // Re-throw so MessageInput can preserve the text } finally { setSending(false); } }; const scrollToBottom = () => { messagesEndRef.current?.scrollIntoView({ behavior: "instant" }); userScrolledRef.current = false; setShowScrollToBottom(false); }; // Callback for terminals to insert text into the message input const handleInsertFromTerminal = useCallback((text: string) => { setTerminalInjectedText(text); }, []); const handleManualReconnect = () => { if (!conversationId || eventSourceRef.current) return; setIsDisconnected(false); setReconnectAttempts(0); if (reconnectTimeoutRef.current) { clearTimeout(reconnectTimeoutRef.current); reconnectTimeoutRef.current = null; } if (periodicRetryRef.current) { clearInterval(periodicRetryRef.current); periodicRetryRef.current = null; } setupMessageStream(); }; // Update the reconnect ref when isDisconnected or conversationId changes useEffect(() => { reconnectRef.current = () => { if (isDisconnected && conversationId && !eventSourceRef.current) { console.log("Visibility/focus/online triggered reconnect attempt"); handleManualReconnect(); } }; }, [isDisconnected, conversationId]); // Handle external trigger to open diff viewer useEffect(() => { if (openDiffViewerTrigger && openDiffViewerTrigger > 0) { setShowDiffViewer(true); } }, [openDiffViewerTrigger]); const handleCancel = async () => { if (!conversationId || cancelling) return; try { setCancelling(true); await api.cancelConversation(conversationId); setAgentWorking(false); } catch (err) { console.error("Failed to cancel conversation:", err); setError("Failed to cancel. Please try again."); } finally { setCancelling(false); } }; // Handler to continue conversation in a new one const handleContinueConversation = async () => { if (!conversationId || !onContinueConversation) return; await onContinueConversation( conversationId, selectedModel, currentConversation?.cwd || selectedCwd || undefined, ); }; const getDisplayTitle = () => { return currentConversation?.slug || "Shelley"; }; // Process messages to coalesce tool calls const processMessages = () => { if (messages.length === 0) { return []; } interface CoalescedItem { type: "message" | "tool"; message?: Message; toolUseId?: string; toolName?: string; toolInput?: unknown; toolResult?: LLMContent[]; toolError?: boolean; toolStartTime?: string | null; toolEndTime?: string | null; hasResult?: boolean; display?: unknown; } const coalescedItems: CoalescedItem[] = []; const toolResultMap: Record< string, { result: LLMContent[]; error: boolean; startTime: string | null; endTime: string | null; } > = {}; // Some tool results may be delivered only as display_data (e.g., screenshots) const displayResultSet: Set = new Set(); const displayDataMap: Record = {}; // First pass: collect all tool results messages.forEach((message) => { // Collect tool_result data from llm_data if present if (message.llm_data) { try { const llmData = typeof message.llm_data === "string" ? JSON.parse(message.llm_data) : message.llm_data; if (llmData && llmData.Content && Array.isArray(llmData.Content)) { llmData.Content.forEach((content: LLMContent) => { if (content && content.Type === 6 && content.ToolUseID) { // tool_result toolResultMap[content.ToolUseID] = { result: content.ToolResult || [], error: content.ToolError || false, startTime: content.ToolUseStartTime || null, endTime: content.ToolUseEndTime || null, }; } }); } } catch (err) { console.error("Failed to parse message LLM data for tool results:", err); } } // Also collect tool_use_ids from display_data to mark completion even if llm_data is omitted if (message.display_data) { try { const displays = typeof message.display_data === "string" ? JSON.parse(message.display_data) : message.display_data; if (Array.isArray(displays)) { for (const d of displays) { if ( d && typeof d === "object" && "tool_use_id" in d && typeof d.tool_use_id === "string" ) { displayResultSet.add(d.tool_use_id); // Store the display data for this tool use if ("display" in d) { displayDataMap[d.tool_use_id] = d.display; } } } } } catch (err) { console.error("Failed to parse display_data for tool completion:", err); } } }); // Second pass: process messages and extract tool uses messages.forEach((message) => { // Skip system messages if (message.type === "system") { return; } if (message.type === "error") { coalescedItems.push({ type: "message", message }); return; } // Check if this is a user message with tool results (skip rendering them as messages) let hasToolResult = false; if (message.llm_data) { try { const llmData = typeof message.llm_data === "string" ? JSON.parse(message.llm_data) : message.llm_data; if (llmData && llmData.Content && Array.isArray(llmData.Content)) { hasToolResult = llmData.Content.some((c: LLMContent) => c.Type === 6); } } catch (err) { console.error("Failed to parse message LLM data:", err); } } // If it's a user message without tool results, show it if (message.type === "user" && !hasToolResult) { coalescedItems.push({ type: "message", message }); return; } // If it's a user message with tool results, skip it (we'll handle it via the toolResultMap) if (message.type === "user" && hasToolResult) { return; } if (message.llm_data) { try { const llmData = typeof message.llm_data === "string" ? JSON.parse(message.llm_data) : message.llm_data; if (llmData && llmData.Content && Array.isArray(llmData.Content)) { // Extract text content and tool uses separately const textContents: LLMContent[] = []; const toolUses: LLMContent[] = []; llmData.Content.forEach((content: LLMContent) => { if (content.Type === 2) { // text textContents.push(content); } else if (content.Type === 5) { // tool_use toolUses.push(content); } }); // If we have text content, add it as a message (but only if it's not empty) const textString = textContents .map((c) => c.Text || "") .join("") .trim(); if (textString) { coalescedItems.push({ type: "message", message }); } // Add tool uses as separate items toolUses.forEach((toolUse) => { const resultData = toolUse.ID ? toolResultMap[toolUse.ID] : undefined; const completedViaDisplay = toolUse.ID ? displayResultSet.has(toolUse.ID) : false; const displayData = toolUse.ID ? displayDataMap[toolUse.ID] : undefined; coalescedItems.push({ type: "tool", toolUseId: toolUse.ID, toolName: toolUse.ToolName, toolInput: toolUse.ToolInput, toolResult: resultData?.result, toolError: resultData?.error, toolStartTime: resultData?.startTime, toolEndTime: resultData?.endTime, hasResult: !!resultData || completedViaDisplay, display: displayData, }); }); } } catch (err) { console.error("Failed to parse message LLM data:", err); coalescedItems.push({ type: "message", message }); } } else { coalescedItems.push({ type: "message", message }); } }); return coalescedItems; }; const renderMessages = () => { // Build ephemeral terminal elements first - they should always render const terminalElements = ephemeralTerminals.map((terminal) => ( setEphemeralTerminals((prev) => prev.filter((t) => t.id !== terminal.id))} /> )); if (messages.length === 0 && ephemeralTerminals.length === 0) { const proxyURL = `https://${hostname}/`; return (

Shelley is an agent, running on {hostname}. You can ask Shelley to do stuff. If you build a web site with Shelley, you can use exe.dev's proxy features (see{" "} docs ) to visit it over the web at{" "} {proxyURL} .

{models.length === 0 ? (

No AI models configured. Press Ctrl + K or + K to add a model.

) : (

Send a message to start the conversation.

)}
); } // If we have terminals but no messages, just show terminals if (messages.length === 0) { return terminalElements; } const coalescedItems = processMessages(); const rendered = coalescedItems.map((item, index) => { if (item.type === "message" && item.message) { return ( { setDiffViewerInitialCommit(commit); setShowDiffViewer(true); }} onCommentTextChange={setDiffCommentText} /> ); } else if (item.type === "tool") { return ( ); } return null; }); // Find system message to render at the top const systemMessage = messages.find((m) => m.type === "system"); // Append ephemeral terminals at the end return [ systemMessage && , ...rendered, ...terminalElements, ]; }; return (
{/* Header */}
{/* Expand drawer button - desktop only when collapsed */} {isDrawerCollapsed && onToggleDrawerCollapse && ( )}

{getDisplayTitle()}

{/* Green + icon in circle for new conversation */} {/* Overflow menu */}
{showOverflowMenu && (
{/* Diffs button - show when we have a CWD */} {(currentConversation?.cwd || selectedCwd) && ( )} {terminalURL && ( )} {links.map((link, index) => ( ))} {/* Version check */}
{/* Theme selector */}
)}
{/* Messages area */} {/* Messages area with scroll-to-bottom button wrapper */}
{loading ? (
) : (
{renderMessages()}
)}
{/* Scroll to bottom button - outside scrollable area */} {showScrollToBottom && ( )}
{/* Unified Status Bar */}
{isDisconnected ? ( // Disconnected state <> Disconnected ) : error ? ( // Error state <> {error} ) : agentWorking && conversationId ? ( // Agent working - show status with stop button and context bar
m.id === selectedModel)?.max_context_tokens || 200000 } conversationId={conversationId} onContinueConversation={ onContinueConversation ? handleContinueConversation : undefined } />
) : // Idle state - show ready message, or configuration for empty conversation !conversationId ? ( // Empty conversation - show model (left) and cwd (right)
{/* Model selector - far left */}
Model: onOpenModelsModal?.()} disabled={sending} />
{/* CWD indicator - far right */}
Dir:
) : ( // Active conversation - show Ready + context bar
Ready on {hostname} m.id === selectedModel)?.max_context_tokens || 200000 } conversationId={conversationId} onContinueConversation={ onContinueConversation ? handleContinueConversation : undefined } />
)}
{/* Message input */} { setDiffCommentText(""); setTerminalInjectedText(null); }} persistKey={conversationId || "new-conversation"} /> {/* Directory Picker Modal */} setShowDirectoryPicker(false)} onSelect={(path) => { setSelectedCwd(path); setCwdError(null); }} initialPath={selectedCwd} /> {/* Diff Viewer */} { setShowDiffViewer(false); setDiffViewerInitialCommit(undefined); }} onCommentTextChange={setDiffCommentText} initialCommit={diffViewerInitialCommit} /> {/* Version Checker Modal */} {VersionModal}
); } export default ChatInterface;