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