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