ChatInterface.tsx

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