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";
  18import ThinkTool from "./ThinkTool";
  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  think: ThinkTool,
 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            // Add tool uses as separate items
1196            toolUses.forEach((toolUse) => {
1197              const resultData = toolUse.ID ? toolResultMap[toolUse.ID] : undefined;
1198              const completedViaDisplay = toolUse.ID ? displayResultSet.has(toolUse.ID) : false;
1199              const displayData = toolUse.ID ? displayDataMap[toolUse.ID] : undefined;
1200              coalescedItems.push({
1201                type: "tool",
1202                toolUseId: toolUse.ID,
1203                toolName: toolUse.ToolName,
1204                toolInput: toolUse.ToolInput,
1205                toolResult: resultData?.result,
1206                toolError: resultData?.error,
1207                toolStartTime: resultData?.startTime,
1208                toolEndTime: resultData?.endTime,
1209                hasResult: !!resultData || completedViaDisplay,
1210                display: displayData,
1211              });
1212            });
1213          }
1214        } catch (err) {
1215          console.error("Failed to parse message LLM data:", err);
1216          coalescedItems.push({ type: "message", message });
1217        }
1218      } else {
1219        coalescedItems.push({ type: "message", message });
1220      }
1221    });
1222
1223    return coalescedItems;
1224  };
1225
1226  const renderMessages = () => {
1227    // Build ephemeral terminal elements first - they should always render
1228    const terminalElements = ephemeralTerminals.map((terminal) => (
1229      <TerminalWidget
1230        key={terminal.id}
1231        command={terminal.command}
1232        cwd={terminal.cwd}
1233        onInsertIntoInput={handleInsertFromTerminal}
1234        onClose={() => setEphemeralTerminals((prev) => prev.filter((t) => t.id !== terminal.id))}
1235      />
1236    ));
1237
1238    if (messages.length === 0 && ephemeralTerminals.length === 0) {
1239      const proxyURL = `https://${hostname}/`;
1240      return (
1241        <div className="empty-state">
1242          <div className="empty-state-content">
1243            <p className="text-base" style={{ marginBottom: "1rem", lineHeight: "1.6" }}>
1244              Shelley is an agent, running on <strong>{hostname}</strong>. You can ask Shelley to do
1245              stuff. If you build a web site with Shelley, you can use exe.dev&apos;s proxy features
1246              (see{" "}
1247              <a
1248                href="https://exe.dev/docs/proxy"
1249                target="_blank"
1250                rel="noopener noreferrer"
1251                style={{ color: "var(--blue-text)", textDecoration: "underline" }}
1252              >
1253                docs
1254              </a>
1255              ) to visit it over the web at{" "}
1256              <a
1257                href={proxyURL}
1258                target="_blank"
1259                rel="noopener noreferrer"
1260                style={{ color: "var(--blue-text)", textDecoration: "underline" }}
1261              >
1262                {proxyURL}
1263              </a>
1264              .
1265            </p>
1266            {models.length === 0 ? (
1267              <div className="add-model-hint">
1268                <p className="text-sm" style={{ color: "var(--text-secondary)" }}>
1269                  No AI models configured. Press <kbd>Ctrl</kbd>
1270                  <span>+</span>
1271                  <kbd>K</kbd> or <kbd></kbd>
1272                  <span>+</span>
1273                  <kbd>K</kbd> to add a model.
1274                </p>
1275              </div>
1276            ) : (
1277              <p className="text-sm" style={{ color: "var(--text-secondary)" }}>
1278                Send a message to start the conversation.
1279              </p>
1280            )}
1281          </div>
1282        </div>
1283      );
1284    }
1285
1286    // If we have terminals but no messages, just show terminals
1287    if (messages.length === 0) {
1288      return terminalElements;
1289    }
1290
1291    const coalescedItems = processMessages();
1292
1293    const rendered = coalescedItems.map((item, index) => {
1294      if (item.type === "message" && item.message) {
1295        return (
1296          <MessageComponent
1297            key={item.message.message_id}
1298            message={item.message}
1299            onOpenDiffViewer={(commit) => {
1300              setDiffViewerInitialCommit(commit);
1301              setShowDiffViewer(true);
1302            }}
1303            onCommentTextChange={setDiffCommentText}
1304          />
1305        );
1306      } else if (item.type === "tool") {
1307        return (
1308          <CoalescedToolCall
1309            key={item.toolUseId || `tool-${index}`}
1310            toolName={item.toolName || "Unknown Tool"}
1311            toolInput={item.toolInput}
1312            toolResult={item.toolResult}
1313            toolError={item.toolError}
1314            toolStartTime={item.toolStartTime}
1315            toolEndTime={item.toolEndTime}
1316            hasResult={item.hasResult}
1317            display={item.display}
1318            onCommentTextChange={setDiffCommentText}
1319          />
1320        );
1321      }
1322      return null;
1323    });
1324
1325    // Find system message to render at the top
1326    const systemMessage = messages.find((m) => m.type === "system");
1327
1328    // Append ephemeral terminals at the end
1329    return [
1330      systemMessage && <SystemPromptView key="system-prompt" message={systemMessage} />,
1331      ...rendered,
1332      ...terminalElements,
1333    ];
1334  };
1335
1336  return (
1337    <div className="full-height flex flex-col">
1338      {/* Header */}
1339      <div className="header">
1340        <div className="header-left">
1341          <button
1342            onClick={onOpenDrawer}
1343            className="btn-icon hide-on-desktop"
1344            aria-label="Open conversations"
1345          >
1346            <svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
1347              <path
1348                strokeLinecap="round"
1349                strokeLinejoin="round"
1350                strokeWidth={2}
1351                d="M4 6h16M4 12h16M4 18h16"
1352              />
1353            </svg>
1354          </button>
1355
1356          {/* Expand drawer button - desktop only when collapsed */}
1357          {isDrawerCollapsed && onToggleDrawerCollapse && (
1358            <button
1359              onClick={onToggleDrawerCollapse}
1360              className="btn-icon show-on-desktop-only"
1361              aria-label="Expand sidebar"
1362              title="Expand sidebar"
1363            >
1364              <svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
1365                <path
1366                  strokeLinecap="round"
1367                  strokeLinejoin="round"
1368                  strokeWidth={2}
1369                  d="M13 5l7 7-7 7M5 5l7 7-7 7"
1370                />
1371              </svg>
1372            </button>
1373          )}
1374
1375          <h1 className="header-title" title={currentConversation?.slug || "Shelley"}>
1376            {getDisplayTitle()}
1377          </h1>
1378        </div>
1379
1380        <div className="header-actions">
1381          {/* Green + icon in circle for new conversation */}
1382          <button onClick={onNewConversation} className="btn-new" aria-label="New conversation">
1383            <svg
1384              fill="none"
1385              stroke="currentColor"
1386              viewBox="0 0 24 24"
1387              style={{ width: "1rem", height: "1rem" }}
1388            >
1389              <path
1390                strokeLinecap="round"
1391                strokeLinejoin="round"
1392                strokeWidth={2}
1393                d="M12 4v16m8-8H4"
1394              />
1395            </svg>
1396          </button>
1397
1398          {/* Overflow menu */}
1399          <div ref={overflowMenuRef} style={{ position: "relative" }}>
1400            <button
1401              onClick={() => setShowOverflowMenu(!showOverflowMenu)}
1402              className="btn-icon"
1403              aria-label="More options"
1404            >
1405              <svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
1406                <path
1407                  strokeLinecap="round"
1408                  strokeLinejoin="round"
1409                  strokeWidth={2}
1410                  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"
1411                />
1412              </svg>
1413              {hasUpdate && <span className="version-update-dot" />}
1414            </button>
1415
1416            {showOverflowMenu && (
1417              <div className="overflow-menu">
1418                {/* Diffs button - show when we have a CWD */}
1419                {(currentConversation?.cwd || selectedCwd) && (
1420                  <button
1421                    onClick={() => {
1422                      setShowOverflowMenu(false);
1423                      setShowDiffViewer(true);
1424                    }}
1425                    className="overflow-menu-item"
1426                  >
1427                    <svg
1428                      fill="none"
1429                      stroke="currentColor"
1430                      viewBox="0 0 24 24"
1431                      style={{ width: "1.25rem", height: "1.25rem", marginRight: "0.75rem" }}
1432                    >
1433                      <path
1434                        strokeLinecap="round"
1435                        strokeLinejoin="round"
1436                        strokeWidth={2}
1437                        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"
1438                      />
1439                    </svg>
1440                    Diffs
1441                  </button>
1442                )}
1443                {terminalURL && (
1444                  <button
1445                    onClick={() => {
1446                      setShowOverflowMenu(false);
1447                      const cwd = currentConversation?.cwd || selectedCwd || "";
1448                      const url = terminalURL.replace("WORKING_DIR", encodeURIComponent(cwd));
1449                      window.open(url, "_blank");
1450                    }}
1451                    className="overflow-menu-item"
1452                  >
1453                    <svg
1454                      fill="none"
1455                      stroke="currentColor"
1456                      viewBox="0 0 24 24"
1457                      style={{ width: "1.25rem", height: "1.25rem", marginRight: "0.75rem" }}
1458                    >
1459                      <path
1460                        strokeLinecap="round"
1461                        strokeLinejoin="round"
1462                        strokeWidth={2}
1463                        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"
1464                      />
1465                    </svg>
1466                    Terminal
1467                  </button>
1468                )}
1469                {links.map((link, index) => (
1470                  <button
1471                    key={index}
1472                    onClick={() => {
1473                      setShowOverflowMenu(false);
1474                      window.open(link.url, "_blank");
1475                    }}
1476                    className="overflow-menu-item"
1477                  >
1478                    <svg
1479                      fill="none"
1480                      stroke="currentColor"
1481                      viewBox="0 0 24 24"
1482                      style={{ width: "1.25rem", height: "1.25rem", marginRight: "0.75rem" }}
1483                    >
1484                      <path
1485                        strokeLinecap="round"
1486                        strokeLinejoin="round"
1487                        strokeWidth={2}
1488                        d={
1489                          link.icon_svg ||
1490                          "M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"
1491                        }
1492                      />
1493                    </svg>
1494                    {link.title}
1495                  </button>
1496                ))}
1497
1498                {/* Version check */}
1499                <div className="overflow-menu-divider" />
1500                <button
1501                  onClick={() => {
1502                    setShowOverflowMenu(false);
1503                    openVersionModal();
1504                  }}
1505                  className="overflow-menu-item"
1506                >
1507                  <svg
1508                    fill="none"
1509                    stroke="currentColor"
1510                    viewBox="0 0 24 24"
1511                    style={{ width: "1.25rem", height: "1.25rem", marginRight: "0.75rem" }}
1512                  >
1513                    <path
1514                      strokeLinecap="round"
1515                      strokeLinejoin="round"
1516                      strokeWidth={2}
1517                      d="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"
1518                    />
1519                  </svg>
1520                  Check for New Version
1521                  {hasUpdate && <span className="version-menu-dot" />}
1522                </button>
1523
1524                {/* Theme selector */}
1525                <div className="overflow-menu-divider" />
1526                <div className="theme-toggle-row">
1527                  <button
1528                    onClick={() => {
1529                      setThemeMode("system");
1530                      setStoredTheme("system");
1531                      applyTheme("system");
1532                    }}
1533                    className={`theme-toggle-btn${themeMode === "system" ? " theme-toggle-btn-selected" : ""}`}
1534                    title="System"
1535                  >
1536                    <svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
1537                      <path
1538                        strokeLinecap="round"
1539                        strokeLinejoin="round"
1540                        strokeWidth={2}
1541                        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"
1542                      />
1543                    </svg>
1544                  </button>
1545                  <button
1546                    onClick={() => {
1547                      setThemeMode("light");
1548                      setStoredTheme("light");
1549                      applyTheme("light");
1550                    }}
1551                    className={`theme-toggle-btn${themeMode === "light" ? " theme-toggle-btn-selected" : ""}`}
1552                    title="Light"
1553                  >
1554                    <svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
1555                      <path
1556                        strokeLinecap="round"
1557                        strokeLinejoin="round"
1558                        strokeWidth={2}
1559                        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"
1560                      />
1561                    </svg>
1562                  </button>
1563                  <button
1564                    onClick={() => {
1565                      setThemeMode("dark");
1566                      setStoredTheme("dark");
1567                      applyTheme("dark");
1568                    }}
1569                    className={`theme-toggle-btn${themeMode === "dark" ? " theme-toggle-btn-selected" : ""}`}
1570                    title="Dark"
1571                  >
1572                    <svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
1573                      <path
1574                        strokeLinecap="round"
1575                        strokeLinejoin="round"
1576                        strokeWidth={2}
1577                        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"
1578                      />
1579                    </svg>
1580                  </button>
1581                </div>
1582              </div>
1583            )}
1584          </div>
1585        </div>
1586      </div>
1587
1588      {/* Messages area */}
1589      {/* Messages area with scroll-to-bottom button wrapper */}
1590      <div className="messages-area-wrapper">
1591        <div className="messages-container scrollable" ref={messagesContainerRef}>
1592          {loading ? (
1593            <div className="flex items-center justify-center full-height">
1594              <div className="spinner"></div>
1595            </div>
1596          ) : (
1597            <div className="messages-list">
1598              {renderMessages()}
1599
1600              <div ref={messagesEndRef} />
1601            </div>
1602          )}
1603        </div>
1604
1605        {/* Scroll to bottom button - outside scrollable area */}
1606        {showScrollToBottom && (
1607          <button
1608            className="scroll-to-bottom-button"
1609            onClick={scrollToBottom}
1610            aria-label="Scroll to bottom"
1611          >
1612            <svg
1613              fill="none"
1614              stroke="currentColor"
1615              viewBox="0 0 24 24"
1616              style={{ width: "1.25rem", height: "1.25rem" }}
1617            >
1618              <path
1619                strokeLinecap="round"
1620                strokeLinejoin="round"
1621                strokeWidth={2}
1622                d="M19 14l-7 7m0 0l-7-7m7 7V3"
1623              />
1624            </svg>
1625          </button>
1626        )}
1627      </div>
1628
1629      {/* Unified Status Bar */}
1630      <div className="status-bar">
1631        <div className="status-bar-content">
1632          {isDisconnected ? (
1633            // Disconnected state
1634            <>
1635              <span className="status-message status-warning">Disconnected</span>
1636              <button
1637                onClick={handleManualReconnect}
1638                className="status-button status-button-primary"
1639              >
1640                Retry
1641              </button>
1642            </>
1643          ) : error ? (
1644            // Error state
1645            <>
1646              <span className="status-message status-error">{error}</span>
1647              <button onClick={() => setError(null)} className="status-button status-button-text">
1648                <svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
1649                  <path
1650                    strokeLinecap="round"
1651                    strokeLinejoin="round"
1652                    strokeWidth={2}
1653                    d="M6 18L18 6M6 6l12 12"
1654                  />
1655                </svg>
1656              </button>
1657            </>
1658          ) : agentWorking && conversationId ? (
1659            // Agent working - show status with stop button and context bar
1660            <div className="status-bar-active">
1661              <div className="status-working-group">
1662                <AnimatedWorkingStatus />
1663                <button
1664                  onClick={handleCancel}
1665                  disabled={cancelling}
1666                  className="status-stop-button"
1667                  title={cancelling ? "Cancelling..." : "Stop"}
1668                >
1669                  <svg viewBox="0 0 24 24" fill="currentColor">
1670                    <rect x="6" y="6" width="12" height="12" rx="1" />
1671                  </svg>
1672                  <span className="status-stop-label">{cancelling ? "Cancelling..." : "Stop"}</span>
1673                </button>
1674              </div>
1675              <ContextUsageBar
1676                contextWindowSize={contextWindowSize}
1677                maxContextTokens={
1678                  models.find((m) => m.id === selectedModel)?.max_context_tokens || 200000
1679                }
1680                conversationId={conversationId}
1681                onContinueConversation={
1682                  onContinueConversation ? handleContinueConversation : undefined
1683                }
1684              />
1685            </div>
1686          ) : // Idle state - show ready message, or configuration for empty conversation
1687          !conversationId ? (
1688            // Empty conversation - show model (left) and cwd (right)
1689            <div className="status-bar-new-conversation">
1690              {/* Model selector - far left */}
1691              <div
1692                className="status-field status-field-model"
1693                title="AI model to use for this conversation"
1694              >
1695                <span className="status-field-label">Model:</span>
1696                <ModelPicker
1697                  models={models}
1698                  selectedModel={selectedModel}
1699                  onSelectModel={setSelectedModel}
1700                  onManageModels={() => onOpenModelsModal?.()}
1701                  disabled={sending}
1702                />
1703              </div>
1704
1705              {/* CWD indicator - far right */}
1706              <div
1707                className={`status-field status-field-cwd${cwdError ? " status-field-error" : ""}`}
1708                title={cwdError || "Working directory for file operations"}
1709              >
1710                <span className="status-field-label">Dir:</span>
1711                <button
1712                  className={`status-chip${cwdError ? " status-chip-error" : ""}`}
1713                  onClick={() => setShowDirectoryPicker(true)}
1714                  disabled={sending}
1715                >
1716                  {selectedCwd || "(no cwd)"}
1717                </button>
1718              </div>
1719            </div>
1720          ) : (
1721            // Active conversation - show Ready + context bar
1722            <div className="status-bar-active">
1723              <span className="status-message status-ready">Ready on {hostname}</span>
1724              <ContextUsageBar
1725                contextWindowSize={contextWindowSize}
1726                maxContextTokens={
1727                  models.find((m) => m.id === selectedModel)?.max_context_tokens || 200000
1728                }
1729                conversationId={conversationId}
1730                onContinueConversation={
1731                  onContinueConversation ? handleContinueConversation : undefined
1732                }
1733              />
1734            </div>
1735          )}
1736        </div>
1737      </div>
1738
1739      {/* Message input */}
1740      <MessageInput
1741        key={conversationId || "new"}
1742        onSend={sendMessage}
1743        disabled={sending || loading}
1744        autoFocus={true}
1745        injectedText={terminalInjectedText || diffCommentText}
1746        onClearInjectedText={() => {
1747          setDiffCommentText("");
1748          setTerminalInjectedText(null);
1749        }}
1750        persistKey={conversationId || "new-conversation"}
1751      />
1752
1753      {/* Directory Picker Modal */}
1754      <DirectoryPickerModal
1755        isOpen={showDirectoryPicker}
1756        onClose={() => setShowDirectoryPicker(false)}
1757        onSelect={(path) => {
1758          setSelectedCwd(path);
1759          setCwdError(null);
1760        }}
1761        initialPath={selectedCwd}
1762      />
1763
1764      {/* Diff Viewer */}
1765      <DiffViewer
1766        cwd={currentConversation?.cwd || selectedCwd}
1767        isOpen={showDiffViewer}
1768        onClose={() => {
1769          setShowDiffViewer(false);
1770          setDiffViewerInitialCommit(undefined);
1771        }}
1772        onCommentTextChange={setDiffCommentText}
1773        initialCommit={diffViewerInitialCommit}
1774      />
1775
1776      {/* Version Checker Modal */}
1777      {VersionModal}
1778    </div>
1779  );
1780}
1781
1782export default ChatInterface;