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