ChatInterface.tsx

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