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 [diffCommentText, setDiffCommentText] = useState("");
595 const [agentWorking, setAgentWorking] = useState(false);
596 const [cancelling, setCancelling] = useState(false);
597 const [contextWindowSize, setContextWindowSize] = useState(0);
598 const terminalURL = window.__SHELLEY_INIT__?.terminal_url || null;
599 const links = window.__SHELLEY_INIT__?.links || [];
600 const hostname = window.__SHELLEY_INIT__?.hostname || "localhost";
601 const { hasUpdate, openModal: openVersionModal, VersionModal } = useVersionChecker();
602 const [, setReconnectAttempts] = useState(0);
603 const [isDisconnected, setIsDisconnected] = useState(false);
604 const [showScrollToBottom, setShowScrollToBottom] = useState(false);
605 // Ephemeral terminals are local-only and not persisted to the database
606 const [ephemeralTerminals, setEphemeralTerminals] = useState<EphemeralTerminal[]>([]);
607 const [terminalInjectedText, setTerminalInjectedText] = useState<string | null>(null);
608 const messagesEndRef = useRef<HTMLDivElement>(null);
609 const messagesContainerRef = useRef<HTMLDivElement>(null);
610 const eventSourceRef = useRef<EventSource | null>(null);
611 const overflowMenuRef = useRef<HTMLDivElement>(null);
612 const reconnectTimeoutRef = useRef<number | null>(null);
613 const periodicRetryRef = useRef<number | null>(null);
614 const heartbeatTimeoutRef = useRef<number | null>(null);
615 const lastSequenceIdRef = useRef<number>(-1);
616 const userScrolledRef = useRef(false);
617
618 // Load messages and set up streaming
619 useEffect(() => {
620 // Clear ephemeral terminals when conversation changes
621 setEphemeralTerminals([]);
622
623 if (conversationId) {
624 setAgentWorking(false);
625 loadMessages();
626 setupMessageStream();
627 } else {
628 // No conversation yet, show empty state
629 setMessages([]);
630 setContextWindowSize(0);
631 setLoading(false);
632 }
633
634 return () => {
635 if (eventSourceRef.current) {
636 eventSourceRef.current.close();
637 }
638 if (reconnectTimeoutRef.current) {
639 clearTimeout(reconnectTimeoutRef.current);
640 }
641 if (periodicRetryRef.current) {
642 clearInterval(periodicRetryRef.current);
643 }
644 if (heartbeatTimeoutRef.current) {
645 clearTimeout(heartbeatTimeoutRef.current);
646 }
647 // Reset sequence ID when conversation changes
648 lastSequenceIdRef.current = -1;
649 };
650 }, [conversationId]);
651
652 // Update favicon when agent working state changes
653 useEffect(() => {
654 setFaviconStatus(agentWorking ? "working" : "ready");
655 }, [agentWorking]);
656
657 // Check scroll position and handle scroll-to-bottom button
658 useEffect(() => {
659 const container = messagesContainerRef.current;
660 if (!container) return;
661
662 const handleScroll = () => {
663 const { scrollTop, scrollHeight, clientHeight } = container;
664 const isNearBottom = scrollHeight - scrollTop - clientHeight < 100;
665 setShowScrollToBottom(!isNearBottom);
666 userScrolledRef.current = !isNearBottom;
667 };
668
669 container.addEventListener("scroll", handleScroll);
670 return () => container.removeEventListener("scroll", handleScroll);
671 }, []);
672
673 // Auto-scroll to bottom when new messages arrive (only if user is already at bottom)
674 useEffect(() => {
675 if (!userScrolledRef.current) {
676 scrollToBottom();
677 }
678 }, [messages]);
679
680 // Close overflow menu when clicking outside
681 useEffect(() => {
682 const handleClickOutside = (event: MouseEvent) => {
683 if (overflowMenuRef.current && !overflowMenuRef.current.contains(event.target as Node)) {
684 setShowOverflowMenu(false);
685 }
686 };
687
688 if (showOverflowMenu) {
689 document.addEventListener("mousedown", handleClickOutside);
690 return () => {
691 document.removeEventListener("mousedown", handleClickOutside);
692 };
693 }
694 }, [showOverflowMenu]);
695
696 // Reconnect when page becomes visible, focused, or online
697 // Store reconnect function in a ref so event listeners always have the latest version
698 const reconnectRef = useRef<() => void>(() => {});
699
700 useEffect(() => {
701 const handleVisibilityChange = () => {
702 if (document.visibilityState === "visible") {
703 reconnectRef.current();
704 }
705 };
706
707 const handleFocus = () => {
708 reconnectRef.current();
709 };
710
711 const handleOnline = () => {
712 reconnectRef.current();
713 };
714
715 document.addEventListener("visibilitychange", handleVisibilityChange);
716 window.addEventListener("focus", handleFocus);
717 window.addEventListener("online", handleOnline);
718
719 return () => {
720 document.removeEventListener("visibilitychange", handleVisibilityChange);
721 window.removeEventListener("focus", handleFocus);
722 window.removeEventListener("online", handleOnline);
723 };
724 }, []);
725
726 const loadMessages = async () => {
727 if (!conversationId) return;
728 try {
729 setLoading(true);
730 setError(null);
731 const response = await api.getConversation(conversationId);
732 setMessages(response.messages ?? []);
733 // ConversationState is sent via the streaming endpoint, not on initial load
734 // We don't update agentWorking here - the stream will provide the current state
735 // Always update context window size when loading a conversation.
736 // If omitted from response (due to omitempty when 0), default to 0.
737 setContextWindowSize(response.context_window_size ?? 0);
738 if (onConversationUpdate) {
739 onConversationUpdate(response.conversation);
740 }
741 } catch (err) {
742 console.error("Failed to load messages:", err);
743 setError("Failed to load messages");
744 } finally {
745 // Always set loading to false, even if other operations fail
746 setLoading(false);
747 }
748 };
749
750 // Reset heartbeat timeout - called on every message received
751 const resetHeartbeatTimeout = () => {
752 if (heartbeatTimeoutRef.current) {
753 clearTimeout(heartbeatTimeoutRef.current);
754 }
755 // If we don't receive any message (including heartbeat) within 60 seconds, reconnect
756 heartbeatTimeoutRef.current = window.setTimeout(() => {
757 console.warn("No heartbeat received in 60 seconds, reconnecting...");
758 if (eventSourceRef.current) {
759 eventSourceRef.current.close();
760 eventSourceRef.current = null;
761 }
762 setupMessageStream();
763 }, 60000);
764 };
765
766 const setupMessageStream = () => {
767 if (!conversationId) return;
768
769 if (eventSourceRef.current) {
770 eventSourceRef.current.close();
771 }
772
773 // Clear any existing heartbeat timeout
774 if (heartbeatTimeoutRef.current) {
775 clearTimeout(heartbeatTimeoutRef.current);
776 }
777
778 // Use last_sequence_id to resume from where we left off (avoids resending all messages)
779 const lastSeqId = lastSequenceIdRef.current;
780 const eventSource = api.createMessageStream(
781 conversationId,
782 lastSeqId >= 0 ? lastSeqId : undefined,
783 );
784 eventSourceRef.current = eventSource;
785
786 eventSource.onmessage = (event) => {
787 // Reset heartbeat timeout on every message
788 resetHeartbeatTimeout();
789
790 try {
791 const streamResponse: StreamResponse = JSON.parse(event.data);
792 const incomingMessages = Array.isArray(streamResponse.messages)
793 ? streamResponse.messages
794 : [];
795
796 // Track the latest sequence ID for reconnection
797 if (incomingMessages.length > 0) {
798 const maxSeqId = Math.max(...incomingMessages.map((m) => m.sequence_id));
799 if (maxSeqId > lastSequenceIdRef.current) {
800 lastSequenceIdRef.current = maxSeqId;
801 }
802 }
803
804 // Merge new messages without losing existing ones.
805 // If no new messages (e.g., only conversation/slug update or heartbeat), keep existing list.
806 if (incomingMessages.length > 0) {
807 setMessages((prev) => {
808 const byId = new Map<string, Message>();
809 for (const m of prev) byId.set(m.message_id, m);
810 for (const m of incomingMessages) byId.set(m.message_id, m);
811 // Preserve original order, then append truly new ones in the order received
812 const result: Message[] = [];
813 for (const m of prev) result.push(byId.get(m.message_id)!);
814 for (const m of incomingMessages) {
815 if (!prev.find((p) => p.message_id === m.message_id)) result.push(m);
816 }
817 return result;
818 });
819 }
820
821 // Update conversation data if provided
822 if (onConversationUpdate && streamResponse.conversation) {
823 onConversationUpdate(streamResponse.conversation);
824 }
825
826 // Handle conversation list updates (for other conversations)
827 if (onConversationListUpdate && streamResponse.conversation_list_update) {
828 onConversationListUpdate(streamResponse.conversation_list_update);
829 }
830
831 // Handle conversation state updates (explicit from server)
832 if (streamResponse.conversation_state) {
833 // Update the conversations list with new working state
834 if (onConversationStateUpdate) {
835 onConversationStateUpdate(streamResponse.conversation_state);
836 }
837 // Update local state if this is for our conversation
838 if (streamResponse.conversation_state.conversation_id === conversationId) {
839 setAgentWorking(streamResponse.conversation_state.working);
840 // Update selected model from conversation (ensures consistency across sessions)
841 if (streamResponse.conversation_state.model) {
842 setSelectedModel(streamResponse.conversation_state.model);
843 }
844 }
845 }
846
847 if (typeof streamResponse.context_window_size === "number") {
848 setContextWindowSize(streamResponse.context_window_size);
849 }
850 } catch (err) {
851 console.error("Failed to parse message stream data:", err);
852 }
853 };
854
855 eventSource.onerror = (event) => {
856 console.warn("Message stream error (will retry):", event);
857 // Close and retry after a delay
858 if (eventSourceRef.current) {
859 eventSourceRef.current.close();
860 eventSourceRef.current = null;
861 }
862
863 // Clear heartbeat timeout on error
864 if (heartbeatTimeoutRef.current) {
865 clearTimeout(heartbeatTimeoutRef.current);
866 heartbeatTimeoutRef.current = null;
867 }
868
869 // Backoff delays: 1s, 2s, 5s, then show disconnected but keep retrying periodically
870 const delays = [1000, 2000, 5000];
871
872 setReconnectAttempts((prev) => {
873 const attempts = prev + 1;
874
875 if (attempts > delays.length) {
876 // Show disconnected UI but start periodic retry every 30 seconds
877 setIsDisconnected(true);
878 if (!periodicRetryRef.current) {
879 periodicRetryRef.current = window.setInterval(() => {
880 if (eventSourceRef.current === null) {
881 console.log("Periodic reconnect attempt");
882 setupMessageStream();
883 }
884 }, 30000);
885 }
886 return attempts;
887 }
888
889 const delay = delays[attempts - 1];
890 console.log(`Reconnecting in ${delay}ms (attempt ${attempts}/${delays.length})`);
891
892 reconnectTimeoutRef.current = window.setTimeout(() => {
893 if (eventSourceRef.current === null) {
894 setupMessageStream();
895 }
896 }, delay);
897
898 return attempts;
899 });
900 };
901
902 eventSource.onopen = () => {
903 console.log("Message stream connected");
904 // Reset reconnect attempts and clear periodic retry on successful connection
905 setReconnectAttempts(0);
906 setIsDisconnected(false);
907 if (periodicRetryRef.current) {
908 clearInterval(periodicRetryRef.current);
909 periodicRetryRef.current = null;
910 }
911 // Start heartbeat timeout monitoring
912 resetHeartbeatTimeout();
913 };
914 };
915
916 const sendMessage = async (message: string) => {
917 if (!message.trim() || sending) return;
918
919 // Check if this is a shell command (starts with "!")
920 const trimmedMessage = message.trim();
921 if (trimmedMessage.startsWith("!")) {
922 const shellCommand = trimmedMessage.slice(1).trim();
923 if (shellCommand) {
924 // Create an ephemeral terminal
925 const terminal: EphemeralTerminal = {
926 id: `term-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`,
927 command: shellCommand,
928 cwd: selectedCwd || window.__SHELLEY_INIT__?.default_cwd || "/",
929 createdAt: new Date(),
930 };
931 setEphemeralTerminals((prev) => [...prev, terminal]);
932 // Scroll to bottom to show the new terminal
933 setTimeout(() => scrollToBottom(), 100);
934 }
935 return;
936 }
937
938 try {
939 setSending(true);
940 setError(null);
941 setAgentWorking(true);
942
943 // If no conversation ID, this is the first message - validate cwd first
944 if (!conversationId && onFirstMessage) {
945 // Validate cwd if provided
946 if (selectedCwd) {
947 const validation = await api.validateCwd(selectedCwd);
948 if (!validation.valid) {
949 throw new Error(`Invalid working directory: ${validation.error}`);
950 }
951 }
952 await onFirstMessage(message.trim(), selectedModel, selectedCwd || undefined);
953 } else if (conversationId) {
954 await api.sendMessage(conversationId, {
955 message: message.trim(),
956 model: selectedModel,
957 });
958 }
959 } catch (err) {
960 console.error("Failed to send message:", err);
961 const message = err instanceof Error ? err.message : "Unknown error";
962 setError(message);
963 setAgentWorking(false);
964 throw err; // Re-throw so MessageInput can preserve the text
965 } finally {
966 setSending(false);
967 }
968 };
969
970 const scrollToBottom = () => {
971 messagesEndRef.current?.scrollIntoView({ behavior: "instant" });
972 userScrolledRef.current = false;
973 setShowScrollToBottom(false);
974 };
975
976 // Callback for terminals to insert text into the message input
977 const handleInsertFromTerminal = useCallback((text: string) => {
978 setTerminalInjectedText(text);
979 }, []);
980
981 const handleManualReconnect = () => {
982 if (!conversationId || eventSourceRef.current) return;
983 setIsDisconnected(false);
984 setReconnectAttempts(0);
985 if (reconnectTimeoutRef.current) {
986 clearTimeout(reconnectTimeoutRef.current);
987 reconnectTimeoutRef.current = null;
988 }
989 if (periodicRetryRef.current) {
990 clearInterval(periodicRetryRef.current);
991 periodicRetryRef.current = null;
992 }
993 setupMessageStream();
994 };
995
996 // Update the reconnect ref when isDisconnected or conversationId changes
997 useEffect(() => {
998 reconnectRef.current = () => {
999 if (isDisconnected && conversationId && !eventSourceRef.current) {
1000 console.log("Visibility/focus/online triggered reconnect attempt");
1001 handleManualReconnect();
1002 }
1003 };
1004 }, [isDisconnected, conversationId]);
1005
1006 // Handle external trigger to open diff viewer
1007 useEffect(() => {
1008 if (openDiffViewerTrigger && openDiffViewerTrigger > 0) {
1009 setShowDiffViewer(true);
1010 }
1011 }, [openDiffViewerTrigger]);
1012
1013 const handleCancel = async () => {
1014 if (!conversationId || cancelling) return;
1015
1016 try {
1017 setCancelling(true);
1018 await api.cancelConversation(conversationId);
1019 setAgentWorking(false);
1020 } catch (err) {
1021 console.error("Failed to cancel conversation:", err);
1022 setError("Failed to cancel. Please try again.");
1023 } finally {
1024 setCancelling(false);
1025 }
1026 };
1027
1028 // Handler to continue conversation in a new one
1029 const handleContinueConversation = async () => {
1030 if (!conversationId || !onContinueConversation) return;
1031 await onContinueConversation(
1032 conversationId,
1033 selectedModel,
1034 currentConversation?.cwd || selectedCwd || undefined,
1035 );
1036 };
1037
1038 const getDisplayTitle = () => {
1039 return currentConversation?.slug || "Shelley";
1040 };
1041
1042 // Process messages to coalesce tool calls
1043 const processMessages = () => {
1044 if (messages.length === 0) {
1045 return [];
1046 }
1047
1048 interface CoalescedItem {
1049 type: "message" | "tool";
1050 message?: Message;
1051 toolUseId?: string;
1052 toolName?: string;
1053 toolInput?: unknown;
1054 toolResult?: LLMContent[];
1055 toolError?: boolean;
1056 toolStartTime?: string | null;
1057 toolEndTime?: string | null;
1058 hasResult?: boolean;
1059 display?: unknown;
1060 }
1061
1062 const coalescedItems: CoalescedItem[] = [];
1063 const toolResultMap: Record<
1064 string,
1065 {
1066 result: LLMContent[];
1067 error: boolean;
1068 startTime: string | null;
1069 endTime: string | null;
1070 }
1071 > = {};
1072 // Some tool results may be delivered only as display_data (e.g., screenshots)
1073 const displayResultSet: Set<string> = new Set();
1074 const displayDataMap: Record<string, unknown> = {};
1075
1076 // First pass: collect all tool results
1077 messages.forEach((message) => {
1078 // Collect tool_result data from llm_data if present
1079 if (message.llm_data) {
1080 try {
1081 const llmData =
1082 typeof message.llm_data === "string" ? JSON.parse(message.llm_data) : message.llm_data;
1083 if (llmData && llmData.Content && Array.isArray(llmData.Content)) {
1084 llmData.Content.forEach((content: LLMContent) => {
1085 if (content && content.Type === 6 && content.ToolUseID) {
1086 // tool_result
1087 toolResultMap[content.ToolUseID] = {
1088 result: content.ToolResult || [],
1089 error: content.ToolError || false,
1090 startTime: content.ToolUseStartTime || null,
1091 endTime: content.ToolUseEndTime || null,
1092 };
1093 }
1094 });
1095 }
1096 } catch (err) {
1097 console.error("Failed to parse message LLM data for tool results:", err);
1098 }
1099 }
1100
1101 // Also collect tool_use_ids from display_data to mark completion even if llm_data is omitted
1102 if (message.display_data) {
1103 try {
1104 const displays =
1105 typeof message.display_data === "string"
1106 ? JSON.parse(message.display_data)
1107 : message.display_data;
1108 if (Array.isArray(displays)) {
1109 for (const d of displays) {
1110 if (
1111 d &&
1112 typeof d === "object" &&
1113 "tool_use_id" in d &&
1114 typeof d.tool_use_id === "string"
1115 ) {
1116 displayResultSet.add(d.tool_use_id);
1117 // Store the display data for this tool use
1118 if ("display" in d) {
1119 displayDataMap[d.tool_use_id] = d.display;
1120 }
1121 }
1122 }
1123 }
1124 } catch (err) {
1125 console.error("Failed to parse display_data for tool completion:", err);
1126 }
1127 }
1128 });
1129
1130 // Second pass: process messages and extract tool uses
1131 messages.forEach((message) => {
1132 // Skip system messages
1133 if (message.type === "system") {
1134 return;
1135 }
1136
1137 if (message.type === "error") {
1138 coalescedItems.push({ type: "message", message });
1139 return;
1140 }
1141
1142 // Check if this is a user message with tool results (skip rendering them as messages)
1143 let hasToolResult = false;
1144 if (message.llm_data) {
1145 try {
1146 const llmData =
1147 typeof message.llm_data === "string" ? JSON.parse(message.llm_data) : message.llm_data;
1148 if (llmData && llmData.Content && Array.isArray(llmData.Content)) {
1149 hasToolResult = llmData.Content.some((c: LLMContent) => c.Type === 6);
1150 }
1151 } catch (err) {
1152 console.error("Failed to parse message LLM data:", err);
1153 }
1154 }
1155
1156 // If it's a user message without tool results, show it
1157 if (message.type === "user" && !hasToolResult) {
1158 coalescedItems.push({ type: "message", message });
1159 return;
1160 }
1161
1162 // If it's a user message with tool results, skip it (we'll handle it via the toolResultMap)
1163 if (message.type === "user" && hasToolResult) {
1164 return;
1165 }
1166
1167 if (message.llm_data) {
1168 try {
1169 const llmData =
1170 typeof message.llm_data === "string" ? JSON.parse(message.llm_data) : message.llm_data;
1171 if (llmData && llmData.Content && Array.isArray(llmData.Content)) {
1172 // Extract text content and tool uses separately
1173 const textContents: LLMContent[] = [];
1174 const toolUses: LLMContent[] = [];
1175
1176 llmData.Content.forEach((content: LLMContent) => {
1177 if (content.Type === 2) {
1178 // text
1179 textContents.push(content);
1180 } else if (content.Type === 5) {
1181 // tool_use
1182 toolUses.push(content);
1183 }
1184 });
1185
1186 // If we have text content, add it as a message (but only if it's not empty)
1187 const textString = textContents
1188 .map((c) => c.Text || "")
1189 .join("")
1190 .trim();
1191 if (textString) {
1192 coalescedItems.push({ type: "message", message });
1193 }
1194
1195 // Check if this message was truncated (tool calls lost)
1196 const wasTruncated = llmData.ExcludedFromContext === true;
1197
1198 // Add tool uses as separate items
1199 toolUses.forEach((toolUse) => {
1200 const resultData = toolUse.ID ? toolResultMap[toolUse.ID] : undefined;
1201 const completedViaDisplay = toolUse.ID ? displayResultSet.has(toolUse.ID) : false;
1202 const displayData = toolUse.ID ? displayDataMap[toolUse.ID] : undefined;
1203 coalescedItems.push({
1204 type: "tool",
1205 toolUseId: toolUse.ID,
1206 toolName: toolUse.ToolName,
1207 toolInput: toolUse.ToolInput,
1208 toolResult: resultData?.result,
1209 // Mark as error if truncated and no result
1210 toolError: resultData?.error || (wasTruncated && !resultData),
1211 toolStartTime: resultData?.startTime,
1212 toolEndTime: resultData?.endTime,
1213 // Mark as complete if truncated (tool was lost, not running)
1214 hasResult: !!resultData || completedViaDisplay || wasTruncated,
1215 display: displayData,
1216 });
1217 });
1218 }
1219 } catch (err) {
1220 console.error("Failed to parse message LLM data:", err);
1221 coalescedItems.push({ type: "message", message });
1222 }
1223 } else {
1224 coalescedItems.push({ type: "message", message });
1225 }
1226 });
1227
1228 return coalescedItems;
1229 };
1230
1231 const renderMessages = () => {
1232 // Build ephemeral terminal elements first - they should always render
1233 const terminalElements = ephemeralTerminals.map((terminal) => (
1234 <TerminalWidget
1235 key={terminal.id}
1236 command={terminal.command}
1237 cwd={terminal.cwd}
1238 onInsertIntoInput={handleInsertFromTerminal}
1239 onClose={() => setEphemeralTerminals((prev) => prev.filter((t) => t.id !== terminal.id))}
1240 />
1241 ));
1242
1243 if (messages.length === 0 && ephemeralTerminals.length === 0) {
1244 const proxyURL = `https://${hostname}/`;
1245 return (
1246 <div className="empty-state">
1247 <div className="empty-state-content">
1248 <p className="text-base" style={{ marginBottom: "1rem", lineHeight: "1.6" }}>
1249 Shelley is an agent, running on <strong>{hostname}</strong>. You can ask Shelley to do
1250 stuff. If you build a web site with Shelley, you can use exe.dev's proxy features
1251 (see{" "}
1252 <a
1253 href="https://exe.dev/docs/proxy"
1254 target="_blank"
1255 rel="noopener noreferrer"
1256 style={{ color: "var(--blue-text)", textDecoration: "underline" }}
1257 >
1258 docs
1259 </a>
1260 ) to visit it over the web at{" "}
1261 <a
1262 href={proxyURL}
1263 target="_blank"
1264 rel="noopener noreferrer"
1265 style={{ color: "var(--blue-text)", textDecoration: "underline" }}
1266 >
1267 {proxyURL}
1268 </a>
1269 .
1270 </p>
1271 {models.length === 0 ? (
1272 <div className="add-model-hint">
1273 <p className="text-sm" style={{ color: "var(--text-secondary)" }}>
1274 No AI models configured. Press <kbd>Ctrl</kbd>
1275 <span>+</span>
1276 <kbd>K</kbd> or <kbd>⌘</kbd>
1277 <span>+</span>
1278 <kbd>K</kbd> to add a model.
1279 </p>
1280 </div>
1281 ) : (
1282 <p className="text-sm" style={{ color: "var(--text-secondary)" }}>
1283 Send a message to start the conversation.
1284 </p>
1285 )}
1286 </div>
1287 </div>
1288 );
1289 }
1290
1291 // If we have terminals but no messages, just show terminals
1292 if (messages.length === 0) {
1293 return terminalElements;
1294 }
1295
1296 const coalescedItems = processMessages();
1297
1298 const rendered = coalescedItems.map((item, index) => {
1299 if (item.type === "message" && item.message) {
1300 return (
1301 <MessageComponent
1302 key={item.message.message_id}
1303 message={item.message}
1304 onOpenDiffViewer={(commit) => {
1305 setDiffViewerInitialCommit(commit);
1306 setShowDiffViewer(true);
1307 }}
1308 onCommentTextChange={setDiffCommentText}
1309 />
1310 );
1311 } else if (item.type === "tool") {
1312 return (
1313 <CoalescedToolCall
1314 key={item.toolUseId || `tool-${index}`}
1315 toolName={item.toolName || "Unknown Tool"}
1316 toolInput={item.toolInput}
1317 toolResult={item.toolResult}
1318 toolError={item.toolError}
1319 toolStartTime={item.toolStartTime}
1320 toolEndTime={item.toolEndTime}
1321 hasResult={item.hasResult}
1322 display={item.display}
1323 onCommentTextChange={setDiffCommentText}
1324 />
1325 );
1326 }
1327 return null;
1328 });
1329
1330 // Find system message to render at the top
1331 const systemMessage = messages.find((m) => m.type === "system");
1332
1333 // Append ephemeral terminals at the end
1334 return [
1335 systemMessage && <SystemPromptView key="system-prompt" message={systemMessage} />,
1336 ...rendered,
1337 ...terminalElements,
1338 ];
1339 };
1340
1341 return (
1342 <div className="full-height flex flex-col">
1343 {/* Header */}
1344 <div className="header">
1345 <div className="header-left">
1346 <button
1347 onClick={onOpenDrawer}
1348 className="btn-icon hide-on-desktop"
1349 aria-label="Open conversations"
1350 >
1351 <svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
1352 <path
1353 strokeLinecap="round"
1354 strokeLinejoin="round"
1355 strokeWidth={2}
1356 d="M4 6h16M4 12h16M4 18h16"
1357 />
1358 </svg>
1359 </button>
1360
1361 {/* Expand drawer button - desktop only when collapsed */}
1362 {isDrawerCollapsed && onToggleDrawerCollapse && (
1363 <button
1364 onClick={onToggleDrawerCollapse}
1365 className="btn-icon show-on-desktop-only"
1366 aria-label="Expand sidebar"
1367 title="Expand sidebar"
1368 >
1369 <svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
1370 <path
1371 strokeLinecap="round"
1372 strokeLinejoin="round"
1373 strokeWidth={2}
1374 d="M13 5l7 7-7 7M5 5l7 7-7 7"
1375 />
1376 </svg>
1377 </button>
1378 )}
1379
1380 <h1 className="header-title" title={currentConversation?.slug || "Shelley"}>
1381 {getDisplayTitle()}
1382 </h1>
1383 </div>
1384
1385 <div className="header-actions">
1386 {/* Green + icon in circle for new conversation */}
1387 <button onClick={onNewConversation} className="btn-new" aria-label="New conversation">
1388 <svg
1389 fill="none"
1390 stroke="currentColor"
1391 viewBox="0 0 24 24"
1392 style={{ width: "1rem", height: "1rem" }}
1393 >
1394 <path
1395 strokeLinecap="round"
1396 strokeLinejoin="round"
1397 strokeWidth={2}
1398 d="M12 4v16m8-8H4"
1399 />
1400 </svg>
1401 </button>
1402
1403 {/* Overflow menu */}
1404 <div ref={overflowMenuRef} style={{ position: "relative" }}>
1405 <button
1406 onClick={() => setShowOverflowMenu(!showOverflowMenu)}
1407 className="btn-icon"
1408 aria-label="More options"
1409 >
1410 <svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
1411 <path
1412 strokeLinecap="round"
1413 strokeLinejoin="round"
1414 strokeWidth={2}
1415 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"
1416 />
1417 </svg>
1418 {hasUpdate && <span className="version-update-dot" />}
1419 </button>
1420
1421 {showOverflowMenu && (
1422 <div className="overflow-menu">
1423 {/* Diffs button - show when we have a CWD */}
1424 {(currentConversation?.cwd || selectedCwd) && (
1425 <button
1426 onClick={() => {
1427 setShowOverflowMenu(false);
1428 setShowDiffViewer(true);
1429 }}
1430 className="overflow-menu-item"
1431 >
1432 <svg
1433 fill="none"
1434 stroke="currentColor"
1435 viewBox="0 0 24 24"
1436 style={{ width: "1.25rem", height: "1.25rem", marginRight: "0.75rem" }}
1437 >
1438 <path
1439 strokeLinecap="round"
1440 strokeLinejoin="round"
1441 strokeWidth={2}
1442 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"
1443 />
1444 </svg>
1445 Diffs
1446 </button>
1447 )}
1448 {terminalURL && (
1449 <button
1450 onClick={() => {
1451 setShowOverflowMenu(false);
1452 const cwd = currentConversation?.cwd || selectedCwd || "";
1453 const url = terminalURL.replace("WORKING_DIR", encodeURIComponent(cwd));
1454 window.open(url, "_blank");
1455 }}
1456 className="overflow-menu-item"
1457 >
1458 <svg
1459 fill="none"
1460 stroke="currentColor"
1461 viewBox="0 0 24 24"
1462 style={{ width: "1.25rem", height: "1.25rem", marginRight: "0.75rem" }}
1463 >
1464 <path
1465 strokeLinecap="round"
1466 strokeLinejoin="round"
1467 strokeWidth={2}
1468 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"
1469 />
1470 </svg>
1471 Terminal
1472 </button>
1473 )}
1474 {links.map((link, index) => (
1475 <button
1476 key={index}
1477 onClick={() => {
1478 setShowOverflowMenu(false);
1479 window.open(link.url, "_blank");
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={
1494 link.icon_svg ||
1495 "M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"
1496 }
1497 />
1498 </svg>
1499 {link.title}
1500 </button>
1501 ))}
1502
1503 {/* Version check */}
1504 <div className="overflow-menu-divider" />
1505 <button
1506 onClick={() => {
1507 setShowOverflowMenu(false);
1508 openVersionModal();
1509 }}
1510 className="overflow-menu-item"
1511 >
1512 <svg
1513 fill="none"
1514 stroke="currentColor"
1515 viewBox="0 0 24 24"
1516 style={{ width: "1.25rem", height: "1.25rem", marginRight: "0.75rem" }}
1517 >
1518 <path
1519 strokeLinecap="round"
1520 strokeLinejoin="round"
1521 strokeWidth={2}
1522 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"
1523 />
1524 </svg>
1525 Check for New Version
1526 {hasUpdate && <span className="version-menu-dot" />}
1527 </button>
1528
1529 {/* Theme selector */}
1530 <div className="overflow-menu-divider" />
1531 <div className="theme-toggle-row">
1532 <button
1533 onClick={() => {
1534 setThemeMode("system");
1535 setStoredTheme("system");
1536 applyTheme("system");
1537 }}
1538 className={`theme-toggle-btn${themeMode === "system" ? " theme-toggle-btn-selected" : ""}`}
1539 title="System"
1540 >
1541 <svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
1542 <path
1543 strokeLinecap="round"
1544 strokeLinejoin="round"
1545 strokeWidth={2}
1546 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"
1547 />
1548 </svg>
1549 </button>
1550 <button
1551 onClick={() => {
1552 setThemeMode("light");
1553 setStoredTheme("light");
1554 applyTheme("light");
1555 }}
1556 className={`theme-toggle-btn${themeMode === "light" ? " theme-toggle-btn-selected" : ""}`}
1557 title="Light"
1558 >
1559 <svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
1560 <path
1561 strokeLinecap="round"
1562 strokeLinejoin="round"
1563 strokeWidth={2}
1564 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"
1565 />
1566 </svg>
1567 </button>
1568 <button
1569 onClick={() => {
1570 setThemeMode("dark");
1571 setStoredTheme("dark");
1572 applyTheme("dark");
1573 }}
1574 className={`theme-toggle-btn${themeMode === "dark" ? " theme-toggle-btn-selected" : ""}`}
1575 title="Dark"
1576 >
1577 <svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
1578 <path
1579 strokeLinecap="round"
1580 strokeLinejoin="round"
1581 strokeWidth={2}
1582 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"
1583 />
1584 </svg>
1585 </button>
1586 </div>
1587 </div>
1588 )}
1589 </div>
1590 </div>
1591 </div>
1592
1593 {/* Messages area */}
1594 {/* Messages area with scroll-to-bottom button wrapper */}
1595 <div className="messages-area-wrapper">
1596 <div className="messages-container scrollable" ref={messagesContainerRef}>
1597 {loading ? (
1598 <div className="flex items-center justify-center full-height">
1599 <div className="spinner"></div>
1600 </div>
1601 ) : (
1602 <div className="messages-list">
1603 {renderMessages()}
1604
1605 <div ref={messagesEndRef} />
1606 </div>
1607 )}
1608 </div>
1609
1610 {/* Scroll to bottom button - outside scrollable area */}
1611 {showScrollToBottom && (
1612 <button
1613 className="scroll-to-bottom-button"
1614 onClick={scrollToBottom}
1615 aria-label="Scroll to bottom"
1616 >
1617 <svg
1618 fill="none"
1619 stroke="currentColor"
1620 viewBox="0 0 24 24"
1621 style={{ width: "1.25rem", height: "1.25rem" }}
1622 >
1623 <path
1624 strokeLinecap="round"
1625 strokeLinejoin="round"
1626 strokeWidth={2}
1627 d="M19 14l-7 7m0 0l-7-7m7 7V3"
1628 />
1629 </svg>
1630 </button>
1631 )}
1632 </div>
1633
1634 {/* Unified Status Bar */}
1635 <div className="status-bar">
1636 <div className="status-bar-content">
1637 {isDisconnected ? (
1638 // Disconnected state
1639 <>
1640 <span className="status-message status-warning">Disconnected</span>
1641 <button
1642 onClick={handleManualReconnect}
1643 className="status-button status-button-primary"
1644 >
1645 Retry
1646 </button>
1647 </>
1648 ) : error ? (
1649 // Error state
1650 <>
1651 <span className="status-message status-error">{error}</span>
1652 <button onClick={() => setError(null)} className="status-button status-button-text">
1653 <svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
1654 <path
1655 strokeLinecap="round"
1656 strokeLinejoin="round"
1657 strokeWidth={2}
1658 d="M6 18L18 6M6 6l12 12"
1659 />
1660 </svg>
1661 </button>
1662 </>
1663 ) : agentWorking && conversationId ? (
1664 // Agent working - show status with stop button and context bar
1665 <div className="status-bar-active">
1666 <div className="status-working-group">
1667 <AnimatedWorkingStatus />
1668 <button
1669 onClick={handleCancel}
1670 disabled={cancelling}
1671 className="status-stop-button"
1672 title={cancelling ? "Cancelling..." : "Stop"}
1673 >
1674 <svg viewBox="0 0 24 24" fill="currentColor">
1675 <rect x="6" y="6" width="12" height="12" rx="1" />
1676 </svg>
1677 <span className="status-stop-label">{cancelling ? "Cancelling..." : "Stop"}</span>
1678 </button>
1679 </div>
1680 <ContextUsageBar
1681 contextWindowSize={contextWindowSize}
1682 maxContextTokens={
1683 models.find((m) => m.id === selectedModel)?.max_context_tokens || 200000
1684 }
1685 conversationId={conversationId}
1686 onContinueConversation={
1687 onContinueConversation ? handleContinueConversation : undefined
1688 }
1689 />
1690 </div>
1691 ) : // Idle state - show ready message, or configuration for empty conversation
1692 !conversationId ? (
1693 // Empty conversation - show model (left) and cwd (right)
1694 <div className="status-bar-new-conversation">
1695 {/* Model selector - far left */}
1696 <div
1697 className="status-field status-field-model"
1698 title="AI model to use for this conversation"
1699 >
1700 <span className="status-field-label">Model:</span>
1701 <ModelPicker
1702 models={models}
1703 selectedModel={selectedModel}
1704 onSelectModel={setSelectedModel}
1705 onManageModels={() => onOpenModelsModal?.()}
1706 disabled={sending}
1707 />
1708 </div>
1709
1710 {/* CWD indicator - far right */}
1711 <div
1712 className={`status-field status-field-cwd${cwdError ? " status-field-error" : ""}`}
1713 title={cwdError || "Working directory for file operations"}
1714 >
1715 <span className="status-field-label">Dir:</span>
1716 <button
1717 className={`status-chip${cwdError ? " status-chip-error" : ""}`}
1718 onClick={() => setShowDirectoryPicker(true)}
1719 disabled={sending}
1720 >
1721 {selectedCwd || "(no cwd)"}
1722 </button>
1723 </div>
1724 </div>
1725 ) : (
1726 // Active conversation - show Ready + context bar
1727 <div className="status-bar-active">
1728 <span className="status-message status-ready">Ready on {hostname}</span>
1729 <ContextUsageBar
1730 contextWindowSize={contextWindowSize}
1731 maxContextTokens={
1732 models.find((m) => m.id === selectedModel)?.max_context_tokens || 200000
1733 }
1734 conversationId={conversationId}
1735 onContinueConversation={
1736 onContinueConversation ? handleContinueConversation : undefined
1737 }
1738 />
1739 </div>
1740 )}
1741 </div>
1742 </div>
1743
1744 {/* Message input */}
1745 <MessageInput
1746 key={conversationId || "new"}
1747 onSend={sendMessage}
1748 disabled={sending || loading}
1749 autoFocus={true}
1750 injectedText={terminalInjectedText || diffCommentText}
1751 onClearInjectedText={() => {
1752 setDiffCommentText("");
1753 setTerminalInjectedText(null);
1754 }}
1755 persistKey={conversationId || "new-conversation"}
1756 />
1757
1758 {/* Directory Picker Modal */}
1759 <DirectoryPickerModal
1760 isOpen={showDirectoryPicker}
1761 onClose={() => setShowDirectoryPicker(false)}
1762 onSelect={(path) => {
1763 setSelectedCwd(path);
1764 setCwdError(null);
1765 }}
1766 initialPath={selectedCwd}
1767 />
1768
1769 {/* Diff Viewer */}
1770 <DiffViewer
1771 cwd={currentConversation?.cwd || selectedCwd}
1772 isOpen={showDiffViewer}
1773 onClose={() => {
1774 setShowDiffViewer(false);
1775 setDiffViewerInitialCommit(undefined);
1776 }}
1777 onCommentTextChange={setDiffCommentText}
1778 initialCommit={diffViewerInitialCommit}
1779 />
1780
1781 {/* Version Checker Modal */}
1782 {VersionModal}
1783 </div>
1784 );
1785}
1786
1787export default ChatInterface;