1import React, { useState, useEffect, useRef, useCallback } from "react";
2import {
3 Message,
4 Conversation,
5 StreamResponse,
6 LLMContent,
7 ConversationListUpdate,
8} from "../types";
9import { api } from "../services/api";
10import { ThemeMode, getStoredTheme, setStoredTheme, applyTheme } from "../services/theme";
11import { setFaviconStatus } from "../services/favicon";
12import MessageComponent from "./Message";
13import MessageInput from "./MessageInput";
14import DiffViewer from "./DiffViewer";
15import BashTool from "./BashTool";
16import PatchTool from "./PatchTool";
17import ScreenshotTool from "./ScreenshotTool";
18
19import KeywordSearchTool from "./KeywordSearchTool";
20import BrowserNavigateTool from "./BrowserNavigateTool";
21import BrowserEvalTool from "./BrowserEvalTool";
22import ReadImageTool from "./ReadImageTool";
23import BrowserConsoleLogsTool from "./BrowserConsoleLogsTool";
24import ChangeDirTool from "./ChangeDirTool";
25import BrowserResizeTool from "./BrowserResizeTool";
26import SubagentTool from "./SubagentTool";
27import OutputIframeTool from "./OutputIframeTool";
28import DirectoryPickerModal from "./DirectoryPickerModal";
29import { useVersionChecker } from "./VersionChecker";
30import TerminalWidget from "./TerminalWidget";
31import ModelPicker from "./ModelPicker";
32import SystemPromptView from "./SystemPromptView";
33
34// Ephemeral terminal instance (not persisted to database)
35interface EphemeralTerminal {
36 id: string;
37 command: string;
38 cwd: string;
39 createdAt: Date;
40}
41
42interface ContextUsageBarProps {
43 contextWindowSize: number;
44 maxContextTokens: number;
45 conversationId?: string | null;
46 onContinueConversation?: () => void;
47}
48
49function ContextUsageBar({
50 contextWindowSize,
51 maxContextTokens,
52 conversationId,
53 onContinueConversation,
54}: ContextUsageBarProps) {
55 const [showPopup, setShowPopup] = useState(false);
56 const [continuing, setContinuing] = useState(false);
57 const barRef = useRef<HTMLDivElement>(null);
58 const hasAutoOpenedRef = useRef<string | null>(null);
59
60 const percentage = maxContextTokens > 0 ? (contextWindowSize / maxContextTokens) * 100 : 0;
61 const clampedPercentage = Math.min(percentage, 100);
62 const showLongConversationWarning = contextWindowSize >= 100000;
63
64 const getBarColor = () => {
65 if (percentage >= 90) return "var(--error-text)";
66 if (percentage >= 70) return "var(--warning-text, #f59e0b)";
67 return "var(--blue-text)";
68 };
69
70 const formatTokens = (tokens: number) => {
71 if (tokens >= 1000000) return `${(tokens / 1000000).toFixed(1)}M`;
72 if (tokens >= 1000) return `${(tokens / 1000).toFixed(0)}k`;
73 return tokens.toString();
74 };
75
76 const handleClick = () => {
77 setShowPopup(!showPopup);
78 };
79
80 // Auto-open popup when hitting 100k tokens (once per conversation)
81 useEffect(() => {
82 if (
83 showLongConversationWarning &&
84 conversationId &&
85 hasAutoOpenedRef.current !== conversationId
86 ) {
87 hasAutoOpenedRef.current = conversationId;
88 setShowPopup(true);
89 }
90 }, [showLongConversationWarning, conversationId]);
91
92 // Close popup when clicking outside
93 useEffect(() => {
94 if (!showPopup) return;
95 const handleClickOutside = (e: MouseEvent) => {
96 if (barRef.current && !barRef.current.contains(e.target as Node)) {
97 setShowPopup(false);
98 }
99 };
100 document.addEventListener("click", handleClickOutside);
101 return () => document.removeEventListener("click", handleClickOutside);
102 }, [showPopup]);
103
104 // Calculate fixed position when popup should be shown
105 const [popupPosition, setPopupPosition] = useState<{ bottom: number; right: number } | null>(
106 null,
107 );
108
109 useEffect(() => {
110 if (showPopup && barRef.current) {
111 const rect = barRef.current.getBoundingClientRect();
112 setPopupPosition({
113 bottom: window.innerHeight - rect.top + 4,
114 right: window.innerWidth - rect.right,
115 });
116 } else {
117 setPopupPosition(null);
118 }
119 }, [showPopup]);
120
121 const handleContinue = async () => {
122 if (continuing || !onContinueConversation) return;
123 setContinuing(true);
124 try {
125 await onContinueConversation();
126 setShowPopup(false);
127 } finally {
128 setContinuing(false);
129 }
130 };
131
132 return (
133 <div ref={barRef}>
134 {showPopup && popupPosition && (
135 <div
136 style={{
137 position: "fixed",
138 bottom: popupPosition.bottom,
139 right: popupPosition.right,
140 padding: "6px 10px",
141 backgroundColor: "var(--bg-secondary)",
142 border: "1px solid var(--border-color)",
143 borderRadius: "4px",
144 fontSize: "12px",
145 color: "var(--text-secondary)",
146 whiteSpace: "nowrap",
147 boxShadow: "0 2px 8px rgba(0,0,0,0.15)",
148 zIndex: 100,
149 }}
150 >
151 {formatTokens(contextWindowSize)} / {formatTokens(maxContextTokens)} (
152 {percentage.toFixed(1)}%) tokens used
153 {showLongConversationWarning && (
154 <div style={{ marginTop: "6px", color: "var(--warning-text, #f59e0b)" }}>
155 This conversation is getting long.
156 <br />
157 For best results, start a new conversation.
158 </div>
159 )}
160 {onContinueConversation && conversationId && (
161 <button
162 onClick={handleContinue}
163 disabled={continuing}
164 style={{
165 display: "block",
166 marginTop: "8px",
167 padding: "4px 8px",
168 backgroundColor: "var(--blue-text)",
169 color: "white",
170 border: "none",
171 borderRadius: "4px",
172 cursor: continuing ? "not-allowed" : "pointer",
173 fontSize: "12px",
174 opacity: continuing ? 0.7 : 1,
175 }}
176 >
177 {continuing ? "Continuing..." : "Continue in new conversation"}
178 </button>
179 )}
180 </div>
181 )}
182 <div className="context-usage-bar-container">
183 {showLongConversationWarning && (
184 <span
185 className="context-warning-icon"
186 title="This conversation is getting long. For best results, start a new conversation."
187 >
188 ⚠️
189 </span>
190 )}
191 <div
192 className="context-usage-bar"
193 onClick={handleClick}
194 title={`Context: ${formatTokens(contextWindowSize)} / ${formatTokens(maxContextTokens)} tokens (${percentage.toFixed(1)}%)`}
195 >
196 <div
197 className="context-usage-fill"
198 style={{
199 width: `${clampedPercentage}%`,
200 backgroundColor: getBarColor(),
201 }}
202 />
203 </div>
204 </div>
205 </div>
206 );
207}
208
209interface CoalescedToolCallProps {
210 toolName: string;
211 toolInput?: unknown;
212 toolResult?: LLMContent[];
213 toolError?: boolean;
214 toolStartTime?: string | null;
215 toolEndTime?: string | null;
216 hasResult?: boolean;
217 display?: unknown;
218 onCommentTextChange?: (text: string) => void;
219}
220
221// Map tool names to their specialized components.
222// IMPORTANT: When adding a new tool here, also add it to Message.tsx renderContent()
223// for both tool_use and tool_result cases. See AGENTS.md in this directory.
224// eslint-disable-next-line @typescript-eslint/no-explicit-any
225const TOOL_COMPONENTS: Record<string, React.ComponentType<any>> = {
226 bash: BashTool,
227 patch: PatchTool,
228 screenshot: ScreenshotTool,
229 browser_take_screenshot: ScreenshotTool,
230
231 keyword_search: KeywordSearchTool,
232 browser_navigate: BrowserNavigateTool,
233 browser_eval: BrowserEvalTool,
234 read_image: ReadImageTool,
235 browser_recent_console_logs: BrowserConsoleLogsTool,
236 browser_clear_console_logs: BrowserConsoleLogsTool,
237 change_dir: ChangeDirTool,
238 browser_resize: BrowserResizeTool,
239 subagent: SubagentTool,
240 output_iframe: OutputIframeTool,
241};
242
243function CoalescedToolCall({
244 toolName,
245 toolInput,
246 toolResult,
247 toolError,
248 toolStartTime,
249 toolEndTime,
250 hasResult,
251 display,
252 onCommentTextChange,
253}: CoalescedToolCallProps) {
254 // Calculate execution time if available
255 let executionTime = "";
256 if (hasResult && toolStartTime && toolEndTime) {
257 const start = new Date(toolStartTime).getTime();
258 const end = new Date(toolEndTime).getTime();
259 const diffMs = end - start;
260 if (diffMs < 1000) {
261 executionTime = `${diffMs}ms`;
262 } else {
263 executionTime = `${(diffMs / 1000).toFixed(1)}s`;
264 }
265 }
266
267 // Look up the specialized component for this tool
268 const ToolComponent = TOOL_COMPONENTS[toolName];
269 if (ToolComponent) {
270 const props = {
271 toolInput,
272 isRunning: !hasResult,
273 toolResult,
274 hasError: toolError,
275 executionTime,
276 display,
277 // BrowserConsoleLogsTool needs the toolName prop
278 ...(toolName === "browser_recent_console_logs" || toolName === "browser_clear_console_logs"
279 ? { toolName }
280 : {}),
281 // Patch tool can add comments
282 ...(toolName === "patch" && onCommentTextChange ? { onCommentTextChange } : {}),
283 };
284 return <ToolComponent {...props} />;
285 }
286
287 const getToolResultSummary = (results: LLMContent[]) => {
288 if (!results || results.length === 0) return "No output";
289
290 const firstResult = results[0];
291 if (firstResult.Type === 2 && firstResult.Text) {
292 // text content
293 const text = firstResult.Text.trim();
294 if (text.length <= 50) return text;
295 return text.substring(0, 47) + "...";
296 }
297
298 return `${results.length} result${results.length > 1 ? "s" : ""}`;
299 };
300
301 const renderContent = (content: LLMContent) => {
302 if (content.Type === 2) {
303 // text
304 return <div className="whitespace-pre-wrap break-words">{content.Text || ""}</div>;
305 }
306 return <div className="text-secondary text-sm italic">[Content type {content.Type}]</div>;
307 };
308
309 if (!hasResult) {
310 // Show "running" state
311 return (
312 <div className="message message-tool" data-testid="tool-call-running">
313 <div className="message-content">
314 <div className="tool-running">
315 <div className="tool-running-header">
316 <svg
317 fill="none"
318 stroke="currentColor"
319 viewBox="0 0 24 24"
320 style={{ width: "1rem", height: "1rem", color: "var(--blue-text)" }}
321 >
322 <path
323 strokeLinecap="round"
324 strokeLinejoin="round"
325 strokeWidth={2}
326 d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"
327 />
328 <path
329 strokeLinecap="round"
330 strokeLinejoin="round"
331 strokeWidth={2}
332 d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
333 />
334 </svg>
335 <span className="tool-name">Tool: {toolName}</span>
336 <span className="tool-status-running">(running)</span>
337 </div>
338 <div className="tool-input">
339 {typeof toolInput === "string" ? toolInput : JSON.stringify(toolInput, null, 2)}
340 </div>
341 </div>
342 </div>
343 </div>
344 );
345 }
346
347 // Show completed state with result
348 const summary = toolResult ? getToolResultSummary(toolResult) : "No output";
349
350 return (
351 <div className="message message-tool" data-testid="tool-call-completed">
352 <div className="message-content">
353 <details className={`tool-result-details ${toolError ? "error" : ""}`}>
354 <summary className="tool-result-summary">
355 <div className="tool-result-meta">
356 <div className="flex items-center space-x-2">
357 <svg
358 fill="none"
359 stroke="currentColor"
360 viewBox="0 0 24 24"
361 style={{ width: "1rem", height: "1rem", color: "var(--blue-text)" }}
362 >
363 <path
364 strokeLinecap="round"
365 strokeLinejoin="round"
366 strokeWidth={2}
367 d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"
368 />
369 <path
370 strokeLinecap="round"
371 strokeLinejoin="round"
372 strokeWidth={2}
373 d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
374 />
375 </svg>
376 <span className="text-sm font-medium text-blue">{toolName}</span>
377 <span className={`tool-result-status text-xs ${toolError ? "error" : "success"}`}>
378 {toolError ? "✗" : "✓"} {summary}
379 </span>
380 </div>
381 <div className="tool-result-time">
382 {executionTime && <span>{executionTime}</span>}
383 </div>
384 </div>
385 </summary>
386 <div className="tool-result-content">
387 {/* Show tool input */}
388 <div className="tool-result-section">
389 <div className="tool-result-label">Input:</div>
390 <div className="tool-result-data">
391 {toolInput ? (
392 typeof toolInput === "string" ? (
393 toolInput
394 ) : (
395 JSON.stringify(toolInput, null, 2)
396 )
397 ) : (
398 <span className="text-secondary italic">No input data</span>
399 )}
400 </div>
401 </div>
402
403 {/* Show tool output with header */}
404 <div className={`tool-result-section output ${toolError ? "error" : ""}`}>
405 <div className="tool-result-label">Output{toolError ? " (Error)" : ""}:</div>
406 <div className="space-y-2">
407 {toolResult?.map((result, idx) => (
408 <div key={idx}>{renderContent(result)}</div>
409 ))}
410 </div>
411 </div>
412 </div>
413 </details>
414 </div>
415 </div>
416 );
417}
418
419// Animated "Agent working..." with letter-by-letter bold animation
420function AnimatedWorkingStatus() {
421 const text = "Agent working...";
422 const [boldIndex, setBoldIndex] = useState(0);
423
424 useEffect(() => {
425 const interval = setInterval(() => {
426 setBoldIndex((prev) => (prev + 1) % text.length);
427 }, 100); // 100ms per letter
428 return () => clearInterval(interval);
429 }, []);
430
431 return (
432 <span className="status-message animated-working">
433 {text.split("").map((char, idx) => (
434 <span key={idx} className={idx === boldIndex ? "bold-letter" : ""}>
435 {char}
436 </span>
437 ))}
438 </span>
439 );
440}
441
442interface ConversationStateUpdate {
443 conversation_id: string;
444 working: boolean;
445 model?: string;
446}
447
448interface ChatInterfaceProps {
449 conversationId: string | null;
450 onOpenDrawer: () => void;
451 onNewConversation: () => void;
452 currentConversation?: Conversation;
453 onConversationUpdate?: (conversation: Conversation) => void;
454 onConversationListUpdate?: (update: ConversationListUpdate) => void;
455 onConversationStateUpdate?: (state: ConversationStateUpdate) => void;
456 onFirstMessage?: (message: string, model: string, cwd?: string) => Promise<void>;
457 onContinueConversation?: (
458 sourceConversationId: string,
459 model: string,
460 cwd?: string,
461 ) => Promise<void>;
462 mostRecentCwd?: string | null;
463 isDrawerCollapsed?: boolean;
464 onToggleDrawerCollapse?: () => void;
465 openDiffViewerTrigger?: number; // increment to trigger opening diff viewer
466 modelsRefreshTrigger?: number; // increment to trigger models list refresh
467 onOpenModelsModal?: () => void;
468}
469
470function ChatInterface({
471 conversationId,
472 onOpenDrawer,
473 onNewConversation,
474 currentConversation,
475 onConversationUpdate,
476 onConversationListUpdate,
477 onConversationStateUpdate,
478 onFirstMessage,
479 onContinueConversation,
480 mostRecentCwd,
481 isDrawerCollapsed,
482 onToggleDrawerCollapse,
483 openDiffViewerTrigger,
484 modelsRefreshTrigger,
485 onOpenModelsModal,
486}: ChatInterfaceProps) {
487 const [messages, setMessages] = useState<Message[]>([]);
488 const [loading, setLoading] = useState(true);
489 const [sending, setSending] = useState(false);
490 const [error, setError] = useState<string | null>(null);
491 const [models, setModels] = useState<
492 Array<{
493 id: string;
494 display_name?: string;
495 source?: string;
496 ready: boolean;
497 max_context_tokens?: number;
498 }>
499 >(window.__SHELLEY_INIT__?.models || []);
500 const [selectedModel, setSelectedModelState] = useState<string>(() => {
501 // First check localStorage for a sticky model preference
502 const storedModel = localStorage.getItem("shelley_selected_model");
503 const initModels = window.__SHELLEY_INIT__?.models || [];
504 // Validate that the stored model exists and is ready
505 if (storedModel) {
506 const modelInfo = initModels.find((m) => m.id === storedModel);
507 if (modelInfo?.ready) {
508 return storedModel;
509 }
510 }
511 // Fall back to server default or first ready model
512 const defaultModel = window.__SHELLEY_INIT__?.default_model;
513 if (defaultModel) {
514 return defaultModel;
515 }
516 const firstReady = initModels.find((m) => m.ready);
517 return firstReady?.id || "claude-sonnet-4.5";
518 });
519 // Wrapper to persist model selection to localStorage
520 const setSelectedModel = (model: string) => {
521 setSelectedModelState(model);
522 localStorage.setItem("shelley_selected_model", model);
523 };
524 const [selectedCwd, setSelectedCwdState] = useState<string>("");
525 const [cwdInitialized, setCwdInitialized] = useState(false);
526 // Wrapper to persist cwd selection to localStorage
527 const setSelectedCwd = (cwd: string) => {
528 setSelectedCwdState(cwd);
529 localStorage.setItem("shelley_selected_cwd", cwd);
530 };
531
532 // Reset cwdInitialized when switching to a new conversation so we re-read from localStorage
533 useEffect(() => {
534 if (conversationId === null) {
535 setCwdInitialized(false);
536 }
537 }, [conversationId]);
538
539 // Initialize CWD with priority: localStorage > mostRecentCwd > server default
540 useEffect(() => {
541 if (cwdInitialized) return;
542
543 // First check localStorage for a sticky cwd preference
544 const storedCwd = localStorage.getItem("shelley_selected_cwd");
545 if (storedCwd) {
546 setSelectedCwdState(storedCwd);
547 setCwdInitialized(true);
548 return;
549 }
550
551 // Use most recent conversation's CWD if available
552 if (mostRecentCwd) {
553 setSelectedCwdState(mostRecentCwd);
554 setCwdInitialized(true);
555 return;
556 }
557
558 // Fall back to server default
559 const defaultCwd = window.__SHELLEY_INIT__?.default_cwd || "";
560 if (defaultCwd) {
561 setSelectedCwdState(defaultCwd);
562 setCwdInitialized(true);
563 }
564 }, [mostRecentCwd, cwdInitialized]);
565
566 // Refresh models list when triggered (e.g., after custom model changes) or when starting new conversation
567 useEffect(() => {
568 // Skip on initial mount with trigger=0, but always refresh when starting a new conversation
569 if (modelsRefreshTrigger === undefined) return;
570 if (modelsRefreshTrigger === 0 && conversationId !== null) return;
571 api
572 .getModels()
573 .then((newModels) => {
574 setModels(newModels);
575 // Also update the global init data so other components see the change
576 if (window.__SHELLEY_INIT__) {
577 window.__SHELLEY_INIT__.models = newModels;
578 }
579 })
580 .catch((err) => {
581 console.error("Failed to refresh models:", err);
582 });
583 }, [modelsRefreshTrigger, conversationId]);
584
585 const [cwdError, setCwdError] = useState<string | null>(null);
586 const [showDirectoryPicker, setShowDirectoryPicker] = useState(false);
587 // Settings modal removed - configuration moved to status bar for empty conversations
588 const [showOverflowMenu, setShowOverflowMenu] = useState(false);
589 const [themeMode, setThemeMode] = useState<ThemeMode>(getStoredTheme);
590 const [showDiffViewer, setShowDiffViewer] = useState(false);
591 const [diffViewerInitialCommit, setDiffViewerInitialCommit] = useState<string | undefined>(
592 undefined,
593 );
594 const [diffViewerCwd, setDiffViewerCwd] = useState<string | undefined>(undefined);
595 const [diffCommentText, setDiffCommentText] = useState("");
596 const [agentWorking, setAgentWorking] = useState(false);
597 const [cancelling, setCancelling] = useState(false);
598 const [contextWindowSize, setContextWindowSize] = useState(0);
599 const terminalURL = window.__SHELLEY_INIT__?.terminal_url || null;
600 const links = window.__SHELLEY_INIT__?.links || [];
601 const hostname = window.__SHELLEY_INIT__?.hostname || "localhost";
602 const { hasUpdate, openModal: openVersionModal, VersionModal } = useVersionChecker();
603 const [reconnectAttempts, setReconnectAttempts] = useState(0);
604 const [isDisconnected, setIsDisconnected] = useState(false);
605 const [isReconnecting, setIsReconnecting] = useState(false);
606 const [showScrollToBottom, setShowScrollToBottom] = useState(false);
607 // Ephemeral terminals are local-only and not persisted to the database
608 const [ephemeralTerminals, setEphemeralTerminals] = useState<EphemeralTerminal[]>([]);
609 const [terminalInjectedText, setTerminalInjectedText] = useState<string | null>(null);
610 const messagesEndRef = useRef<HTMLDivElement>(null);
611 const messagesContainerRef = useRef<HTMLDivElement>(null);
612 const eventSourceRef = useRef<EventSource | null>(null);
613 const overflowMenuRef = useRef<HTMLDivElement>(null);
614 const reconnectTimeoutRef = useRef<number | null>(null);
615 const periodicRetryRef = useRef<number | null>(null);
616 const heartbeatTimeoutRef = useRef<number | null>(null);
617 const lastSequenceIdRef = useRef<number>(-1);
618 const userScrolledRef = useRef(false);
619
620 // Load messages and set up streaming
621 useEffect(() => {
622 // Clear ephemeral terminals when conversation changes
623 setEphemeralTerminals([]);
624
625 if (conversationId) {
626 setAgentWorking(false);
627 loadMessages();
628 setupMessageStream();
629 } else {
630 // No conversation yet, show empty state
631 setMessages([]);
632 setContextWindowSize(0);
633 setLoading(false);
634 }
635
636 return () => {
637 if (eventSourceRef.current) {
638 eventSourceRef.current.close();
639 }
640 if (reconnectTimeoutRef.current) {
641 clearTimeout(reconnectTimeoutRef.current);
642 }
643 if (periodicRetryRef.current) {
644 clearInterval(periodicRetryRef.current);
645 }
646 if (heartbeatTimeoutRef.current) {
647 clearTimeout(heartbeatTimeoutRef.current);
648 }
649 // Reset sequence ID when conversation changes
650 lastSequenceIdRef.current = -1;
651 };
652 }, [conversationId]);
653
654 // Update favicon when agent working state changes
655 useEffect(() => {
656 setFaviconStatus(agentWorking ? "working" : "ready");
657 }, [agentWorking]);
658
659 // Check scroll position and handle scroll-to-bottom button
660 useEffect(() => {
661 const container = messagesContainerRef.current;
662 if (!container) return;
663
664 const handleScroll = () => {
665 const { scrollTop, scrollHeight, clientHeight } = container;
666 const isNearBottom = scrollHeight - scrollTop - clientHeight < 100;
667 setShowScrollToBottom(!isNearBottom);
668 userScrolledRef.current = !isNearBottom;
669 };
670
671 container.addEventListener("scroll", handleScroll);
672 return () => container.removeEventListener("scroll", handleScroll);
673 }, []);
674
675 // Auto-scroll to bottom when new messages arrive (only if user is already at bottom)
676 useEffect(() => {
677 if (!userScrolledRef.current) {
678 scrollToBottom();
679 }
680 }, [messages]);
681
682 // Close overflow menu when clicking outside
683 useEffect(() => {
684 const handleClickOutside = (event: MouseEvent) => {
685 if (overflowMenuRef.current && !overflowMenuRef.current.contains(event.target as Node)) {
686 setShowOverflowMenu(false);
687 }
688 };
689
690 if (showOverflowMenu) {
691 document.addEventListener("mousedown", handleClickOutside);
692 return () => {
693 document.removeEventListener("mousedown", handleClickOutside);
694 };
695 }
696 }, [showOverflowMenu]);
697
698 // Reconnect when page becomes visible, focused, or online
699 // Store reconnect function in a ref so event listeners always have the latest version
700 const reconnectRef = useRef<() => void>(() => {});
701
702 // Check connection health - returns true if connection needs to be re-established
703 const checkConnectionHealth = useCallback(() => {
704 if (!conversationId) return false;
705
706 const es = eventSourceRef.current;
707 // No connection exists
708 if (!es) return true;
709 // EventSource.CLOSED = 2, EventSource.CONNECTING = 0
710 // If closed or errored, we need to reconnect
711 if (es.readyState === 2) return true;
712 // If still connecting after coming back, that's fine
713 return false;
714 }, [conversationId]);
715
716 useEffect(() => {
717 const handleVisibilityChange = () => {
718 if (document.visibilityState === "visible") {
719 // When tab becomes visible, always check connection health
720 if (checkConnectionHealth()) {
721 console.log("Tab visible: connection unhealthy, reconnecting");
722 reconnectRef.current();
723 } else {
724 console.log("Tab visible: connection healthy");
725 }
726 }
727 };
728
729 const handleFocus = () => {
730 // On focus, check connection health
731 if (checkConnectionHealth()) {
732 console.log("Window focus: connection unhealthy, reconnecting");
733 reconnectRef.current();
734 }
735 };
736
737 const handleOnline = () => {
738 // Coming back online - definitely try to reconnect if needed
739 if (checkConnectionHealth()) {
740 console.log("Online: connection unhealthy, reconnecting");
741 reconnectRef.current();
742 }
743 };
744
745 document.addEventListener("visibilitychange", handleVisibilityChange);
746 window.addEventListener("focus", handleFocus);
747 window.addEventListener("online", handleOnline);
748
749 return () => {
750 document.removeEventListener("visibilitychange", handleVisibilityChange);
751 window.removeEventListener("focus", handleFocus);
752 window.removeEventListener("online", handleOnline);
753 };
754 }, [checkConnectionHealth]);
755
756 const loadMessages = async () => {
757 if (!conversationId) return;
758 try {
759 setLoading(true);
760 setError(null);
761 const response = await api.getConversation(conversationId);
762 setMessages(response.messages ?? []);
763 // ConversationState is sent via the streaming endpoint, not on initial load
764 // We don't update agentWorking here - the stream will provide the current state
765 // Always update context window size when loading a conversation.
766 // If omitted from response (due to omitempty when 0), default to 0.
767 setContextWindowSize(response.context_window_size ?? 0);
768 if (onConversationUpdate) {
769 onConversationUpdate(response.conversation);
770 }
771 } catch (err) {
772 console.error("Failed to load messages:", err);
773 setError("Failed to load messages");
774 } finally {
775 // Always set loading to false, even if other operations fail
776 setLoading(false);
777 }
778 };
779
780 // Reset heartbeat timeout - called on every message received
781 const resetHeartbeatTimeout = () => {
782 if (heartbeatTimeoutRef.current) {
783 clearTimeout(heartbeatTimeoutRef.current);
784 }
785 // If we don't receive any message (including heartbeat) within 60 seconds, reconnect
786 heartbeatTimeoutRef.current = window.setTimeout(() => {
787 console.warn("No heartbeat received in 60 seconds, reconnecting...");
788 if (eventSourceRef.current) {
789 eventSourceRef.current.close();
790 eventSourceRef.current = null;
791 }
792 setupMessageStream();
793 }, 60000);
794 };
795
796 const setupMessageStream = () => {
797 if (!conversationId) return;
798
799 if (eventSourceRef.current) {
800 eventSourceRef.current.close();
801 }
802
803 // Clear any existing heartbeat timeout
804 if (heartbeatTimeoutRef.current) {
805 clearTimeout(heartbeatTimeoutRef.current);
806 }
807
808 // Use last_sequence_id to resume from where we left off (avoids resending all messages)
809 const lastSeqId = lastSequenceIdRef.current;
810 const eventSource = api.createMessageStream(
811 conversationId,
812 lastSeqId >= 0 ? lastSeqId : undefined,
813 );
814 eventSourceRef.current = eventSource;
815
816 eventSource.onmessage = (event) => {
817 // Reset heartbeat timeout on every message
818 resetHeartbeatTimeout();
819
820 try {
821 const streamResponse: StreamResponse = JSON.parse(event.data);
822 const incomingMessages = Array.isArray(streamResponse.messages)
823 ? streamResponse.messages
824 : [];
825
826 // Track the latest sequence ID for reconnection
827 if (incomingMessages.length > 0) {
828 const maxSeqId = Math.max(...incomingMessages.map((m) => m.sequence_id));
829 if (maxSeqId > lastSequenceIdRef.current) {
830 lastSequenceIdRef.current = maxSeqId;
831 }
832 }
833
834 // Merge new messages without losing existing ones.
835 // If no new messages (e.g., only conversation/slug update or heartbeat), keep existing list.
836 if (incomingMessages.length > 0) {
837 setMessages((prev) => {
838 const byId = new Map<string, Message>();
839 for (const m of prev) byId.set(m.message_id, m);
840 for (const m of incomingMessages) byId.set(m.message_id, m);
841 // Preserve original order, then append truly new ones in the order received
842 const result: Message[] = [];
843 for (const m of prev) result.push(byId.get(m.message_id)!);
844 for (const m of incomingMessages) {
845 if (!prev.find((p) => p.message_id === m.message_id)) result.push(m);
846 }
847 return result;
848 });
849 }
850
851 // Update conversation data if provided
852 if (onConversationUpdate && streamResponse.conversation) {
853 onConversationUpdate(streamResponse.conversation);
854 }
855
856 // Handle conversation list updates (for other conversations)
857 if (onConversationListUpdate && streamResponse.conversation_list_update) {
858 onConversationListUpdate(streamResponse.conversation_list_update);
859 }
860
861 // Handle conversation state updates (explicit from server)
862 if (streamResponse.conversation_state) {
863 // Update the conversations list with new working state
864 if (onConversationStateUpdate) {
865 onConversationStateUpdate(streamResponse.conversation_state);
866 }
867 // Update local state if this is for our conversation
868 if (streamResponse.conversation_state.conversation_id === conversationId) {
869 setAgentWorking(streamResponse.conversation_state.working);
870 // Update selected model from conversation (ensures consistency across sessions)
871 if (streamResponse.conversation_state.model) {
872 setSelectedModel(streamResponse.conversation_state.model);
873 }
874 }
875 }
876
877 if (typeof streamResponse.context_window_size === "number") {
878 setContextWindowSize(streamResponse.context_window_size);
879 }
880 } catch (err) {
881 console.error("Failed to parse message stream data:", err);
882 }
883 };
884
885 eventSource.onerror = (event) => {
886 console.warn("Message stream error (will retry):", event);
887 // Close and retry after a delay
888 if (eventSourceRef.current) {
889 eventSourceRef.current.close();
890 eventSourceRef.current = null;
891 }
892
893 // Clear heartbeat timeout on error
894 if (heartbeatTimeoutRef.current) {
895 clearTimeout(heartbeatTimeoutRef.current);
896 heartbeatTimeoutRef.current = null;
897 }
898
899 // Backoff delays: 1s, 2s, 5s, then show disconnected but keep retrying periodically
900 const delays = [1000, 2000, 5000];
901
902 setReconnectAttempts((prev) => {
903 const attempts = prev + 1;
904
905 if (attempts > delays.length) {
906 // Show disconnected UI but start periodic retry every 30 seconds
907 setIsReconnecting(false);
908 setIsDisconnected(true);
909 if (!periodicRetryRef.current) {
910 periodicRetryRef.current = window.setInterval(() => {
911 if (eventSourceRef.current === null) {
912 console.log("Periodic reconnect attempt");
913 setupMessageStream();
914 }
915 }, 30000);
916 }
917 return attempts;
918 }
919
920 // Show reconnecting indicator during backoff attempts
921 setIsReconnecting(true);
922 const delay = delays[attempts - 1];
923 console.log(`Reconnecting in ${delay}ms (attempt ${attempts}/${delays.length})`);
924
925 reconnectTimeoutRef.current = window.setTimeout(() => {
926 if (eventSourceRef.current === null) {
927 setupMessageStream();
928 }
929 }, delay);
930
931 return attempts;
932 });
933 };
934
935 eventSource.onopen = () => {
936 console.log("Message stream connected");
937 // Reset reconnect attempts and clear periodic retry on successful connection
938 setReconnectAttempts(0);
939 setIsDisconnected(false);
940 setIsReconnecting(false);
941 if (periodicRetryRef.current) {
942 clearInterval(periodicRetryRef.current);
943 periodicRetryRef.current = null;
944 }
945 // Start heartbeat timeout monitoring
946 resetHeartbeatTimeout();
947 };
948 };
949
950 const sendMessage = async (message: string) => {
951 if (!message.trim() || sending) return;
952
953 // Check if this is a shell command (starts with "!")
954 const trimmedMessage = message.trim();
955 if (trimmedMessage.startsWith("!")) {
956 const shellCommand = trimmedMessage.slice(1).trim();
957 if (shellCommand) {
958 // Create an ephemeral terminal
959 const terminal: EphemeralTerminal = {
960 id: `term-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`,
961 command: shellCommand,
962 cwd: selectedCwd || window.__SHELLEY_INIT__?.default_cwd || "/",
963 createdAt: new Date(),
964 };
965 setEphemeralTerminals((prev) => [...prev, terminal]);
966 // Scroll to bottom to show the new terminal
967 setTimeout(() => scrollToBottom(), 100);
968 }
969 return;
970 }
971
972 try {
973 setSending(true);
974 setError(null);
975 setAgentWorking(true);
976
977 // If no conversation ID, this is the first message - validate cwd first
978 if (!conversationId && onFirstMessage) {
979 // Validate cwd if provided
980 if (selectedCwd) {
981 const validation = await api.validateCwd(selectedCwd);
982 if (!validation.valid) {
983 throw new Error(`Invalid working directory: ${validation.error}`);
984 }
985 }
986 await onFirstMessage(message.trim(), selectedModel, selectedCwd || undefined);
987 } else if (conversationId) {
988 await api.sendMessage(conversationId, {
989 message: message.trim(),
990 model: selectedModel,
991 });
992 }
993 } catch (err) {
994 console.error("Failed to send message:", err);
995 const message = err instanceof Error ? err.message : "Unknown error";
996 setError(message);
997 setAgentWorking(false);
998 throw err; // Re-throw so MessageInput can preserve the text
999 } finally {
1000 setSending(false);
1001 }
1002 };
1003
1004 const scrollToBottom = () => {
1005 messagesEndRef.current?.scrollIntoView({ behavior: "instant" });
1006 userScrolledRef.current = false;
1007 setShowScrollToBottom(false);
1008 };
1009
1010 // Callback for terminals to insert text into the message input
1011 const handleInsertFromTerminal = useCallback((text: string) => {
1012 setTerminalInjectedText(text);
1013 }, []);
1014
1015 const handleManualReconnect = () => {
1016 if (!conversationId || eventSourceRef.current) return;
1017 setIsDisconnected(false);
1018 setIsReconnecting(false);
1019 setReconnectAttempts(0);
1020 if (reconnectTimeoutRef.current) {
1021 clearTimeout(reconnectTimeoutRef.current);
1022 reconnectTimeoutRef.current = null;
1023 }
1024 if (periodicRetryRef.current) {
1025 clearInterval(periodicRetryRef.current);
1026 periodicRetryRef.current = null;
1027 }
1028 setupMessageStream();
1029 };
1030
1031 // Update the reconnect ref - always attempt reconnect if connection is unhealthy
1032 useEffect(() => {
1033 reconnectRef.current = () => {
1034 if (!conversationId) return;
1035 // Always try to reconnect if there's no active connection
1036 if (!eventSourceRef.current || eventSourceRef.current.readyState === 2) {
1037 console.log("Reconnect triggered: no active connection");
1038 // Clear any pending reconnect attempts
1039 if (reconnectTimeoutRef.current) {
1040 clearTimeout(reconnectTimeoutRef.current);
1041 reconnectTimeoutRef.current = null;
1042 }
1043 if (periodicRetryRef.current) {
1044 clearInterval(periodicRetryRef.current);
1045 periodicRetryRef.current = null;
1046 }
1047 // Reset state and reconnect
1048 setIsDisconnected(false);
1049 setIsReconnecting(false);
1050 setReconnectAttempts(0);
1051 setupMessageStream();
1052 }
1053 };
1054 }, [conversationId]);
1055
1056 // Handle external trigger to open diff viewer
1057 useEffect(() => {
1058 if (openDiffViewerTrigger && openDiffViewerTrigger > 0) {
1059 setShowDiffViewer(true);
1060 }
1061 }, [openDiffViewerTrigger]);
1062
1063 const handleCancel = async () => {
1064 if (!conversationId || cancelling) return;
1065
1066 try {
1067 setCancelling(true);
1068 await api.cancelConversation(conversationId);
1069 setAgentWorking(false);
1070 } catch (err) {
1071 console.error("Failed to cancel conversation:", err);
1072 setError("Failed to cancel. Please try again.");
1073 } finally {
1074 setCancelling(false);
1075 }
1076 };
1077
1078 // Handler to continue conversation in a new one
1079 const handleContinueConversation = async () => {
1080 if (!conversationId || !onContinueConversation) return;
1081 await onContinueConversation(
1082 conversationId,
1083 selectedModel,
1084 currentConversation?.cwd || selectedCwd || undefined,
1085 );
1086 };
1087
1088 const getDisplayTitle = () => {
1089 return currentConversation?.slug || "Shelley";
1090 };
1091
1092 // Process messages to coalesce tool calls
1093 const processMessages = () => {
1094 if (messages.length === 0) {
1095 return [];
1096 }
1097
1098 interface CoalescedItem {
1099 type: "message" | "tool";
1100 message?: Message;
1101 toolUseId?: string;
1102 toolName?: string;
1103 toolInput?: unknown;
1104 toolResult?: LLMContent[];
1105 toolError?: boolean;
1106 toolStartTime?: string | null;
1107 toolEndTime?: string | null;
1108 hasResult?: boolean;
1109 display?: unknown;
1110 }
1111
1112 const coalescedItems: CoalescedItem[] = [];
1113 const toolResultMap: Record<
1114 string,
1115 {
1116 result: LLMContent[];
1117 error: boolean;
1118 startTime: string | null;
1119 endTime: string | null;
1120 }
1121 > = {};
1122 // Some tool results may be delivered only as display_data (e.g., screenshots)
1123 const displayResultSet: Set<string> = new Set();
1124 const displayDataMap: Record<string, unknown> = {};
1125
1126 // First pass: collect all tool results
1127 messages.forEach((message) => {
1128 // Collect tool_result data from llm_data if present
1129 if (message.llm_data) {
1130 try {
1131 const llmData =
1132 typeof message.llm_data === "string" ? JSON.parse(message.llm_data) : message.llm_data;
1133 if (llmData && llmData.Content && Array.isArray(llmData.Content)) {
1134 llmData.Content.forEach((content: LLMContent) => {
1135 if (content && content.Type === 6 && content.ToolUseID) {
1136 // tool_result
1137 toolResultMap[content.ToolUseID] = {
1138 result: content.ToolResult || [],
1139 error: content.ToolError || false,
1140 startTime: content.ToolUseStartTime || null,
1141 endTime: content.ToolUseEndTime || null,
1142 };
1143 }
1144 });
1145 }
1146 } catch (err) {
1147 console.error("Failed to parse message LLM data for tool results:", err);
1148 }
1149 }
1150
1151 // Also collect tool_use_ids from display_data to mark completion even if llm_data is omitted
1152 if (message.display_data) {
1153 try {
1154 const displays =
1155 typeof message.display_data === "string"
1156 ? JSON.parse(message.display_data)
1157 : message.display_data;
1158 if (Array.isArray(displays)) {
1159 for (const d of displays) {
1160 if (
1161 d &&
1162 typeof d === "object" &&
1163 "tool_use_id" in d &&
1164 typeof d.tool_use_id === "string"
1165 ) {
1166 displayResultSet.add(d.tool_use_id);
1167 // Store the display data for this tool use
1168 if ("display" in d) {
1169 displayDataMap[d.tool_use_id] = d.display;
1170 }
1171 }
1172 }
1173 }
1174 } catch (err) {
1175 console.error("Failed to parse display_data for tool completion:", err);
1176 }
1177 }
1178 });
1179
1180 // Second pass: process messages and extract tool uses
1181 messages.forEach((message) => {
1182 // Skip system messages
1183 if (message.type === "system") {
1184 return;
1185 }
1186
1187 if (message.type === "error") {
1188 coalescedItems.push({ type: "message", message });
1189 return;
1190 }
1191
1192 // Check if this is a user message with tool results (skip rendering them as messages)
1193 let hasToolResult = false;
1194 if (message.llm_data) {
1195 try {
1196 const llmData =
1197 typeof message.llm_data === "string" ? JSON.parse(message.llm_data) : message.llm_data;
1198 if (llmData && llmData.Content && Array.isArray(llmData.Content)) {
1199 hasToolResult = llmData.Content.some((c: LLMContent) => c.Type === 6);
1200 }
1201 } catch (err) {
1202 console.error("Failed to parse message LLM data:", err);
1203 }
1204 }
1205
1206 // If it's a user message without tool results, show it
1207 if (message.type === "user" && !hasToolResult) {
1208 coalescedItems.push({ type: "message", message });
1209 return;
1210 }
1211
1212 // If it's a user message with tool results, skip it (we'll handle it via the toolResultMap)
1213 if (message.type === "user" && hasToolResult) {
1214 return;
1215 }
1216
1217 if (message.llm_data) {
1218 try {
1219 const llmData =
1220 typeof message.llm_data === "string" ? JSON.parse(message.llm_data) : message.llm_data;
1221 if (llmData && llmData.Content && Array.isArray(llmData.Content)) {
1222 // Extract text content and tool uses separately
1223 const textContents: LLMContent[] = [];
1224 const toolUses: LLMContent[] = [];
1225
1226 llmData.Content.forEach((content: LLMContent) => {
1227 if (content.Type === 2) {
1228 // text
1229 textContents.push(content);
1230 } else if (content.Type === 5) {
1231 // tool_use
1232 toolUses.push(content);
1233 }
1234 });
1235
1236 // If we have text content, add it as a message (but only if it's not empty)
1237 const textString = textContents
1238 .map((c) => c.Text || "")
1239 .join("")
1240 .trim();
1241 if (textString) {
1242 coalescedItems.push({ type: "message", message });
1243 }
1244
1245 // Check if this message was truncated (tool calls lost)
1246 const wasTruncated = llmData.ExcludedFromContext === true;
1247
1248 // Add tool uses as separate items
1249 toolUses.forEach((toolUse) => {
1250 const resultData = toolUse.ID ? toolResultMap[toolUse.ID] : undefined;
1251 const completedViaDisplay = toolUse.ID ? displayResultSet.has(toolUse.ID) : false;
1252 const displayData = toolUse.ID ? displayDataMap[toolUse.ID] : undefined;
1253 coalescedItems.push({
1254 type: "tool",
1255 toolUseId: toolUse.ID,
1256 toolName: toolUse.ToolName,
1257 toolInput: toolUse.ToolInput,
1258 toolResult: resultData?.result,
1259 // Mark as error if truncated and no result
1260 toolError: resultData?.error || (wasTruncated && !resultData),
1261 toolStartTime: resultData?.startTime,
1262 toolEndTime: resultData?.endTime,
1263 // Mark as complete if truncated (tool was lost, not running)
1264 hasResult: !!resultData || completedViaDisplay || wasTruncated,
1265 display: displayData,
1266 });
1267 });
1268 }
1269 } catch (err) {
1270 console.error("Failed to parse message LLM data:", err);
1271 coalescedItems.push({ type: "message", message });
1272 }
1273 } else {
1274 coalescedItems.push({ type: "message", message });
1275 }
1276 });
1277
1278 return coalescedItems;
1279 };
1280
1281 const renderMessages = () => {
1282 // Build ephemeral terminal elements first - they should always render
1283 const terminalElements = ephemeralTerminals.map((terminal) => (
1284 <TerminalWidget
1285 key={terminal.id}
1286 command={terminal.command}
1287 cwd={terminal.cwd}
1288 onInsertIntoInput={handleInsertFromTerminal}
1289 onClose={() => setEphemeralTerminals((prev) => prev.filter((t) => t.id !== terminal.id))}
1290 />
1291 ));
1292
1293 if (messages.length === 0 && ephemeralTerminals.length === 0) {
1294 const proxyURL = `https://${hostname}/`;
1295 return (
1296 <div className="empty-state">
1297 <div className="empty-state-content">
1298 <p className="text-base" style={{ marginBottom: "1rem", lineHeight: "1.6" }}>
1299 Shelley is an agent, running on <strong>{hostname}</strong>. You can ask Shelley to do
1300 stuff. If you build a web site with Shelley, you can use exe.dev's proxy features
1301 (see{" "}
1302 <a
1303 href="https://exe.dev/docs/proxy"
1304 target="_blank"
1305 rel="noopener noreferrer"
1306 style={{ color: "var(--blue-text)", textDecoration: "underline" }}
1307 >
1308 docs
1309 </a>
1310 ) to visit it over the web at{" "}
1311 <a
1312 href={proxyURL}
1313 target="_blank"
1314 rel="noopener noreferrer"
1315 style={{ color: "var(--blue-text)", textDecoration: "underline" }}
1316 >
1317 {proxyURL}
1318 </a>
1319 .
1320 </p>
1321 {models.length === 0 ? (
1322 <div className="add-model-hint">
1323 <p className="text-sm" style={{ color: "var(--text-secondary)" }}>
1324 No AI models configured. Press <kbd>Ctrl</kbd>
1325 <span>+</span>
1326 <kbd>K</kbd> or <kbd>⌘</kbd>
1327 <span>+</span>
1328 <kbd>K</kbd> to add a model.
1329 </p>
1330 </div>
1331 ) : (
1332 <p className="text-sm" style={{ color: "var(--text-secondary)" }}>
1333 Send a message to start the conversation.
1334 </p>
1335 )}
1336 </div>
1337 </div>
1338 );
1339 }
1340
1341 // If we have terminals but no messages, just show terminals
1342 if (messages.length === 0) {
1343 return terminalElements;
1344 }
1345
1346 const coalescedItems = processMessages();
1347
1348 const rendered = coalescedItems.map((item, index) => {
1349 if (item.type === "message" && item.message) {
1350 return (
1351 <MessageComponent
1352 key={item.message.message_id}
1353 message={item.message}
1354 onOpenDiffViewer={(commit, cwd) => {
1355 setDiffViewerInitialCommit(commit);
1356 setDiffViewerCwd(cwd);
1357 setShowDiffViewer(true);
1358 }}
1359 onCommentTextChange={setDiffCommentText}
1360 />
1361 );
1362 } else if (item.type === "tool") {
1363 return (
1364 <CoalescedToolCall
1365 key={item.toolUseId || `tool-${index}`}
1366 toolName={item.toolName || "Unknown Tool"}
1367 toolInput={item.toolInput}
1368 toolResult={item.toolResult}
1369 toolError={item.toolError}
1370 toolStartTime={item.toolStartTime}
1371 toolEndTime={item.toolEndTime}
1372 hasResult={item.hasResult}
1373 display={item.display}
1374 onCommentTextChange={setDiffCommentText}
1375 />
1376 );
1377 }
1378 return null;
1379 });
1380
1381 // Find system message to render at the top
1382 const systemMessage = messages.find((m) => m.type === "system");
1383
1384 // Append ephemeral terminals at the end
1385 return [
1386 systemMessage && <SystemPromptView key="system-prompt" message={systemMessage} />,
1387 ...rendered,
1388 ...terminalElements,
1389 ];
1390 };
1391
1392 return (
1393 <div className="full-height flex flex-col">
1394 {/* Header */}
1395 <div className="header">
1396 <div className="header-left">
1397 <button
1398 onClick={onOpenDrawer}
1399 className="btn-icon hide-on-desktop"
1400 aria-label="Open conversations"
1401 >
1402 <svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
1403 <path
1404 strokeLinecap="round"
1405 strokeLinejoin="round"
1406 strokeWidth={2}
1407 d="M4 6h16M4 12h16M4 18h16"
1408 />
1409 </svg>
1410 </button>
1411
1412 {/* Expand drawer button - desktop only when collapsed */}
1413 {isDrawerCollapsed && onToggleDrawerCollapse && (
1414 <button
1415 onClick={onToggleDrawerCollapse}
1416 className="btn-icon show-on-desktop-only"
1417 aria-label="Expand sidebar"
1418 title="Expand sidebar"
1419 >
1420 <svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
1421 <path
1422 strokeLinecap="round"
1423 strokeLinejoin="round"
1424 strokeWidth={2}
1425 d="M13 5l7 7-7 7M5 5l7 7-7 7"
1426 />
1427 </svg>
1428 </button>
1429 )}
1430
1431 <h1 className="header-title" title={currentConversation?.slug || "Shelley"}>
1432 {getDisplayTitle()}
1433 </h1>
1434 </div>
1435
1436 <div className="header-actions">
1437 {/* Green + icon in circle for new conversation */}
1438 <button onClick={onNewConversation} className="btn-new" aria-label="New conversation">
1439 <svg
1440 fill="none"
1441 stroke="currentColor"
1442 viewBox="0 0 24 24"
1443 style={{ width: "1rem", height: "1rem" }}
1444 >
1445 <path
1446 strokeLinecap="round"
1447 strokeLinejoin="round"
1448 strokeWidth={2}
1449 d="M12 4v16m8-8H4"
1450 />
1451 </svg>
1452 </button>
1453
1454 {/* Overflow menu */}
1455 <div ref={overflowMenuRef} style={{ position: "relative" }}>
1456 <button
1457 onClick={() => setShowOverflowMenu(!showOverflowMenu)}
1458 className="btn-icon"
1459 aria-label="More options"
1460 >
1461 <svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
1462 <path
1463 strokeLinecap="round"
1464 strokeLinejoin="round"
1465 strokeWidth={2}
1466 d="M12 5v.01M12 12v.01M12 19v.01M12 6a1 1 0 110-2 1 1 0 010 2zm0 7a1 1 0 110-2 1 1 0 010 2zm0 7a1 1 0 110-2 1 1 0 010 2z"
1467 />
1468 </svg>
1469 {hasUpdate && <span className="version-update-dot" />}
1470 </button>
1471
1472 {showOverflowMenu && (
1473 <div className="overflow-menu">
1474 {/* Diffs button - show when we have a CWD */}
1475 {(currentConversation?.cwd || selectedCwd) && (
1476 <button
1477 onClick={() => {
1478 setShowOverflowMenu(false);
1479 setShowDiffViewer(true);
1480 }}
1481 className="overflow-menu-item"
1482 >
1483 <svg
1484 fill="none"
1485 stroke="currentColor"
1486 viewBox="0 0 24 24"
1487 style={{ width: "1.25rem", height: "1.25rem", marginRight: "0.75rem" }}
1488 >
1489 <path
1490 strokeLinecap="round"
1491 strokeLinejoin="round"
1492 strokeWidth={2}
1493 d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
1494 />
1495 </svg>
1496 Diffs
1497 </button>
1498 )}
1499 {terminalURL && (
1500 <button
1501 onClick={() => {
1502 setShowOverflowMenu(false);
1503 const cwd = currentConversation?.cwd || selectedCwd || "";
1504 const url = terminalURL.replace("WORKING_DIR", encodeURIComponent(cwd));
1505 window.open(url, "_blank");
1506 }}
1507 className="overflow-menu-item"
1508 >
1509 <svg
1510 fill="none"
1511 stroke="currentColor"
1512 viewBox="0 0 24 24"
1513 style={{ width: "1.25rem", height: "1.25rem", marginRight: "0.75rem" }}
1514 >
1515 <path
1516 strokeLinecap="round"
1517 strokeLinejoin="round"
1518 strokeWidth={2}
1519 d="M8 9l3 3-3 3m5 0h3M5 20h14a2 2 0 002-2V6a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"
1520 />
1521 </svg>
1522 Terminal
1523 </button>
1524 )}
1525 {links.map((link, index) => (
1526 <button
1527 key={index}
1528 onClick={() => {
1529 setShowOverflowMenu(false);
1530 window.open(link.url, "_blank");
1531 }}
1532 className="overflow-menu-item"
1533 >
1534 <svg
1535 fill="none"
1536 stroke="currentColor"
1537 viewBox="0 0 24 24"
1538 style={{ width: "1.25rem", height: "1.25rem", marginRight: "0.75rem" }}
1539 >
1540 <path
1541 strokeLinecap="round"
1542 strokeLinejoin="round"
1543 strokeWidth={2}
1544 d={
1545 link.icon_svg ||
1546 "M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"
1547 }
1548 />
1549 </svg>
1550 {link.title}
1551 </button>
1552 ))}
1553
1554 {/* Version check */}
1555 <div className="overflow-menu-divider" />
1556 <button
1557 onClick={() => {
1558 setShowOverflowMenu(false);
1559 openVersionModal();
1560 }}
1561 className="overflow-menu-item"
1562 >
1563 <svg
1564 fill="none"
1565 stroke="currentColor"
1566 viewBox="0 0 24 24"
1567 style={{ width: "1.25rem", height: "1.25rem", marginRight: "0.75rem" }}
1568 >
1569 <path
1570 strokeLinecap="round"
1571 strokeLinejoin="round"
1572 strokeWidth={2}
1573 d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
1574 />
1575 </svg>
1576 Check for New Version
1577 {hasUpdate && <span className="version-menu-dot" />}
1578 </button>
1579
1580 {/* Theme selector */}
1581 <div className="overflow-menu-divider" />
1582 <div className="theme-toggle-row">
1583 <button
1584 onClick={() => {
1585 setThemeMode("system");
1586 setStoredTheme("system");
1587 applyTheme("system");
1588 }}
1589 className={`theme-toggle-btn${themeMode === "system" ? " theme-toggle-btn-selected" : ""}`}
1590 title="System"
1591 >
1592 <svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
1593 <path
1594 strokeLinecap="round"
1595 strokeLinejoin="round"
1596 strokeWidth={2}
1597 d="M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"
1598 />
1599 </svg>
1600 </button>
1601 <button
1602 onClick={() => {
1603 setThemeMode("light");
1604 setStoredTheme("light");
1605 applyTheme("light");
1606 }}
1607 className={`theme-toggle-btn${themeMode === "light" ? " theme-toggle-btn-selected" : ""}`}
1608 title="Light"
1609 >
1610 <svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
1611 <path
1612 strokeLinecap="round"
1613 strokeLinejoin="round"
1614 strokeWidth={2}
1615 d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z"
1616 />
1617 </svg>
1618 </button>
1619 <button
1620 onClick={() => {
1621 setThemeMode("dark");
1622 setStoredTheme("dark");
1623 applyTheme("dark");
1624 }}
1625 className={`theme-toggle-btn${themeMode === "dark" ? " theme-toggle-btn-selected" : ""}`}
1626 title="Dark"
1627 >
1628 <svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
1629 <path
1630 strokeLinecap="round"
1631 strokeLinejoin="round"
1632 strokeWidth={2}
1633 d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z"
1634 />
1635 </svg>
1636 </button>
1637 </div>
1638 </div>
1639 )}
1640 </div>
1641 </div>
1642 </div>
1643
1644 {/* Messages area */}
1645 {/* Messages area with scroll-to-bottom button wrapper */}
1646 <div className="messages-area-wrapper">
1647 <div className="messages-container scrollable" ref={messagesContainerRef}>
1648 {loading ? (
1649 <div className="flex items-center justify-center full-height">
1650 <div className="spinner"></div>
1651 </div>
1652 ) : (
1653 <div className="messages-list">
1654 {renderMessages()}
1655
1656 <div ref={messagesEndRef} />
1657 </div>
1658 )}
1659 </div>
1660
1661 {/* Scroll to bottom button - outside scrollable area */}
1662 {showScrollToBottom && (
1663 <button
1664 className="scroll-to-bottom-button"
1665 onClick={scrollToBottom}
1666 aria-label="Scroll to bottom"
1667 >
1668 <svg
1669 fill="none"
1670 stroke="currentColor"
1671 viewBox="0 0 24 24"
1672 style={{ width: "1.25rem", height: "1.25rem" }}
1673 >
1674 <path
1675 strokeLinecap="round"
1676 strokeLinejoin="round"
1677 strokeWidth={2}
1678 d="M19 14l-7 7m0 0l-7-7m7 7V3"
1679 />
1680 </svg>
1681 </button>
1682 )}
1683 </div>
1684
1685 {/* Unified Status Bar */}
1686 <div className="status-bar">
1687 <div className="status-bar-content">
1688 {isDisconnected ? (
1689 // Disconnected state
1690 <>
1691 <span className="status-message status-warning">Disconnected</span>
1692 <button
1693 onClick={handleManualReconnect}
1694 className="status-button status-button-primary"
1695 >
1696 Retry
1697 </button>
1698 </>
1699 ) : isReconnecting ? (
1700 // Reconnecting state - show during backoff attempts
1701 <>
1702 <span className="status-message status-reconnecting">
1703 Reconnecting{reconnectAttempts > 0 ? ` (${reconnectAttempts}/3)` : ""}
1704 <span className="reconnecting-dots">...</span>
1705 </span>
1706 </>
1707 ) : error ? (
1708 // Error state
1709 <>
1710 <span className="status-message status-error">{error}</span>
1711 <button onClick={() => setError(null)} className="status-button status-button-text">
1712 <svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
1713 <path
1714 strokeLinecap="round"
1715 strokeLinejoin="round"
1716 strokeWidth={2}
1717 d="M6 18L18 6M6 6l12 12"
1718 />
1719 </svg>
1720 </button>
1721 </>
1722 ) : agentWorking && conversationId ? (
1723 // Agent working - show status with stop button and context bar
1724 <div className="status-bar-active">
1725 <div className="status-working-group">
1726 <AnimatedWorkingStatus />
1727 <button
1728 onClick={handleCancel}
1729 disabled={cancelling}
1730 className="status-stop-button"
1731 title={cancelling ? "Cancelling..." : "Stop"}
1732 >
1733 <svg viewBox="0 0 24 24" fill="currentColor">
1734 <rect x="6" y="6" width="12" height="12" rx="1" />
1735 </svg>
1736 <span className="status-stop-label">{cancelling ? "Cancelling..." : "Stop"}</span>
1737 </button>
1738 </div>
1739 <ContextUsageBar
1740 contextWindowSize={contextWindowSize}
1741 maxContextTokens={
1742 models.find((m) => m.id === selectedModel)?.max_context_tokens || 200000
1743 }
1744 conversationId={conversationId}
1745 onContinueConversation={
1746 onContinueConversation ? handleContinueConversation : undefined
1747 }
1748 />
1749 </div>
1750 ) : // Idle state - show ready message, or configuration for empty conversation
1751 !conversationId ? (
1752 // Empty conversation - show model (left) and cwd (right)
1753 <div className="status-bar-new-conversation">
1754 {/* Model selector - far left */}
1755 <div
1756 className="status-field status-field-model"
1757 title="AI model to use for this conversation"
1758 >
1759 <span className="status-field-label">Model:</span>
1760 <ModelPicker
1761 models={models}
1762 selectedModel={selectedModel}
1763 onSelectModel={setSelectedModel}
1764 onManageModels={() => onOpenModelsModal?.()}
1765 disabled={sending}
1766 />
1767 </div>
1768
1769 {/* CWD indicator - far right */}
1770 <div
1771 className={`status-field status-field-cwd${cwdError ? " status-field-error" : ""}`}
1772 title={cwdError || "Working directory for file operations"}
1773 >
1774 <span className="status-field-label">Dir:</span>
1775 <button
1776 className={`status-chip${cwdError ? " status-chip-error" : ""}`}
1777 onClick={() => setShowDirectoryPicker(true)}
1778 disabled={sending}
1779 >
1780 {selectedCwd || "(no cwd)"}
1781 </button>
1782 </div>
1783 </div>
1784 ) : (
1785 // Active conversation - show Ready + context bar
1786 <div className="status-bar-active">
1787 <span className="status-message status-ready">Ready on {hostname}</span>
1788 <ContextUsageBar
1789 contextWindowSize={contextWindowSize}
1790 maxContextTokens={
1791 models.find((m) => m.id === selectedModel)?.max_context_tokens || 200000
1792 }
1793 conversationId={conversationId}
1794 onContinueConversation={
1795 onContinueConversation ? handleContinueConversation : undefined
1796 }
1797 />
1798 </div>
1799 )}
1800 </div>
1801 </div>
1802
1803 {/* Message input */}
1804 <MessageInput
1805 key={conversationId || "new"}
1806 onSend={sendMessage}
1807 disabled={sending || loading}
1808 autoFocus={true}
1809 injectedText={terminalInjectedText || diffCommentText}
1810 onClearInjectedText={() => {
1811 setDiffCommentText("");
1812 setTerminalInjectedText(null);
1813 }}
1814 persistKey={conversationId || "new-conversation"}
1815 />
1816
1817 {/* Directory Picker Modal */}
1818 <DirectoryPickerModal
1819 isOpen={showDirectoryPicker}
1820 onClose={() => setShowDirectoryPicker(false)}
1821 onSelect={(path) => {
1822 setSelectedCwd(path);
1823 setCwdError(null);
1824 }}
1825 initialPath={selectedCwd}
1826 />
1827
1828 {/* Diff Viewer */}
1829 <DiffViewer
1830 cwd={diffViewerCwd || currentConversation?.cwd || selectedCwd}
1831 isOpen={showDiffViewer}
1832 onClose={() => {
1833 setShowDiffViewer(false);
1834 setDiffViewerInitialCommit(undefined);
1835 setDiffViewerCwd(undefined);
1836 }}
1837 onCommentTextChange={setDiffCommentText}
1838 initialCommit={diffViewerInitialCommit}
1839 onCwdChange={setDiffViewerCwd}
1840 />
1841
1842 {/* Version Checker Modal */}
1843 {VersionModal}
1844 </div>
1845 );
1846}
1847
1848export default ChatInterface;