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