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