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";
18import ThinkTool from "./ThinkTool";
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 think: ThinkTool,
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 // Add tool uses as separate items
1196 toolUses.forEach((toolUse) => {
1197 const resultData = toolUse.ID ? toolResultMap[toolUse.ID] : undefined;
1198 const completedViaDisplay = toolUse.ID ? displayResultSet.has(toolUse.ID) : false;
1199 const displayData = toolUse.ID ? displayDataMap[toolUse.ID] : undefined;
1200 coalescedItems.push({
1201 type: "tool",
1202 toolUseId: toolUse.ID,
1203 toolName: toolUse.ToolName,
1204 toolInput: toolUse.ToolInput,
1205 toolResult: resultData?.result,
1206 toolError: resultData?.error,
1207 toolStartTime: resultData?.startTime,
1208 toolEndTime: resultData?.endTime,
1209 hasResult: !!resultData || completedViaDisplay,
1210 display: displayData,
1211 });
1212 });
1213 }
1214 } catch (err) {
1215 console.error("Failed to parse message LLM data:", err);
1216 coalescedItems.push({ type: "message", message });
1217 }
1218 } else {
1219 coalescedItems.push({ type: "message", message });
1220 }
1221 });
1222
1223 return coalescedItems;
1224 };
1225
1226 const renderMessages = () => {
1227 // Build ephemeral terminal elements first - they should always render
1228 const terminalElements = ephemeralTerminals.map((terminal) => (
1229 <TerminalWidget
1230 key={terminal.id}
1231 command={terminal.command}
1232 cwd={terminal.cwd}
1233 onInsertIntoInput={handleInsertFromTerminal}
1234 onClose={() => setEphemeralTerminals((prev) => prev.filter((t) => t.id !== terminal.id))}
1235 />
1236 ));
1237
1238 if (messages.length === 0 && ephemeralTerminals.length === 0) {
1239 const proxyURL = `https://${hostname}/`;
1240 return (
1241 <div className="empty-state">
1242 <div className="empty-state-content">
1243 <p className="text-base" style={{ marginBottom: "1rem", lineHeight: "1.6" }}>
1244 Shelley is an agent, running on <strong>{hostname}</strong>. You can ask Shelley to do
1245 stuff. If you build a web site with Shelley, you can use exe.dev's proxy features
1246 (see{" "}
1247 <a
1248 href="https://exe.dev/docs/proxy"
1249 target="_blank"
1250 rel="noopener noreferrer"
1251 style={{ color: "var(--blue-text)", textDecoration: "underline" }}
1252 >
1253 docs
1254 </a>
1255 ) to visit it over the web at{" "}
1256 <a
1257 href={proxyURL}
1258 target="_blank"
1259 rel="noopener noreferrer"
1260 style={{ color: "var(--blue-text)", textDecoration: "underline" }}
1261 >
1262 {proxyURL}
1263 </a>
1264 .
1265 </p>
1266 {models.length === 0 ? (
1267 <div className="add-model-hint">
1268 <p className="text-sm" style={{ color: "var(--text-secondary)" }}>
1269 No AI models configured. Press <kbd>Ctrl</kbd>
1270 <span>+</span>
1271 <kbd>K</kbd> or <kbd>⌘</kbd>
1272 <span>+</span>
1273 <kbd>K</kbd> to add a model.
1274 </p>
1275 </div>
1276 ) : (
1277 <p className="text-sm" style={{ color: "var(--text-secondary)" }}>
1278 Send a message to start the conversation.
1279 </p>
1280 )}
1281 </div>
1282 </div>
1283 );
1284 }
1285
1286 // If we have terminals but no messages, just show terminals
1287 if (messages.length === 0) {
1288 return terminalElements;
1289 }
1290
1291 const coalescedItems = processMessages();
1292
1293 const rendered = coalescedItems.map((item, index) => {
1294 if (item.type === "message" && item.message) {
1295 return (
1296 <MessageComponent
1297 key={item.message.message_id}
1298 message={item.message}
1299 onOpenDiffViewer={(commit) => {
1300 setDiffViewerInitialCommit(commit);
1301 setShowDiffViewer(true);
1302 }}
1303 onCommentTextChange={setDiffCommentText}
1304 />
1305 );
1306 } else if (item.type === "tool") {
1307 return (
1308 <CoalescedToolCall
1309 key={item.toolUseId || `tool-${index}`}
1310 toolName={item.toolName || "Unknown Tool"}
1311 toolInput={item.toolInput}
1312 toolResult={item.toolResult}
1313 toolError={item.toolError}
1314 toolStartTime={item.toolStartTime}
1315 toolEndTime={item.toolEndTime}
1316 hasResult={item.hasResult}
1317 display={item.display}
1318 onCommentTextChange={setDiffCommentText}
1319 />
1320 );
1321 }
1322 return null;
1323 });
1324
1325 // Find system message to render at the top
1326 const systemMessage = messages.find((m) => m.type === "system");
1327
1328 // Append ephemeral terminals at the end
1329 return [
1330 systemMessage && <SystemPromptView key="system-prompt" message={systemMessage} />,
1331 ...rendered,
1332 ...terminalElements,
1333 ];
1334 };
1335
1336 return (
1337 <div className="full-height flex flex-col">
1338 {/* Header */}
1339 <div className="header">
1340 <div className="header-left">
1341 <button
1342 onClick={onOpenDrawer}
1343 className="btn-icon hide-on-desktop"
1344 aria-label="Open conversations"
1345 >
1346 <svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
1347 <path
1348 strokeLinecap="round"
1349 strokeLinejoin="round"
1350 strokeWidth={2}
1351 d="M4 6h16M4 12h16M4 18h16"
1352 />
1353 </svg>
1354 </button>
1355
1356 {/* Expand drawer button - desktop only when collapsed */}
1357 {isDrawerCollapsed && onToggleDrawerCollapse && (
1358 <button
1359 onClick={onToggleDrawerCollapse}
1360 className="btn-icon show-on-desktop-only"
1361 aria-label="Expand sidebar"
1362 title="Expand sidebar"
1363 >
1364 <svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
1365 <path
1366 strokeLinecap="round"
1367 strokeLinejoin="round"
1368 strokeWidth={2}
1369 d="M13 5l7 7-7 7M5 5l7 7-7 7"
1370 />
1371 </svg>
1372 </button>
1373 )}
1374
1375 <h1 className="header-title" title={currentConversation?.slug || "Shelley"}>
1376 {getDisplayTitle()}
1377 </h1>
1378 </div>
1379
1380 <div className="header-actions">
1381 {/* Green + icon in circle for new conversation */}
1382 <button onClick={onNewConversation} className="btn-new" aria-label="New conversation">
1383 <svg
1384 fill="none"
1385 stroke="currentColor"
1386 viewBox="0 0 24 24"
1387 style={{ width: "1rem", height: "1rem" }}
1388 >
1389 <path
1390 strokeLinecap="round"
1391 strokeLinejoin="round"
1392 strokeWidth={2}
1393 d="M12 4v16m8-8H4"
1394 />
1395 </svg>
1396 </button>
1397
1398 {/* Overflow menu */}
1399 <div ref={overflowMenuRef} style={{ position: "relative" }}>
1400 <button
1401 onClick={() => setShowOverflowMenu(!showOverflowMenu)}
1402 className="btn-icon"
1403 aria-label="More options"
1404 >
1405 <svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
1406 <path
1407 strokeLinecap="round"
1408 strokeLinejoin="round"
1409 strokeWidth={2}
1410 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"
1411 />
1412 </svg>
1413 {hasUpdate && <span className="version-update-dot" />}
1414 </button>
1415
1416 {showOverflowMenu && (
1417 <div className="overflow-menu">
1418 {/* Diffs button - show when we have a CWD */}
1419 {(currentConversation?.cwd || selectedCwd) && (
1420 <button
1421 onClick={() => {
1422 setShowOverflowMenu(false);
1423 setShowDiffViewer(true);
1424 }}
1425 className="overflow-menu-item"
1426 >
1427 <svg
1428 fill="none"
1429 stroke="currentColor"
1430 viewBox="0 0 24 24"
1431 style={{ width: "1.25rem", height: "1.25rem", marginRight: "0.75rem" }}
1432 >
1433 <path
1434 strokeLinecap="round"
1435 strokeLinejoin="round"
1436 strokeWidth={2}
1437 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"
1438 />
1439 </svg>
1440 Diffs
1441 </button>
1442 )}
1443 {terminalURL && (
1444 <button
1445 onClick={() => {
1446 setShowOverflowMenu(false);
1447 const cwd = currentConversation?.cwd || selectedCwd || "";
1448 const url = terminalURL.replace("WORKING_DIR", encodeURIComponent(cwd));
1449 window.open(url, "_blank");
1450 }}
1451 className="overflow-menu-item"
1452 >
1453 <svg
1454 fill="none"
1455 stroke="currentColor"
1456 viewBox="0 0 24 24"
1457 style={{ width: "1.25rem", height: "1.25rem", marginRight: "0.75rem" }}
1458 >
1459 <path
1460 strokeLinecap="round"
1461 strokeLinejoin="round"
1462 strokeWidth={2}
1463 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"
1464 />
1465 </svg>
1466 Terminal
1467 </button>
1468 )}
1469 {links.map((link, index) => (
1470 <button
1471 key={index}
1472 onClick={() => {
1473 setShowOverflowMenu(false);
1474 window.open(link.url, "_blank");
1475 }}
1476 className="overflow-menu-item"
1477 >
1478 <svg
1479 fill="none"
1480 stroke="currentColor"
1481 viewBox="0 0 24 24"
1482 style={{ width: "1.25rem", height: "1.25rem", marginRight: "0.75rem" }}
1483 >
1484 <path
1485 strokeLinecap="round"
1486 strokeLinejoin="round"
1487 strokeWidth={2}
1488 d={
1489 link.icon_svg ||
1490 "M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"
1491 }
1492 />
1493 </svg>
1494 {link.title}
1495 </button>
1496 ))}
1497
1498 {/* Version check */}
1499 <div className="overflow-menu-divider" />
1500 <button
1501 onClick={() => {
1502 setShowOverflowMenu(false);
1503 openVersionModal();
1504 }}
1505 className="overflow-menu-item"
1506 >
1507 <svg
1508 fill="none"
1509 stroke="currentColor"
1510 viewBox="0 0 24 24"
1511 style={{ width: "1.25rem", height: "1.25rem", marginRight: "0.75rem" }}
1512 >
1513 <path
1514 strokeLinecap="round"
1515 strokeLinejoin="round"
1516 strokeWidth={2}
1517 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"
1518 />
1519 </svg>
1520 Check for New Version
1521 {hasUpdate && <span className="version-menu-dot" />}
1522 </button>
1523
1524 {/* Theme selector */}
1525 <div className="overflow-menu-divider" />
1526 <div className="theme-toggle-row">
1527 <button
1528 onClick={() => {
1529 setThemeMode("system");
1530 setStoredTheme("system");
1531 applyTheme("system");
1532 }}
1533 className={`theme-toggle-btn${themeMode === "system" ? " theme-toggle-btn-selected" : ""}`}
1534 title="System"
1535 >
1536 <svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
1537 <path
1538 strokeLinecap="round"
1539 strokeLinejoin="round"
1540 strokeWidth={2}
1541 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"
1542 />
1543 </svg>
1544 </button>
1545 <button
1546 onClick={() => {
1547 setThemeMode("light");
1548 setStoredTheme("light");
1549 applyTheme("light");
1550 }}
1551 className={`theme-toggle-btn${themeMode === "light" ? " theme-toggle-btn-selected" : ""}`}
1552 title="Light"
1553 >
1554 <svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
1555 <path
1556 strokeLinecap="round"
1557 strokeLinejoin="round"
1558 strokeWidth={2}
1559 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"
1560 />
1561 </svg>
1562 </button>
1563 <button
1564 onClick={() => {
1565 setThemeMode("dark");
1566 setStoredTheme("dark");
1567 applyTheme("dark");
1568 }}
1569 className={`theme-toggle-btn${themeMode === "dark" ? " theme-toggle-btn-selected" : ""}`}
1570 title="Dark"
1571 >
1572 <svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
1573 <path
1574 strokeLinecap="round"
1575 strokeLinejoin="round"
1576 strokeWidth={2}
1577 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"
1578 />
1579 </svg>
1580 </button>
1581 </div>
1582 </div>
1583 )}
1584 </div>
1585 </div>
1586 </div>
1587
1588 {/* Messages area */}
1589 {/* Messages area with scroll-to-bottom button wrapper */}
1590 <div className="messages-area-wrapper">
1591 <div className="messages-container scrollable" ref={messagesContainerRef}>
1592 {loading ? (
1593 <div className="flex items-center justify-center full-height">
1594 <div className="spinner"></div>
1595 </div>
1596 ) : (
1597 <div className="messages-list">
1598 {renderMessages()}
1599
1600 <div ref={messagesEndRef} />
1601 </div>
1602 )}
1603 </div>
1604
1605 {/* Scroll to bottom button - outside scrollable area */}
1606 {showScrollToBottom && (
1607 <button
1608 className="scroll-to-bottom-button"
1609 onClick={scrollToBottom}
1610 aria-label="Scroll to bottom"
1611 >
1612 <svg
1613 fill="none"
1614 stroke="currentColor"
1615 viewBox="0 0 24 24"
1616 style={{ width: "1.25rem", height: "1.25rem" }}
1617 >
1618 <path
1619 strokeLinecap="round"
1620 strokeLinejoin="round"
1621 strokeWidth={2}
1622 d="M19 14l-7 7m0 0l-7-7m7 7V3"
1623 />
1624 </svg>
1625 </button>
1626 )}
1627 </div>
1628
1629 {/* Unified Status Bar */}
1630 <div className="status-bar">
1631 <div className="status-bar-content">
1632 {isDisconnected ? (
1633 // Disconnected state
1634 <>
1635 <span className="status-message status-warning">Disconnected</span>
1636 <button
1637 onClick={handleManualReconnect}
1638 className="status-button status-button-primary"
1639 >
1640 Retry
1641 </button>
1642 </>
1643 ) : error ? (
1644 // Error state
1645 <>
1646 <span className="status-message status-error">{error}</span>
1647 <button onClick={() => setError(null)} className="status-button status-button-text">
1648 <svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
1649 <path
1650 strokeLinecap="round"
1651 strokeLinejoin="round"
1652 strokeWidth={2}
1653 d="M6 18L18 6M6 6l12 12"
1654 />
1655 </svg>
1656 </button>
1657 </>
1658 ) : agentWorking && conversationId ? (
1659 // Agent working - show status with stop button and context bar
1660 <div className="status-bar-active">
1661 <div className="status-working-group">
1662 <AnimatedWorkingStatus />
1663 <button
1664 onClick={handleCancel}
1665 disabled={cancelling}
1666 className="status-stop-button"
1667 title={cancelling ? "Cancelling..." : "Stop"}
1668 >
1669 <svg viewBox="0 0 24 24" fill="currentColor">
1670 <rect x="6" y="6" width="12" height="12" rx="1" />
1671 </svg>
1672 <span className="status-stop-label">{cancelling ? "Cancelling..." : "Stop"}</span>
1673 </button>
1674 </div>
1675 <ContextUsageBar
1676 contextWindowSize={contextWindowSize}
1677 maxContextTokens={
1678 models.find((m) => m.id === selectedModel)?.max_context_tokens || 200000
1679 }
1680 conversationId={conversationId}
1681 onContinueConversation={
1682 onContinueConversation ? handleContinueConversation : undefined
1683 }
1684 />
1685 </div>
1686 ) : // Idle state - show ready message, or configuration for empty conversation
1687 !conversationId ? (
1688 // Empty conversation - show model (left) and cwd (right)
1689 <div className="status-bar-new-conversation">
1690 {/* Model selector - far left */}
1691 <div
1692 className="status-field status-field-model"
1693 title="AI model to use for this conversation"
1694 >
1695 <span className="status-field-label">Model:</span>
1696 <ModelPicker
1697 models={models}
1698 selectedModel={selectedModel}
1699 onSelectModel={setSelectedModel}
1700 onManageModels={() => onOpenModelsModal?.()}
1701 disabled={sending}
1702 />
1703 </div>
1704
1705 {/* CWD indicator - far right */}
1706 <div
1707 className={`status-field status-field-cwd${cwdError ? " status-field-error" : ""}`}
1708 title={cwdError || "Working directory for file operations"}
1709 >
1710 <span className="status-field-label">Dir:</span>
1711 <button
1712 className={`status-chip${cwdError ? " status-chip-error" : ""}`}
1713 onClick={() => setShowDirectoryPicker(true)}
1714 disabled={sending}
1715 >
1716 {selectedCwd || "(no cwd)"}
1717 </button>
1718 </div>
1719 </div>
1720 ) : (
1721 // Active conversation - show Ready + context bar
1722 <div className="status-bar-active">
1723 <span className="status-message status-ready">Ready on {hostname}</span>
1724 <ContextUsageBar
1725 contextWindowSize={contextWindowSize}
1726 maxContextTokens={
1727 models.find((m) => m.id === selectedModel)?.max_context_tokens || 200000
1728 }
1729 conversationId={conversationId}
1730 onContinueConversation={
1731 onContinueConversation ? handleContinueConversation : undefined
1732 }
1733 />
1734 </div>
1735 )}
1736 </div>
1737 </div>
1738
1739 {/* Message input */}
1740 <MessageInput
1741 key={conversationId || "new"}
1742 onSend={sendMessage}
1743 disabled={sending || loading}
1744 autoFocus={true}
1745 injectedText={terminalInjectedText || diffCommentText}
1746 onClearInjectedText={() => {
1747 setDiffCommentText("");
1748 setTerminalInjectedText(null);
1749 }}
1750 persistKey={conversationId || "new-conversation"}
1751 />
1752
1753 {/* Directory Picker Modal */}
1754 <DirectoryPickerModal
1755 isOpen={showDirectoryPicker}
1756 onClose={() => setShowDirectoryPicker(false)}
1757 onSelect={(path) => {
1758 setSelectedCwd(path);
1759 setCwdError(null);
1760 }}
1761 initialPath={selectedCwd}
1762 />
1763
1764 {/* Diff Viewer */}
1765 <DiffViewer
1766 cwd={currentConversation?.cwd || selectedCwd}
1767 isOpen={showDiffViewer}
1768 onClose={() => {
1769 setShowDiffViewer(false);
1770 setDiffViewerInitialCommit(undefined);
1771 }}
1772 onCommentTextChange={setDiffCommentText}
1773 initialCommit={diffViewerInitialCommit}
1774 />
1775
1776 {/* Version Checker Modal */}
1777 {VersionModal}
1778 </div>
1779 );
1780}
1781
1782export default ChatInterface;