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