import React, { useEffect, useLayoutEffect, useRef, useState, useCallback, useMemo } from 'react';
import { useChatStore } from '../hooks/useChatStore';
import { useAGUI, type AGUIPart, type ApprovalRememberScope } from '../hooks/useAGUI';
import { getApiBase, getChatPermissionState, getSandboxParams } from '../lib/api';
import { stripDenyApprovalPolicies } from '../lib/approvalPolicy';
import { hideStreamingDrafts } from '../lib/streamingDrafts';
import { reconcileToolCallMessages } from '../lib/toolCallReconciliation';
import type { Message, FileAttachment } from '../types/api';
import type { ContentBlock } from '../lib/chatUtils';
import { buildMessageRenderPlan } from '../lib/messageRenderPlan';
import { useGoalTasks } from '../hooks/useGoalTasks';
import { useMemory } from '../hooks/useMemory';
import { useCanvas } from '../hooks/useCanvas';
import type { A2UISurface } from '../types/a2ui';
import { useAutoScroll } from '../hooks/useAutoScroll';
import { useUnifiedFileUpload } from '../hooks/useUnifiedFileUpload';
import { useToolApproval } from '../hooks/chat/useToolApproval';
import {
  permissionApprovalsToParts,
  useApprovalRestore,
} from '../hooks/chat/useApprovalRestore';
import { NewChatView } from './NewChatView';
import { ChatInputPanel, type FileMentionSelection } from './ChatInputPanel';
import { ImageViewer } from './ImageViewer';
import { FileViewer } from './FileViewer';
import {
  UserMessage,
  AssistantMessage,
  RightSidebar,
  MarkdownRenderer,
  PermissionApprovalDock,
} from './chat';
import { useI18n } from '../i18n';
import { useHeartbeatRunning } from '../hooks/useHeartbeatRunning';
import { SubAgentView } from './sidebar/SubAgentView';
import { ProjectKanbanView } from './sidebar/ProjectKanbanView';
import { useSubAgentStatus } from '../hooks/useSubAgentStatus';
import { useEventBus, isBusStreaming, subscribeToBusPayloads, subscribeToStreamEvents } from '../hooks/useEventBus';
import { useStatusStore } from '../hooks/useStatusStore';
import { useContextUsageStore } from '../hooks/useContextUsageStore';
import { useActivatedToolsStore } from '../hooks/useActivatedToolsStore';
import type { SubAgentStatus } from './chat/SubAgentCallBlock';
import type {
  SubAgentSpawnedPayload,
  SubAgentCompletedPayload,
  SubAgentFailedPayload,
} from '../lib/streamEvents';

const INITIAL_VISIBLE_MESSAGES = 30;
const LOAD_MORE_MESSAGES = 60;
const LOAD_MORE_SCROLL_THRESHOLD_PX = 96;

function getLastMessageTimestamp(messages: Message[]): string | undefined {
  for (let i = messages.length - 1; i >= 0; i -= 1) {
    if (messages[i].timestamp) return messages[i].timestamp;
  }
  return undefined;
}

function formatCompactLifecycleNotice(payload: any): string | null {
  if (!payload || payload.event !== 'auto_compaction') return null;
  const source = String(payload.source || 'auto');
  const prefix = source === 'auto' ? 'Auto compaction' : 'Compaction';

  if (payload.stage === 'start') return `${prefix} running...`;
  if (source === 'slash') return null;
  if (payload.stage === 'skipped') return payload.message || `${prefix} skipped`;
  if (payload.stage === 'error') return payload.message || `${prefix} failed`;

  const before = Number(payload.tokens_before ?? 0);
  const after = payload.tokens_after != null ? Number(payload.tokens_after) : null;
  const fmt = (n: number) => (n >= 1000 ? `${(n / 1000).toFixed(1)}k` : `${n}`);
  return before > 0 && after != null
    ? `${prefix} ${fmt(before)} -> ${fmt(after)}`
    : `${prefix} complete`;
}

// ── AGUIPart[] → Store Message conversion ────────────────────────────
function escapeHtmlForStore(unsafe: string): string {
  return unsafe
    .replace(/&/g, '&amp;')
    .replace(/</g, '&lt;')
    .replace(/>/g, '&gt;')
    .replace(/"/g, '&quot;')
    .replace(/'/g, '&#039;');
}

function formatUsage(usage: any): string {
  if (!usage) return '';
  const fmt = (n: number) => n.toLocaleString();
  return `Input: ${fmt(usage.input_tokens)} | Output: ${fmt(usage.output_tokens)} | Total: ${fmt(usage.total_tokens)}`;
}

function truncateForStore(value: string, limit: number): string {
  if (value.length <= limit) return value;
  return `${value.slice(0, limit)}\n... [${value.length - limit} chars truncated for display]`;
}

function extractFileMentionPaths(value: string): string[] {
  const paths = new Set<string>();
  const pattern = /(^|\s)@((?:\/[^\s@]+)+)/g;
  let match: RegExpExecArray | null;
  while ((match = pattern.exec(value)) !== null) {
    paths.add(match[2].replace(/[),.;:!?]+$/, ''));
  }
  return Array.from(paths);
}

function extractSelectedFileMentions(value: string, mentions: FileMentionSelection[]): FileMentionSelection[] {
  const manualPaths = extractFileMentionPaths(value).map(path => ({ name: path.split('/').pop() || path, path, type: 'file' as const }));
  const selected = mentions.filter(mention => {
    const safeName = mention.name.replace(/"/g, "'");
    return value.includes(`@"${safeName}"`) || value.includes(`@[${mention.name}]`);
  });
  const byPath = new Map<string, FileMentionSelection>();
  for (const mention of [...selected, ...manualPaths]) {
    byPath.set(mention.path, mention);
  }
  return Array.from(byPath.values());
}

function formatToolArgsForStore(toolName: string, args: string): string {
  if ((toolName === 'edit_file' || toolName === 'write_file') && args.length <= 200 * 1024) {
    return args;
  }
  return truncateForStore(args, 6000);
}

/**
 * Convert AG-UI streaming parts to a store Message for persistence.
 * Tool invocations are serialized as HTML <details> blocks with emoji conventions
 * so the existing historical message rendering pipeline can display them.
 */
function aguiPartsToStoreMessage(parts: AGUIPart[], usage?: any, role: Message['role'] = 'assistant'): Message {
  // Normalize: merge duplicate tool parts (same toolCallId) so only the final
  // state is stored. Without this, a tool that went through approval-requested →
  // completed would write two 🔧 blocks, the first with data-approval-state="pending".
  const normalizedParts: AGUIPart[] = [];
  const toolIndexById = new Map<string, number>();
  for (const part of parts) {
    if (part.type === 'tool' && part.toolCallId) {
      const existingIdx = toolIndexById.get(part.toolCallId);
      if (existingIdx !== undefined) {
        const existing = normalizedParts[existingIdx];
        normalizedParts[existingIdx] = {
          ...existing,
          ...part,
          toolName: part.toolName || existing.toolName,
          args: part.args ?? existing.args,
          output: part.output ?? existing.output,
          approvalId: part.approvalId ?? existing.approvalId,
          state: part.state ?? existing.state,
        };
        continue;
      }
      toolIndexById.set(part.toolCallId, normalizedParts.length);
    }
    normalizedParts.push(part);
  }

  let content = '';
  const persistedParts: AGUIPart[] = [];
  for (const part of normalizedParts) {
    if (part.type === 'text') {
      content += part.text || '';
      persistedParts.push({ ...part });
    } else if (part.type === 'reasoning') {
      const text = part.text || '';
      if (text) {
        content += `\n\n<details data-reasoning="true"><summary>Thinking</summary>\n\n${text}\n\n</details>\n\n`;
        persistedParts.push({ ...part });
      }
    } else if (part.type === 'tool') {
      const toolName = part.toolName || 'unknown';
      const toolCallId = part.toolCallId || '';
      const argsStr = formatToolArgsForStore(toolName, part.args || '');
      const outputStr = part.output != null ? truncateForStore(part.output, 12000) : undefined;
      const approvalId = part.approvalId || '';
      const stateAttr = (part.state === 'approval-requested' && !outputStr) ? 'pending' : (part.state === 'error' ? 'denied' : '');
      const attrs = ` data-tool-call-id="${toolCallId}"` +
        (approvalId ? ` data-approval-id="${approvalId}"` : '') +
        (stateAttr ? ` data-approval-state="${stateAttr}"` : '');

      content += `\n\n<details${attrs}><summary>\u{1F527} ${toolName}</summary>\n\n<pre><code class="language-text">${escapeHtmlForStore(argsStr)}</code></pre>\n\n</details>\n\n`;
      if (outputStr != null) {
        content += `\n\n<details data-tool-call-id="${toolCallId}"><summary>\u{1F4E6} ${toolName}</summary>\n\n<pre><code class="language-text">${escapeHtmlForStore(outputStr)}</code></pre>\n\n</details>\n\n`;
      }
      persistedParts.push({ ...part, args: argsStr, output: outputStr });
    } else if (part.type === 'a2ui' && part.surface && !part.surface.deferred) {
      // Inline A2UI surface — stored as a data attribute for re-hydration.
      // Deferred surfaces (ask_question) are transient and must not persist in history.
      const encoded = encodeURIComponent(JSON.stringify(part.surface));
      content += `\n\n<div data-a2ui="${encoded}"></div>\n\n`;
      persistedParts.push({ ...part, surface: { ...part.surface } });
    }
  }
  return { role, content, parts: persistedParts, timestamp: new Date().toISOString(), stepInfo: usage ? formatUsage(usage) : undefined };
}

function finalizeInterruptedParts(parts: AGUIPart[]): AGUIPart[] {
  return parts.map(part => {
    if (part.type === 'tool' && part.state === 'approval-requested') {
      return {
        ...part,
        state: 'error' as const,
        approvalId: undefined,
        output: part.output ?? 'Cancelled: user redirected the conversation',
      };
    }
    if (part.type === 'tool' && part.state === 'running') {
      return {
        ...part,
        state: 'error' as const,
        output: part.output ?? 'Interrupted: user redirected the conversation',
      };
    }
    return part;
  });
}

function groupedBlocksToAssistantContent(blocks: ContentBlock[]): string {
  let content = '';
  for (const block of blocks) {
    if (block.type === 'toolCall') {
      const toolName = block.toolName || 'unknown';
      const toolCallId = block.toolCallId || '';
      const argsStr = block.toolArgs || '';
      const approvalId = block.approvalId || '';
      const stateAttr = block.approvalState === 'pending' ? 'pending' : (block.approvalState === 'denied' ? 'denied' : '');
      const attrs = ` data-tool-call-id="${toolCallId}"` +
        (approvalId ? ` data-approval-id="${approvalId}"` : '') +
        (stateAttr ? ` data-approval-state="${stateAttr}"` : '');

      content += `\n\n<details${attrs}><summary>\u{1F527} ${toolName}</summary>\n\n<pre><code class="language-text">${escapeHtmlForStore(argsStr)}</code></pre>\n\n</details>\n\n`;
      if (block.content) {
        content += `\n\n<details data-tool-call-id="${toolCallId}"><summary>\u{1F4E6} ${toolName}</summary>\n\n<pre><code class="language-text">${escapeHtmlForStore(block.content)}</code></pre>\n\n</details>\n\n`;
      }
    } else if (block.type === 'reasoning' && block.content.trim()) {
      content += `\n\n<details data-reasoning="true"><summary>Thinking</summary>\n\n${block.content}\n\n</details>\n\n`;
    }
  }
  return content;
}

// ── Stream seed helpers (sessionStorage) ─────────────────────────────
// Persists streaming parts across page refreshes so the frontend can
// (a) reconnect with a visual seed, or (b) detect a missed stream completion.
const STREAM_SEED_KEY = 'chat_stream_seed_v1';

interface StreamSeed {
  chatId: string;
  parts: AGUIPart[];
  ts: number;
}

function _saveStreamSeed(chatId: string, parts: AGUIPart[]): void {
  try {
    const seed: StreamSeed = { chatId, parts, ts: Date.now() };
    sessionStorage.setItem(STREAM_SEED_KEY, JSON.stringify(seed));
  } catch { /* storage may be full or unavailable */ }
}

function _loadStreamSeed(chatId: string): StreamSeed | null {
  try {
    const raw = sessionStorage.getItem(STREAM_SEED_KEY);
    if (!raw) return null;
    const seed: StreamSeed = JSON.parse(raw);
    if (seed.chatId !== chatId) return null;
    if (Date.now() - seed.ts > 10 * 60 * 1000) {
      sessionStorage.removeItem(STREAM_SEED_KEY);
      return null;
    }
    return seed;
  } catch { return null; }
}

function _clearStreamSeed(): void {
  try { sessionStorage.removeItem(STREAM_SEED_KEY); } catch { /* ignore */ }
}

// Drag overlay component
const DragOverlay: React.FC = () => {
  const { t } = useI18n();
  return (
    <div className="absolute inset-0 z-50 bg-brutal-blue/20 border-4 border-dashed border-brutal-black flex items-center justify-center pointer-events-none">
      <div className="bg-brutal-yellow border-4 border-brutal-black shadow-brutal-xl px-8 py-6 flex flex-col items-center gap-3">
        <svg xmlns="http://www.w3.org/2000/svg" className="h-16 w-16 text-brutal-black" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={3}>
          <path strokeLinecap="round" strokeLinejoin="round" d="M15.172 7l-6.586 6.586a2 2 0 102.828 2.828l6.414-6.586a4 4 0 00-5.656-5.656l-6.415 6.585a6 6 0 108.486 8.486L20.5 13" />
        </svg>
        <span className="text-lg font-bold text-brutal-black uppercase">{t('chatWindow.dragDropTitle')}</span>
        <span className="text-sm text-brutal-black">{t('chatWindow.dragDropDesc')}</span>
      </div>
    </div>
  );
};

// Scroll to bottom button
const ScrollToBottomButton: React.FC<{ onClick: () => void }> = ({ onClick }) => {
  const { t } = useI18n();
  return (
    <button
      onClick={onClick}
      className="absolute bottom-6 right-6 z-20 w-10 h-10 bg-white dark:bg-zinc-800 text-brutal-black dark:text-white border-2 border-brutal-black dark:border-zinc-600 shadow-[4px_4px_0_0_#000] flex items-center justify-center hover:bg-neutral-100 dark:hover:bg-zinc-700 active:translate-y-[2px] active:translate-x-[2px] active:shadow-none transition-all animate-brutal-pop"
      title={t('chatWindow.scrollToBottom')}
    >
      <svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={2.5}>
        <path strokeLinecap="round" strokeLinejoin="round" d="M19 14l-7 7m0 0l-7-7m7 7V3" />
      </svg>
    </button>
  );
};

// Loading indicator
const LoadingIndicator: React.FC = () => {
  const { t } = useI18n();
  return (
    <div className="flex items-center justify-center p-4">
      <div className="bg-brutal-yellow border-2 border-brutal-black px-4 py-2 text-xs font-bold uppercase animate-pulse shadow-brutal-sm">
        {t('chatWindow.connecting')}
      </div>
    </div>
  );
};

const SystemTriggeredMessage: React.FC<{ message: Message }> = ({ message }) => {
  const raw = (message.content || '').trim();
  if (!raw) return null;

  // Pull out the leading title: prefer a **bold** first line, else first line.
  const lines = raw.split('\n');
  const firstLine = lines[0].trim();
  const boldMatch = firstLine.match(/^\*\*(.+?)\*\*$/);
  const title = (boldMatch ? boldMatch[1] : firstLine).trim();
  const body = lines.slice(1).join('\n').trim();

  return (
    <div className="w-full max-w-3xl my-1 pl-2 md:pl-4">
      <div className="border-l-[3px] border-brutal-black/20 dark:border-neutral-700 px-4 py-2 opacity-50 hover:opacity-100 transition-opacity duration-300">
        {/* Title */}
        <div className="flex items-center gap-2 mb-1.5">
          <span className="text-brutal-black/60 dark:text-neutral-400 text-[10px] leading-none" aria-hidden="true">⏱</span>
          <span className="font-mono text-[10px] font-bold uppercase tracking-wider text-brutal-black/60 dark:text-neutral-400 truncate">
            {title}
          </span>
        </div>
        
        {/* Body */}
        {body && (
          <div className="text-[12px] leading-relaxed text-brutal-black/80 dark:text-neutral-300">
            <MarkdownRenderer content={body} />
          </div>
        )}
      </div>
    </div>
  );
};

const NoticeMessage: React.FC<{ message: Message }> = ({ message }) => {
  if (!message.content?.trim()) {
    return null;
  }

  return (
    <div className="w-full max-w-3xl pl-2 md:pl-6">
      <div className="border-2 border-dashed border-brutal-black bg-brutal-yellow/20 px-4 py-3 shadow-[3px_3px_0_0_#000]">
        <div className="text-[10px] font-bold uppercase tracking-wider text-brutal-black">Notice</div>
        <div className="text-sm leading-relaxed text-brutal-black"><MarkdownRenderer content={message.content} /></div>
      </div>
    </div>
  );
};

// Message list component (renders historical / store messages only)
const MessageList: React.FC<{
  messages: Message[];
  streamingForCurrentChat: boolean;
  messageIndexOffset?: number;
  chatId?: string;
  onImageClick?: (src: string) => void;
  onFileClick?: (filePath: string, fileName: string, shiftKey?: boolean) => void;
  onToolApproval?: (approvalId: string, toolCallId: string, approved: boolean, remember?: ApprovalRememberScope, toolName?: string) => void;
  toolApprovalPolicy?: Record<string, string>;
  onRemoveApprovalPolicy?: (toolName: string) => void;
  onInlineAction?: (surfaceId: string, action: string, context: Record<string, unknown>) => void;
  subAgentTasks?: Record<string, { status: SubAgentStatus; resultSummary?: string; error?: string }>;
  onOpenSubAgentSidebar?: (taskId: string) => void;
  onStopSubAgent?: (taskId: string) => void;
  onForceWebContext?: (contextId: string) => void;
  onRetry?: () => void;
}> = ({ messages, streamingForCurrentChat, messageIndexOffset = 0, chatId, onImageClick, onFileClick, onToolApproval, toolApprovalPolicy, onRemoveApprovalPolicy, onInlineAction, subAgentTasks, onOpenSubAgentSidebar, onStopSubAgent, onForceWebContext, onRetry }) => {
  const { skipIndices, groupRenders, stepSummaryByMessageIndex } = useMemo(
    () => buildMessageRenderPlan(messages),
    [messages],
  );

  // Index of the last non-skipped assistant message — only it shows the retry button.
  const lastAssistantIdx = useMemo(() => {
    for (let i = messages.length - 1; i >= 0; i--) {
      if (messages[i].role === 'assistant' && !skipIndices.has(i)) return i;
    }
    return -1;
  }, [messages, skipIndices]);

  const getPreviousMessageTimestamp = (index: number): string | undefined => {
    for (let i = index - 1; i >= 0; i -= 1) {
      if (messages[i].timestamp) return messages[i].timestamp;
    }
    return undefined;
  };

  return (
    <div className="space-y-6">
      {messages.map((m, idx) => {
        if (skipIndices.has(idx)) return null; // Part of a group, rendered by representative

        const globalIdx = messageIndexOffset + idx;
        const isUser = m.role === 'user';
        const isNotice = m.role === 'notice';
        const isLastMessage = idx === messages.length - 1;
        const isAssistant = m.role === 'assistant';
        const group = groupRenders.get(idx);

        // Intermediate step group representative: badge shown here (top of the turn), tool pills below.
        if (group) {
          const groupedMessage: Message = {
            role: 'assistant',
            content: groupedBlocksToAssistantContent(group.mergedBlocks),
            timestamp: m.timestamp,
          };
          return (
            <div key={globalIdx} className="chat-msg-row w-full flex flex-col group/message">
              <div className="flex justify-start w-full">
                <AssistantMessage
                  message={groupedMessage}
                  previousMessageTimestamp={getPreviousMessageTimestamp(idx)}
                  messageIndex={globalIdx}
                  isStreaming={false}
                  isLastMessage={false}
                  onFileClick={onFileClick}
                  onToolApproval={onToolApproval}
                  toolApprovalPolicy={toolApprovalPolicy}
                  onRemoveApprovalPolicy={onRemoveApprovalPolicy}
                  onInlineAction={onInlineAction}
                  subAgentTasks={subAgentTasks}
                  onOpenSubAgentSidebar={onOpenSubAgentSidebar}
                  onStopSubAgent={onStopSubAgent}
                  onForceWebContext={onForceWebContext}
                />
              </div>
            </div>
          );
        }

        // Regular message rendering
        const stepSummary = isAssistant ? (stepSummaryByMessageIndex.get(idx) || null) : null;

        // Canvas action message — lightweight dashed pill
        if (m.role === 'canvas_action') {
          return (
            <div key={globalIdx} className="chat-msg-row w-full flex justify-start pl-2">
              <div className="border-2 border-dashed border-brutal-black px-4 py-2 text-sm font-mono text-neutral-500 dark:text-neutral-400 italic bg-white dark:bg-zinc-800">
                {m.content}
              </div>
            </div>
          );
        }

        // System-triggered turn (cron, heartbeat, wake-parent etc.) — compact card
        // showing what kicked off this agent turn.
        if (m.role === 'system_triggered') {
          return (
            <div key={globalIdx} className="chat-msg-row w-full flex justify-start">
              <SystemTriggeredMessage message={m} />
            </div>
          );
        }

        if (isNotice) {
          return (
            <div key={globalIdx} className="chat-msg-row w-full flex justify-start">
              <NoticeMessage message={m} />
            </div>
          );
        }

        return (
          <div key={globalIdx} className="chat-msg-row w-full flex flex-col group/message">
            <div className={`flex ${isUser ? 'justify-end' : 'justify-start'} w-full`}>
              {isUser ? (
                <UserMessage message={m} chatId={chatId} onImageClick={onImageClick} onFileClick={onFileClick} />
              ) : (
                <AssistantMessage
                  message={m}
                  previousMessageTimestamp={getPreviousMessageTimestamp(idx)}
                  messageIndex={globalIdx}
                  isStreaming={streamingForCurrentChat}
                  isLastMessage={isLastMessage}
                  onFileClick={onFileClick}
                  onToolApproval={onToolApproval}
                  toolApprovalPolicy={toolApprovalPolicy}
                  onRemoveApprovalPolicy={onRemoveApprovalPolicy}
                  onInlineAction={onInlineAction}
                  subAgentTasks={subAgentTasks}
                  onOpenSubAgentSidebar={onOpenSubAgentSidebar}
                  onStopSubAgent={onStopSubAgent}
                  onForceWebContext={onForceWebContext}
                  onRetry={idx === lastAssistantIdx && !streamingForCurrentChat ? onRetry : undefined}
                />
              )}
            </div>
            {isAssistant && stepSummary && (
              <div className="flex justify-start w-full mt-2 pl-4">
                <div className="inline-flex items-center gap-2 text-[10px] text-brutal-black font-mono font-bold px-3 py-1 bg-neutral-100 border-2 border-brutal-black shadow-sm select-none">
                  <span className="text-brutal-blue">⚡</span>
                  <span>{stepSummary}</span>
                </div>
              </div>
            )}
          </div>
        );
      })}
    </div>
  );
};

// Memoised so scroll-driven setShowScrollButton re-renders in ChatWindow don't
// retrigger the O(n) message grouping logic.
const MessageListMemo = React.memo(MessageList);

interface ChatWindowProps {
  isRightSidebarOpen?: boolean;
  onRightSidebarToggle?: (isOpen: boolean) => void;
  onRightSidebarWidthChange?: (width: number | null) => void;
  rightSidebarMaxWidthPx?: number;
  viewportWidthPx?: number;
  rightSidebarForceFullView?: boolean;
}

export const ChatWindow: React.FC<ChatWindowProps> = ({
  isRightSidebarOpen = false,
  onRightSidebarToggle = () => { },
  onRightSidebarWidthChange,
  rightSidebarMaxWidthPx,
  viewportWidthPx,
  rightSidebarForceFullView = false,
}) => {

  // Store hooks
  const {
    messages,
    addMessage,
    config,
    backendConfig,
    setConfig,
    shouldResetNext,
    consumeResetFlag,
    updateMessage,
    truncateMessagesFrom,
    setIsStreaming,
    currentChatId,
    createNewChat,
    loadChat,
    isStreaming,
    activeStreamingChatId,
    chats,
    refreshChatListSilently,
    updateChatTitleLocally,
  } = useChatStore();

  const { refresh: refreshGoalTasks, refreshKanban, goal, tasks, kanban } = useGoalTasks();
  const { loadCoreMemory, loadStats } = useMemory();
  const canvas = useCanvas(currentChatId);
  const { t } = useI18n();
  const setHeartbeatRunning = useHeartbeatRunning(s => s.setRunning);

  // Local state
  const [input, setInput] = useState('');
  const [viewingImage, setViewingImage] = useState<string | null>(null);
  const [viewingFile, setViewingFile] = useState<{ path: string; name: string } | null>(null);
  const [sidebarFilePreview, setSidebarFilePreview] = useState<{ path: string; name: string } | null>(null);
  const [currentUsage, setCurrentUsage] = useState<any>(null);
  const {
    setUsage: setLastKnownUsage,
    setUsageForChat,
    clearUsage: clearLastKnownUsage,
  } = useContextUsageStore();
  const { addActivatedTools, clearActivatedTools } = useActivatedToolsStore();
  const [subAgentTasks, setSubAgentTasks] = useState<Record<string, { status: SubAgentStatus; resultSummary?: string; error?: string }>>({});
  const [viewingSubAgentTaskId, setViewingSubAgentTaskId] = useState<string | null>(null);
  const [forcedWebContextId, setForcedWebContextId] = useState<string | null>(null);
  const [isBoardFullscreen, setIsBoardFullscreen] = useState(false);
  const [fileMentions, setFileMentions] = useState<FileMentionSelection[]>([]);
  const [visibleMessageCount, setVisibleMessageCount] = useState(INITIAL_VISIBLE_MESSAGES);
  const [compactNotice, setCompactNoticeLocal] = useState<string | null>(null);
  const prependScrollSnapshotRef = useRef<{ scrollHeight: number; scrollTop: number } | null>(null);
  const { onSpawned: onSubAgentSpawned, onCompleted: onSubAgentCompleted, onFailed: onSubAgentFailed } = useSubAgentStatus();
  const { setStatus: setStatusBar } = useStatusStore();
  const textareaRef = useRef<HTMLTextAreaElement | null>(null);
  const stopInFlightRef = useRef(false);
  // True while a steer is in flight — prevents the normal-send finally from hiding the bubble
  const steeringRef = useRef(false);

  // Ref for async callback access to current chatId
  const activeChatIdRef = useRef<string | null>(currentChatId);
  useEffect(() => {
    activeChatIdRef.current = currentChatId;
    setVisibleMessageCount(INITIAL_VISIBLE_MESSAGES);
    setIsBoardFullscreen(false);
    prependScrollSnapshotRef.current = null;
    clearActivatedTools();
    if (!currentChatId) {
      clearLastKnownUsage();
      return;
    }

    const cachedUsage = useContextUsageStore.getState().getUsageForChat(currentChatId);
    if (cachedUsage) {
      setLastKnownUsage(cachedUsage);
    } else {
      clearLastKnownUsage();
    }
  }, [currentChatId]);

  // Keep sub-agent UI state scoped to the currently viewed chat.
  // Without this reset, task state from the previous chat can leave the
  // agents tab incorrectly enabled after switching chats.
  useEffect(() => {
    setSubAgentTasks({});
    setViewingSubAgentTaskId(null);
  }, [currentChatId]);

  useEffect(() => {
    if (!currentChatId) {
      setCompactNoticeLocal(null);
      return;
    }

    const unsub = subscribeToBusPayloads((payload) => {
      if (!payload || payload.event !== 'auto_compaction') return;
      if (payload.chat_id !== currentChatId) return;
      setCompactNoticeLocal(formatCompactLifecycleNotice(payload));
    });
    return unsub;
  }, [currentChatId]);

  // Ref to lock the chat ID for the current stream so switching chats doesn't misroute messages
  const streamingChatIdRef = useRef<string | null>(null);

  // Set to true when the backend signals HEARTBEAT_OK — onFinish should discard the message.
  const heartbeatOkRef = useRef(false);
  const heartbeatInFlightRef = useRef(false);
  // Set to true while a live background stream is active — onFinish should skip addMessage/forceSaveNow.
  const isLiveStreamRef = useRef(false);
  // Set to true when a stream_started arrives while a live/user stream is already in flight;
  // tryConnect will retry once the current stream finishes.
  const pendingConnectRef = useRef(false);
  // True while a tryConnect is already in-flight (fetch to /chat/live is open).
  // Prevents duplicate connections when both the direct call and stream_started fire.
  const connectingRef = useRef(false);
  // Holds the current effect's tryConnect so handleSend can call it directly
  // after /chat/send returns 202, without waiting for stream_started from the bus.
  const tryConnectRef = useRef<(() => void) | null>(null);
  // Captures the final AGUI parts from a live background stream so the cleanup path can
  // convert them to a persisted rich message (with tool-step HTML) after clearParts.
  const liveStreamPartsRef = useRef<AGUIPart[]>([]);
  // Chats whose live stream we silently abandoned on navigation. On return we
  // reload them from the DB so the response that streamed (and was persisted by
  // the backend) while we were away is shown.
  const abandonedStreamChatsRef = useRef<Set<string>>(new Set());
  // Snapshot of streaming parts at the moment we abandoned a chat's live stream,
  // so reconnecting can seed them back (preserving steps and in-flight tools).
  const abandonedPartsRef = useRef<Map<string, AGUIPart[]>>(new Map());
  // Wall-clock start time per streaming chat, so the activity timer resumes from
  // the original start instead of resetting when the transient message remounts.
  const streamStartByChatRef = useRef<Map<string, number>>(new Map());
  const [streamDisplayRole, setStreamDisplayRole] = useState<Message['role']>('assistant');
  const streamDisplayRoleRef = useRef<Message['role']>('assistant');

  const setCurrentStreamDisplayRole = useCallback((role: Message['role']) => {
    streamDisplayRoleRef.current = role;
    setStreamDisplayRole(role);
  }, []);

  // ── AG-UI streaming hook ─────────────────────────────────────────────
  const {
    parts: streamingParts,
    sendMessage: sendAGUI,
    stop: stopAGUIStream,
    stopSilently: stopAGUIStreamSilently,
    getParts: getStreamingParts,
    clearParts,
    restorePartsFromSeed,
    resolveApproval,
    addApprovalDecision,
    consumeApprovalDecisions,
    removeInlineSurface,
  } = useAGUI({
    url: `${getApiBase()}/chat`,
    onFinish: (parts) => {
      const chatId = streamingChatIdRef.current || activeChatIdRef.current;

      const hasPendingApprovals = parts.some(
        p => p.type === 'tool' && p.state === 'approval-requested'
      );

      if (heartbeatOkRef.current) {
        // HEARTBEAT_OK: backend rolled back the messages; discard streamed content and reload.
        setHeartbeatRunning(false, null);
        heartbeatInFlightRef.current = false;
        heartbeatOkRef.current = false;
        setIsStreaming(false, chatId);
        streamingChatIdRef.current = null;
        clearParts();
        _clearStreamSeed();
        setCurrentUsage(null);
        setCurrentStreamDisplayRole('assistant');
        // Reload chat from DB to reflect rolled-back state.
        setTimeout(() => { try { loadChat(chatId!, { force: true }); } catch { } }, 300);
        return;
      }

      // Stream paused for HITL approvals: keep transient parts visible and
      // wait for resume before persisting to the chat history.
      if (hasPendingApprovals) {
        setIsStreaming(true, chatId);
        heartbeatInFlightRef.current = false;
        setCurrentUsage(null);
        // For live background streams (social/cron), save the parts so tryConnect's
        // cleanup can detect the pending-approval state and skip clearParts().
        if (isLiveStreamRef.current) {
          liveStreamPartsRef.current = parts;
        }
        setCurrentStreamDisplayRole('assistant');
        return;
      }

      if (isLiveStreamRef.current) {
        // Live background stream: backend already persisted the message.
        // Capture final parts so the cleanup path can persist the rich (tool-step) version.
        // Do NOT call addMessage, forceSaveNow, setIsStreaming(false), or clearParts here.
        // The live stream effect's finally() handles all cleanup AFTER loadChat resolves,
        // so streaming parts stay visible until the DB reload completes — no blank flash.
        liveStreamPartsRef.current = parts;
        streamingChatIdRef.current = null;
        setCurrentUsage(null);
        setCurrentStreamDisplayRole('assistant');
        return;
      }

      // 100% Backend Authored: instead of pushing frontend-assembled HTML,
      // load the official JSON-based chat history from the backend.
      if (heartbeatInFlightRef.current) {
        setHeartbeatRunning(false, null);
      }
      heartbeatInFlightRef.current = false;
      streamingChatIdRef.current = null;
      // Clear any stale live-stream parts from a previous background turn so
      // tryConnect's cleanup path doesn't convert them to a redundant message.
      liveStreamPartsRef.current = [];

      // Optimistic append: convert parts to HTML and store locally so the
      // message is visible immediately — no blank flash while loadChat fetches DB.
      const storeMsg = aguiPartsToStoreMessage(parts, currentUsage, streamDisplayRoleRef.current);
      if (storeMsg.content.trim()) {
        addMessage(storeMsg, chatId!);
        if (/context compacted/i.test(storeMsg.content)) {
          setCompactNoticeLocal('Context compacted');
        }
      }

      setCurrentUsage(null);
      setCurrentStreamDisplayRole('assistant');
      // Clear streaming state synchronously in the same React batch as addMessage so
      // the transient assistant bubble is replaced by the optimistic message in one
      // render. Previously these lived in .finally(), causing a window where the
      // transient disappeared but the DB reload hadn't returned yet — the user saw
      // stale pending-approval tool blocks from the backend race condition.
      setIsStreaming(false, chatId);
      clearParts();
      _clearStreamSeed();

      // Background DB sync — delay slightly so the backend has time to commit tool
      // results before we reload. An immediate reload risks getting stale
      // approval-requested state that the guards may not catch in all edge cases.
      const _syncChatId = chatId!;
      setTimeout(() => { try { loadChat(_syncChatId, { force: true }); } catch { } }, 800);

      try { loadCoreMemory(); loadStats(); } catch { }
      try { refreshGoalTasks(); refreshKanban(); } catch { }
    },
    onMarkDeferred: (surfaceId) => {
      canvas.markDeferred(surfaceId);
    },
    onCustomEvent: (name, value) => {
      if (name === 'stream_display_role') {
        const role = (value as { role?: Message['role'] })?.role;
        if (role === 'assistant' || role === 'notice') {
          setCurrentStreamDisplayRole(role);
        }
        return;
      }
      if (name === 'processing_status') {
        const phase = (value as { phase?: string } | null)?.phase;
        if (phase === 'compressing_context') {
          setCompactNoticeLocal('Compaction running...');
        } else if (phase === 'running' || phase === 'complete') {
          setCompactNoticeLocal('Compaction complete');
        }
        return;
      }
      if (name === 'chat_title_updated') {
        const { chat_id: titleChatId, title } = value as { chat_id: string; title: string };
        if (titleChatId && title) updateChatTitleLocally(titleChatId, title);
        return;
      } else if (name === 'plan_refresh') {
        // Refresh goal/task sidebar after agent tool calls or stream end
        refreshGoalTasks();
        refreshKanban();
      } else if (name === 'usage_update') {
        setCurrentUsage(value);
        const chatId = streamingChatIdRef.current || activeChatIdRef.current;
        if (chatId) {
          setUsageForChat(chatId, value as any);
        } else {
          setLastKnownUsage(value as any);
        }
      } else if (name === 'heartbeat_ok') {
        // Backend signals that this heartbeat run was OK and messages were rolled back.
        heartbeatOkRef.current = true;
      } else if (name === 'subagent_spawned') {
        const p = value as SubAgentSpawnedPayload;
        onSubAgentSpawned(p);
        setSubAgentTasks(prev => ({ ...prev, [p.task_id]: { status: 'running' } }));
      } else if (name === 'subagent_completed') {
        const p = value as SubAgentCompletedPayload;
        onSubAgentCompleted(p);
        setSubAgentTasks(prev => ({ ...prev, [p.task_id]: { status: 'completed', resultSummary: p.result_summary } }));
        setStatusBar(`Sub-agent completed — ${p.task_id}`, 'success', 5000);
      } else if (name === 'subagent_failed') {
        const p = value as SubAgentFailedPayload;
        onSubAgentFailed(p);
        setSubAgentTasks(prev => ({ ...prev, [p.task_id]: { status: 'failed', error: p.error } }));
        setStatusBar(`Sub-agent failed — ${p.task_id}`, 'error', 5000);
      } else if (name === 'tool_activated') {
        const { toolNames } = value as { toolNames: string[] };
        if (Array.isArray(toolNames)) addActivatedTools(toolNames);
      } else if (name === 'a2ui.render') {
        const rawSurface = value as (A2UISurface & { chatId?: string }) | null;
        if (!rawSurface || typeof rawSurface !== 'object' || !('id' in rawSurface) || !('component' in rawSurface)) {
          return;
        }
        const sourceChatId = rawSurface.chatId || streamingChatIdRef.current || activeStreamingChatId || null;
        const viewedChatId = activeChatIdRef.current;
        if (sourceChatId && viewedChatId && sourceChatId !== viewedChatId) {
          return;
        }
        const { chatId: _eventChatId, ...surfaceData } = rawSurface;
        const surface = surfaceData as A2UISurface;
        const isFirst = !canvas.hasSurfaces;
        canvas.setSurface(surface);
        if (isFirst) {
          onRightSidebarToggle(true);
        }
      }
    },
    onError: (error, parts = []) => {
      console.error('AG-UI error:', error);
      const chatId = streamingChatIdRef.current || activeChatIdRef.current;
      const wasHeartbeat = heartbeatInFlightRef.current;
      if (wasHeartbeat) setHeartbeatRunning(false, null);
      heartbeatInFlightRef.current = false;
      heartbeatOkRef.current = false;
      setIsStreaming(false, chatId);
      streamingChatIdRef.current = null;
      stopInFlightRef.current = false;
      if (chatId) {
        streamStartByChatRef.current.delete(chatId);
        abandonedPartsRef.current.delete(chatId);
      }
      const errorMessage = typeof error?.message === 'string' ? error.message : '';
      const isNetworkError = errorMessage === 'Failed to fetch' || error instanceof TypeError;
      const isOutputValidationRetryError =
        errorMessage.includes('output validation') &&
        errorMessage.includes('Exceeded maximum retries');

      const displayMessage = isOutputValidationRetryError
        ? t('chatWindow.outputValidationRetryError')
        : (errorMessage || t('chatWindow.genericError'));

      if (!wasHeartbeat && !isNetworkError) {
        const partialMessage = aguiPartsToStoreMessage(parts, currentUsage, streamDisplayRoleRef.current);
        if (partialMessage.content.trim()) {
          addMessage(partialMessage, chatId);
          addMessage({ role: 'notice', content: `\u26a0\ufe0f Error: ${displayMessage}` }, chatId);
        } else {
          addMessage({ role: 'assistant', content: `\u26a0\ufe0f Error: ${displayMessage}` }, chatId);
        }
      }
      clearParts();
      setCurrentUsage(null);
      isLiveStreamRef.current = false;
      liveStreamPartsRef.current = [];
      setCurrentStreamDisplayRole('assistant');
    },
  });

  // Listen for external message triggers (e.g. from Heartbeat "Run Now")
  useEffect(() => {
    const handler = (e: any) => {
      const { body, options } = e.detail || {};
      const isHeartbeat = Boolean(body?.is_heartbeat);
      heartbeatInFlightRef.current = isHeartbeat;
      if (body) {
        const chatId = body.chat_id || currentChatId;
        if (isHeartbeat) setHeartbeatRunning(true, chatId);
        // Mirror what handleSend does so the streaming overlay renders.
        setIsStreaming(true, chatId);
        streamingChatIdRef.current = chatId;
        sendAGUI(body, options).catch(err => {
          heartbeatInFlightRef.current = false;
          if (isHeartbeat) setHeartbeatRunning(false, null);
          console.error('[ChatWindow] External sendAGUI failed:', err);
        }).finally(() => {
          setIsStreaming(false, chatId);
        });
      }
    };
    window.addEventListener('agui:send-message', handler);
    return () => window.removeEventListener('agui:send-message', handler);
  }, [sendAGUI, currentChatId, setHeartbeatRunning, setIsStreaming]);

  // Save the current streaming parts to sessionStorage on page hide so they
  // can be used as a seed (or to detect a missed completion) after a refresh.
  useEffect(() => {
    const saveOnHide = () => {
      const chatId = streamingChatIdRef.current;
      if (!chatId) return;
      const parts = getStreamingParts();
      if (parts.length > 0) {
        _saveStreamSeed(chatId, parts);
      }
    };
    document.addEventListener('visibilitychange', saveOnHide);
    window.addEventListener('beforeunload', saveOnHide);
    return () => {
      document.removeEventListener('visibilitychange', saveOnHide);
      window.removeEventListener('beforeunload', saveOnHide);
    };
  // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [getStreamingParts]);

  // Resume a tool-approval stream via the background queue so it is
  // reconnectable after a page refresh (same pattern as /chat/send).
  const resumeViaQueue = useCallback(async (body: Record<string, unknown>) => {
    const chatId = (body.chat_id as string) || streamingChatIdRef.current || currentChatId;
    if (!chatId) return;

    // Save current parts as seed so tryConnect's reconnect can show prior steps.
    const currentParts = getStreamingParts();
    if (currentParts.length > 0) {
      abandonedPartsRef.current.set(chatId, currentParts);
    }

    const resp = await fetch(`${getApiBase()}/chat/send`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(body),
    });

    if (!resp.ok) {
      const msg = resp.status === 409 ? 'Chat is already responding' : `Resume failed (${resp.status})`;
      setStatusBar(msg, 'error', 4000);
      throw new Error(msg);
    }
    // 202: the previous (suspended) stream has ended, but its onFinish pending-
    // approval branch left streamingChatIdRef pinned to this chat. That pin would
    // make tryConnect's guard skip the reconnect, so the resume stream would never
    // be consumed. Clear the live-stream refs so tryConnect can attach; its
    // onStreamStart re-pins streamingChatIdRef correctly.
    streamingChatIdRef.current = null;
    isLiveStreamRef.current = false;
    // Connect immediately rather than waiting for stream_started from the bus.
    tryConnectRef.current?.();
  }, [currentChatId, getStreamingParts, setStatusBar]);

  const { handleToolApproval } = useToolApproval({
    currentChatId,
    activeStreamingChatId,
    config,
    messages,
    streamingParts,
    streamingChatIdRef,
    activeChatIdRef,
    stopInFlightRef,
    setConfig,
    updateMessage,
    setIsStreaming,
    resumeStream: resumeViaQueue,
    resolveApproval,
    addApprovalDecision,
    consumeApprovalDecisions,
  });

  const { restorePendingApprovals, clearIfResolved } = useApprovalRestore({
    restorePartsFromSeed,
    setIsStreaming,
    clearParts,
    streamingChatIdRef,
  });

  // Remove a tool from the approval policy
  const handleRemoveApprovalPolicy = useCallback((toolName: string) => {
    setConfig(prev => {
      if (!prev.tool_approval_policy) return prev;
      const { [toolName]: _, ...rest } = prev.tool_approval_policy;
      return {
        ...prev,
        tool_approval_policy: rest
      };
    });
  }, [setConfig]);

  // handleCanvasDispatch is defined after safeConfig below

  // Custom hooks
  const {
    selectedFiles,
    isDragging,
    fileInputRef,
    handleFileSelect,
    handlePaste,
    removeFile,
    clearFiles,
    handleDragEnter,
    handleDragLeave,
    handleDragOver,
    handleDrop,
    uploadFiles,
    uploadProgress,
    isUploading,
    error: fileError,
  } = useUnifiedFileUpload();

  const safeBackendConfig = backendConfig || null;
  const streamingForCurrentChat = isStreaming && activeStreamingChatId === currentChatId;
  // Keep transient tool approvals attached to their source chat.
  // Without this guard, switching chats can render stale approval UI
  // from a different chat because AG-UI parts are kept for resume.
  const transientPartsChatId = streamingChatIdRef.current || activeStreamingChatId;
  const hasPendingTransientApprovals =
    transientPartsChatId === currentChatId &&
    streamingParts.some(p => p.type === 'tool' && p.state === 'approval-requested');
  const showTransientAssistant = streamingForCurrentChat || hasPendingTransientApprovals;

  // Safe values
  const safeMessages = useMemo(
    () => reconcileToolCallMessages(
      hideStreamingDrafts(messages || [], showTransientAssistant),
      showTransientAssistant ? streamingParts : [],
    ),
    [messages, showTransientAssistant, streamingParts],
  );
  const visibleMessageStartIndex = Math.max(0, safeMessages.length - visibleMessageCount);
  const visibleMessages = useMemo(
    () => safeMessages.slice(visibleMessageStartIndex),
    [safeMessages, visibleMessageStartIndex],
  );
  const hasHiddenOlderMessages = visibleMessageStartIndex > 0;
  const _prefs = backendConfig?.userPreferences;
  const _base = config || { model: '', agent: '', tools: [] as string[] };
  // Platform chats (cron, social) have no model/agent — fall back to user prefs.
  // The config selector is hidden for these chats so users can't override the managed config.
  const _isPlatformChat = !!(config as any)?.platform;
  const safeConfig = stripDenyApprovalPolicies({
    ..._base,
    model: _base.model || (_isPlatformChat ? (_prefs?.model || backendConfig?.models?.[0] || '') : ''),
    agent: _base.agent || (_isPlatformChat ? (_prefs?.agent || backendConfig?.agents?.[0] || '') : ''),
    tools: _base.tools?.length ? _base.tools : (_isPlatformChat ? (_prefs?.tools ?? []) : []),
  });

  // Unified canvas action dispatcher — used by both the canvas sidebar panel and inline surfaces.
  // Adds a decorative canvas_action pill to the chat, then starts a real AG-UI stream so the
  // agent's reply appears with full streaming (tool calls, typewriter, etc.).
  const handleCanvasDispatch = useCallback(async (
    action: string,
    context: Record<string, unknown>,
    surfaceId: string,
  ) => {
    if (!currentChatId) return;

    // Deferred surface (ask_question): resolve the waiting tool call directly,
    // no new agent turn needed — the existing stream will continue.
    if (canvas.deferredIds.has(surfaceId)) {
      const answer = { ...context };
      delete answer.button_label;
      // If it was a button click, use the label as the answer value
      if (context.button_label) answer.answer = context.button_label;
      // Remove the form immediately — the tool is done, no need to keep it visible
      removeInlineSurface(surfaceId);
      try {
        await fetch(`${getApiBase()}/canvas/${currentChatId}/answer`, {
          method: 'POST',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify({ surface_id: surfaceId, answer }),
        });
      } catch (err) {
        console.error('[A2UI] answer dispatch failed:', err);
      }
      return;
    }

    // Regular canvas action: add a pill message and start a new agent stream.
    const buttonLabel = context.button_label as string | undefined;
    const ctxRest = { ...context };
    delete ctxRest.button_label;
    if (surfaceId && !('surface_id' in ctxRest)) {
      ctxRest.surface_id = surfaceId;
    }
    const labelStr = buttonLabel ? ` "${buttonLabel}"` : '';
    const contextStr = Object.keys(ctxRest).length > 0 ? ` ${JSON.stringify(ctxRest)}` : '';
    const messageContent = `[canvas: ${action}]${labelStr}${contextStr}`;

    addMessage(
      { role: 'canvas_action', content: messageContent, timestamp: new Date().toISOString() },
      currentChatId,
    );

    setIsStreaming(true, currentChatId);
    streamingChatIdRef.current = currentChatId;
    activeChatIdRef.current = currentChatId;
    stopInFlightRef.current = false;

    try {
      await sendAGUI({ message: messageContent, config: safeConfig, chat_id: currentChatId });
    } catch (err) {
      console.error('[A2UI] canvas dispatch failed:', err);
    }
  // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [currentChatId, canvas.deferredIds, addMessage, setIsStreaming, sendAGUI]);

  const handleForceWebContext = useCallback((contextId: string) => {
    setForcedWebContextId(contextId);
    if (!isRightSidebarOpen) {
      onRightSidebarToggle(true);
    }
  }, [isRightSidebarOpen, onRightSidebarToggle]);

  const configReady = !!(safeBackendConfig && safeConfig.model && safeConfig.agent);

  const handleOpenSubAgentSidebar = useCallback((taskId: string) => {
    setViewingSubAgentTaskId(taskId);
    onRightSidebarToggle(true);
  }, [onRightSidebarToggle]);

  const handleStopSubAgent = useCallback(async (taskId: string) => {
    try {
      await fetch(`${getApiBase()}/subagents/${taskId}/stop`, { method: 'POST' });
    } catch { /* ignore */ }
  }, []);


  // Auto-scroll
  const { scrollContainerRef, bottomRef, showScrollButton, scrollToBottom } = useAutoScroll(
    [safeMessages, isStreaming],
    { resetKey: `${currentChatId ?? 'new'}:${safeMessages.length > 0}` },
  );

  // When a tool transitions into a pending approval, force-scroll to the bottom
  // so the approval buttons are visible — even if the user had scrolled up.
  const prevHasPendingApprovalRef = useRef(false);
  useEffect(() => {
    if (hasPendingTransientApprovals && !prevHasPendingApprovalRef.current) {
      scrollToBottom();
    }
    prevHasPendingApprovalRef.current = hasPendingTransientApprovals;
  }, [hasPendingTransientApprovals, scrollToBottom]);

  const loadOlderVisibleMessages = useCallback(() => {
    const el = scrollContainerRef.current;
    if (!el || !hasHiddenOlderMessages) return;

    prependScrollSnapshotRef.current = {
      scrollHeight: el.scrollHeight,
      scrollTop: el.scrollTop,
    };
    setVisibleMessageCount(prev => Math.min(safeMessages.length, prev + LOAD_MORE_MESSAGES));
  }, [hasHiddenOlderMessages, safeMessages.length, scrollContainerRef]);

  useEffect(() => {
    const el = scrollContainerRef.current;
    if (!el || !hasHiddenOlderMessages) return;

    const maybeLoadOlder = () => {
      if (el.scrollTop <= LOAD_MORE_SCROLL_THRESHOLD_PX) {
        loadOlderVisibleMessages();
      }
    };

    el.addEventListener('scroll', maybeLoadOlder, { passive: true });

    const raf = requestAnimationFrame(maybeLoadOlder);
    return () => {
      el.removeEventListener('scroll', maybeLoadOlder);
      cancelAnimationFrame(raf);
    };
  }, [hasHiddenOlderMessages, loadOlderVisibleMessages, scrollContainerRef]);

  useLayoutEffect(() => {
    const snapshot = prependScrollSnapshotRef.current;
    const el = scrollContainerRef.current;
    if (!snapshot || !el) return;

    el.scrollTop = el.scrollHeight - snapshot.scrollHeight + snapshot.scrollTop;
    prependScrollSnapshotRef.current = null;
  }, [visibleMessages.length, scrollContainerRef]);

  // Background stream subscription: connect to /chat/live the moment the event bus
  // fires stream_started for this chat. Works for all chat types (heartbeat, cron,
  // social, and regular chats receiving a subagent wakeup).
  const currentChatSummary = chats.find(c => c.id === currentChatId);
  const isProbablyLoadingChatMessages =
    !!currentChatId && safeMessages.length === 0 && (currentChatSummary?.messageCount ?? 0) > 0;
  const isBackgroundChat = !!currentChatSummary?.platform || !!currentChatSummary?.heartbeatEnabled;
  // Keep useEventBus subscribed so the singleton EventSource stays open.
  useEventBus();

  useEffect(() => {
    if (!currentChatId) return;

    const chatIdAtMount = currentChatId;
    let cancelled = false;

    // Restore stream seed saved before a page refresh so we can either:
    // (a) seed an in-progress reconnect with prior visual state, or
    // (b) detect that the stream completed while we were gone and reload from DB.
    const savedSeed = _loadStreamSeed(chatIdAtMount);
    if (savedSeed) {
      _clearStreamSeed();
      if (savedSeed.parts.length > 0) {
        // Pre-populate the abandoned-parts map so tryConnect below can use it
        // as a visual seed — even if the snapshot hasn't arrived yet.
        abandonedPartsRef.current.set(chatIdAtMount, savedSeed.parts);
      }
      // Mark as abandoned so the existing reload-on-return logic triggers.
      abandonedStreamChatsRef.current.add(chatIdAtMount);

      // If there are pending tool approvals in the seed and no active stream,
      // re-hydrate the approval dialog (with its live approvalId) so the user can
      // still act on it. The backend keeps the run suspended; reconnect via
      // /chat/live returns 204 for a suspended run, so the seed is the only
      // carrier of the approvalId across a chat switch. The pending UI persists
      // until the user acts or a DB reload proves it resolved — no auto-clear.
      if (!isBusStreaming(chatIdAtMount)) {
        restorePendingApprovals(chatIdAtMount, savedSeed.parts);
      }
    }

    // A seed is only a local fast path. Always validate it against the
    // backend-owned approval contracts: a completed stream may have left a stale
    // sessionStorage snapshot even though its tool calls are already resolved.
    if (!isBusStreaming(chatIdAtMount)) {
      getChatPermissionState(chatIdAtMount)
        .then(state => {
          if (cancelled || isBusStreaming(chatIdAtMount)) return;
          if (state.pendingApprovals.length > 0) {
            restorePendingApprovals(
              chatIdAtMount,
              permissionApprovalsToParts(state.pendingApprovals),
            );
            return;
          }

          const hasRestoredApprovals = getStreamingParts().some(
            part => part.type === 'tool'
              && part.state === 'approval-requested'
              && !!part.approvalId,
          );
          if (
            hasRestoredApprovals
            && streamingChatIdRef.current === chatIdAtMount
          ) {
            setIsStreaming(false, chatIdAtMount);
            streamingChatIdRef.current = null;
            abandonedPartsRef.current.delete(chatIdAtMount);
            streamStartByChatRef.current.delete(chatIdAtMount);
            clearParts();
          }
        })
        .catch(() => {
          // Keep the local seed visible when authoritative validation fails.
        });
    }

    // On entry: reload from DB so any stream that completed while we were away
    // is visible. Covers platform/heartbeat chats and regular chats whose live
    // stream we abandoned on a previous navigation.
    if (isBackgroundChat || abandonedStreamChatsRef.current.has(chatIdAtMount)) {
      abandonedStreamChatsRef.current.delete(chatIdAtMount);
      // If the stream already finished while we were away, the DB has the full
      // message — drop any preserved reconnect state so it isn't seeded stale.
      if (!isBusStreaming(chatIdAtMount)) {
        abandonedPartsRef.current.delete(chatIdAtMount);
        streamStartByChatRef.current.delete(chatIdAtMount);
      }
      loadChat(chatIdAtMount, { force: true }).catch(() => {});
    }

    const tryConnect = async (): Promise<void> => {
      if (cancelled) return;
      // If a live stream, a connection attempt, or a user turn is already in
      // progress, mark pending so we retry immediately after it finishes.
      if (isLiveStreamRef.current || connectingRef.current || streamingChatIdRef.current === chatIdAtMount) {
        pendingConnectRef.current = true;
        return;
      }
      connectingRef.current = true;

      const liveUrl = `${getApiBase()}/chat/live`;

      // Seed with parts from a prior connection we abandoned on switch, so the
      // visible steps and in-flight tool states resume instead of resetting.
      const seedParts = abandonedPartsRef.current.get(chatIdAtMount);
      abandonedPartsRef.current.delete(chatIdAtMount);

      let streamed: boolean;
      try {
        streamed = await sendAGUI({ chat_id: chatIdAtMount, wait_ms: 8000 }, {
          urlOverride: liveUrl,
          seedParts,
          onStreamStart: () => {
            isLiveStreamRef.current = true;
            // Pin the streaming chat ID so onFinish uses the correct chat even if
            // the user navigates away mid-stream.
            streamingChatIdRef.current = chatIdAtMount;
            // Preserve the original start time across reconnects so the activity
            // timer continues; only set it if this is the first time we see it.
            if (!streamStartByChatRef.current.has(chatIdAtMount)) {
              streamStartByChatRef.current.set(chatIdAtMount, Date.now());
            }
            setIsStreaming(true, chatIdAtMount);
            loadChat(chatIdAtMount, { force: true }).catch(() => {});
          },
        });
      } finally {
        // Always release the connect guard, even if sendAGUI rejected — otherwise
        // connectingRef stays true forever and every future tryConnect (send,
        // retry, steer, resume, bus stream_started) becomes a silent no-op.
        connectingRef.current = false;
      }

      if (!streamed || cancelled) {
        // 204 (no active stream) or cancelled — clear pending so it doesn't loop.
        pendingConnectRef.current = false;
        return;
      }

      // If the stream paused for tool approval, keep the approval UI visible.
      // The event bus will fire stream_started again when the resume stream begins.
      const pendingApproval = liveStreamPartsRef.current.some(
        p => p.type === 'tool' && p.state === 'approval-requested'
      );
      if (pendingApproval) {
        isLiveStreamRef.current = false;
        liveStreamPartsRef.current = [];
        return;
      }

      const richMsg = aguiPartsToStoreMessage(liveStreamPartsRef.current, null);
      const isSocialStream = chatIdAtMount.startsWith('social-');
      setIsStreaming(false, chatIdAtMount);
      // Stream finished cleanly — drop preserved reconnect state for this chat.
      streamStartByChatRef.current.delete(chatIdAtMount);
      abandonedPartsRef.current.delete(chatIdAtMount);
      _clearStreamSeed();

      if (isSocialStream) {
        if (richMsg.content.trim()) addMessage(richMsg, chatIdAtMount);
        try { await loadChat(chatIdAtMount, { force: true }); } catch { /* ignore */ }
      }

      clearParts();
      liveStreamPartsRef.current = [];
      isLiveStreamRef.current = false;

      if (!isSocialStream && richMsg.content.trim()) {
        addMessage(richMsg, chatIdAtMount);
      }

      // A stream_started arrived while we were busy — connect to it now.
      if (pendingConnectRef.current) {
        pendingConnectRef.current = false;
        tryConnect();
      }
    };

    // If a stream is already active when this chat is opened, connect immediately.
    if (isBusStreaming(chatIdAtMount)) {
      tryConnect();
    }

    // Expose tryConnect so handleSend can call it directly after /chat/send 202,
    // without depending on stream_started arriving from the event bus.
    tryConnectRef.current = tryConnect;

    // Subscribe directly to stream_started — bypasses the React render cycle so
    // there is zero frame delay between the SSE event arriving and tryConnect() firing.
    const unsubEvents = subscribeToStreamEvents(chatIdAtMount, { onStart: tryConnect });

    return () => {
      cancelled = true;
      pendingConnectRef.current = false;
      connectingRef.current = false;
      tryConnectRef.current = null;
      unsubEvents();
      // Only stop the AG-UI connection if this effect started a background live stream.
      // Leave user-initiated /chat streams alone (they use a different code path).
      if (isLiveStreamRef.current && streamingChatIdRef.current === chatIdAtMount) {
        // Silent stop: discard transient parts without finalizing them. Otherwise
        // the abort-triggered onFinish would misroute this chat's parts into the
        // chat we just switched to. The backend keeps producing and persists the
        // result; we mark this chat for reload-on-return.
        // Snapshot the current parts first so reconnecting can seed them back
        // (preserving visible steps + in-flight tool states).
        const snapshot = getStreamingParts();
        if (snapshot.length > 0) {
          abandonedPartsRef.current.set(chatIdAtMount, snapshot);
        }
        stopAGUIStreamSilently();
        abandonedStreamChatsRef.current.add(chatIdAtMount);
        isLiveStreamRef.current = false;
        streamingChatIdRef.current = null;
        clearParts();
        // Clear streaming state so the next chat's send button is not blocked.
        // The backend continues processing; reconnecting via /chat/live picks it up.
        setIsStreaming(false, chatIdAtMount);
      }
    };
  // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [currentChatId, isBackgroundChat]);

  // Tear down restored approval UI once the reloaded DB state proves the
  // approval was resolved server-side (e.g. acted on in another window while we
  // were away). This replaces the old blind 5s self-destruct timer: instead of
  // guessing from the absence of a live stream, we clear only when the persisted
  // message actually shows the tool call resolved (has output / non-pending).
  useEffect(() => {
    if (!currentChatId) return;
    if (streamingChatIdRef.current !== currentChatId) return;

    const resolvedToolCallIds = new Set<string>();
    for (const m of messages) {
      if (m.role !== 'assistant' || !Array.isArray(m.parts)) continue;
      for (const p of m.parts) {
        if (
          p.type === 'tool' &&
          p.toolCallId &&
          (p.output != null || p.state !== 'approval-requested')
        ) {
          resolvedToolCallIds.add(p.toolCallId);
        }
      }
    }

    clearIfResolved(currentChatId, getStreamingParts(), resolvedToolCallIds);
  }, [currentChatId, messages, clearIfResolved, getStreamingParts, streamingChatIdRef]);

  // Auto-resize textarea
  useEffect(() => {
    const textarea = textareaRef.current;
    if (textarea) {
      textarea.style.height = '0px';
      textarea.style.height = `${Math.min(Math.max(textarea.scrollHeight, 40), 120)}px`;
    }
  }, [input, isRightSidebarOpen]);

  // Send message handler (also handles steering when streaming)
  const send = async () => {
    const prompt = input.trim();
    if (!prompt || !configReady || isUploading) return;

    // If currently streaming (or paused on pending approvals) for this chat,
    // steer instead of starting a brand-new turn.
    if ((streamingForCurrentChat || hasPendingTransientApprovals) && currentChatId) {
      const interruptedMessage = aguiPartsToStoreMessage(
        finalizeInterruptedParts(streamingParts),
        currentUsage,
        streamDisplayRoleRef.current,
      );
      if (interruptedMessage.content.trim()) {
        addMessage(interruptedMessage, currentChatId);
      }
      setInput('');
      addMessage({ role: 'user', content: prompt, timestamp: new Date().toISOString() }, currentChatId);
      setCurrentUsage(null);

      steeringRef.current = true;
      setIsStreaming(true, currentChatId);
      activeChatIdRef.current = currentChatId;
      stopInFlightRef.current = false;

      // Abort the current live connection silently, then start steer via the
      // background queue (/chat/steer-send → 202 → /chat/live) so it is
      // reconnectable after a page refresh.
      stopAGUIStreamSilently();
      clearParts();
      // Clear after abort so tryConnect isn't blocked when stream_started arrives.
      streamingChatIdRef.current = null;
      isLiveStreamRef.current = false;

      const steerChatId = currentChatId;
      fetch(`${getApiBase()}/chat/steer-send`, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ chat_id: steerChatId, message: prompt, config: safeConfig }),
      }).then(resp => {
        if (!resp.ok) {
          const msg = resp.status === 409 ? 'Steer failed — chat is busy' : `Steer failed (${resp.status})`;
          setStatusBar(msg, 'error', 4000);
          setIsStreaming(false, steerChatId);
          return;
        }
        tryConnectRef.current?.();
      }).catch(err => {
        console.error('[send] /chat/steer-send failed:', err);
        setIsStreaming(false, steerChatId);
      }).finally(() => {
        steeringRef.current = false;
        stopInFlightRef.current = false;
      });
      return;
    }

    // Block only if the frontend is currently watching this chat's live stream.
    // Other chats streaming in the background don't block a new send here.
    if (isStreaming) return;

    // Create chat if needed
    let chatIdForSend = currentChatId;
    if (!chatIdForSend) {
      chatIdForSend = await createNewChat();
      if (!chatIdForSend) {
        console.error('Unable to initialize chat before sending message.');
        return;
      }
    }

    // Block if a background stream is active AND the frontend also thinks this chat
    // is streaming. If the frontend already cleared (e.g. user just hit Stop), skip
    // this check — the backend will return 409 if it's truly still busy, which gives
    // a cleaner error than blocking here during the brief cancel-propagation window.
    if (isBusStreaming(chatIdForSend) && isStreaming && activeStreamingChatId === chatIdForSend) {
      setStatusBar('This chat is still responding — wait or use the redirect button', 'info', 4000);
      return;
    }

    const filesToSend = [...selectedFiles];

    // Upload all files to server + generate base64 for images
    let uploadedFileMetadata: FileAttachment[] | undefined;
    let imagePreviews;

    if (filesToSend.length > 0) {
      try {
        const { fileMetadata, imagePreviews: imagePreviewData } = await uploadFiles(filesToSend, chatIdForSend);
        uploadedFileMetadata = fileMetadata;
        imagePreviews = imagePreviewData.length > 0 ? imagePreviewData : undefined;
      } catch (error) {
        console.error('Error uploading files:', error);
        return;
      }
    }

    const resetFlag = shouldResetNext;
    if (resetFlag) consumeResetFlag();
    setInput('');
    clearFiles();
    setFileMentions([]);
    setCurrentUsage(null);

    addMessage({
      role: 'user',
      content: prompt,
      timestamp: new Date().toISOString(),
      images: uploadedFileMetadata ? undefined : imagePreviews,
      files: uploadedFileMetadata
    }, chatIdForSend);

    // Show loading indicator immediately before the network round-trip.
    clearParts();
    streamStartByChatRef.current.set(chatIdForSend, Date.now());
    setIsStreaming(true, chatIdForSend);
    const clearPartsIfStillViewingSendChat = () => {
      if (activeChatIdRef.current === chatIdForSend) {
        clearParts();
      }
    };

    const mentionsToSend = extractSelectedFileMentions(prompt, fileMentions);
    const payload: Record<string, unknown> = {
      message: prompt,
      config: safeConfig,
      chat_id: chatIdForSend,
      reset: resetFlag,
    };
    if (mentionsToSend.length > 0) payload.file_mentions = mentionsToSend;
    if (uploadedFileMetadata) payload.files = uploadedFileMetadata;

    // Fire /chat/send — backend registers the background stream and returns 202.
    // On success, call tryConnect() directly so we attach to /chat/live immediately
    // without waiting for stream_started from the event bus (avoids new-chat races
    // where the subscription for the new chat ID isn't set up yet).
    fetch(`${getApiBase()}/chat/send`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(payload),
    }).then(resp => {
      if (!resp.ok) {
        const msg = resp.status === 409 ? 'Chat is already responding' : `Send failed (${resp.status})`;
        setStatusBar(msg, 'error', 4000);
        setIsStreaming(false, chatIdForSend);
        clearPartsIfStillViewingSendChat();
        return;
      }
      // 202: stream registered — connect to /chat/live immediately.
      tryConnectRef.current?.();
    }).catch(err => {
      console.error('[send] /chat/send failed:', err);
      setIsStreaming(false, chatIdForSend);
      clearPartsIfStillViewingSendChat();
    });
  };

  // Retry handler — restores last checkpoint and re-runs the original message.
  // Uses the background queue (/chat/send) so the stream is reconnectable after
  // a page refresh, matching the behaviour of the main send path.
  const handleRetry = useCallback(() => {
    if (!currentChatId || isStreaming) return;

    // Strip all messages from the last user message onwards so the UI is clean
    // before the new assistant response streams in.
    const safeMessages = messages ?? [];
    let lastUserIdx = -1;
    for (let i = safeMessages.length - 1; i >= 0; i--) {
      if (safeMessages[i].role === 'user') {
        lastUserIdx = i;
        break;
      }
    }
    if (lastUserIdx >= 0) {
      truncateMessagesFrom(lastUserIdx + 1, currentChatId);
    }

    const chatIdForRetry = currentChatId;
    clearParts();
    streamStartByChatRef.current.set(chatIdForRetry, Date.now());
    setIsStreaming(true, chatIdForRetry);
    // Don't set streamingChatIdRef here — tryConnect sets it in onStreamStart,
    // same as the main send path. Setting it prematurely would block tryConnect.
    activeChatIdRef.current = chatIdForRetry;
    stopInFlightRef.current = false;

    fetch(`${getApiBase()}/chat/send`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ message: '/retry', chat_id: chatIdForRetry, config: safeConfig }),
    }).then(resp => {
      if (!resp.ok) {
        const msg = resp.status === 409 ? 'Chat is already responding' : `Retry failed (${resp.status})`;
        setStatusBar(msg, 'error', 4000);
        setIsStreaming(false, chatIdForRetry);
        return;
      }
      tryConnectRef.current?.();
    }).catch(err => {
      console.error('[handleRetry] /chat/send failed:', err);
      setIsStreaming(false, chatIdForRetry);
    }).finally(() => {
      stopInFlightRef.current = false;
    });
  }, [currentChatId, isStreaming, messages, truncateMessagesFrom, setIsStreaming, clearParts, safeConfig, setStatusBar]);

  // Stop streaming handler
  const stopStreaming = async () => {
    if (!isStreaming || stopInFlightRef.current) return;
    stopInFlightRef.current = true;

    const targetChatId = activeStreamingChatId || streamingChatIdRef.current;

    // Abort the AG-UI SSE connection. When the stream went through tryConnect
    // (isLiveStreamRef=true), onFinish deliberately skips setIsStreaming(false)
    // to let tryConnect's finally() handle cleanup. But for a user-triggered stop
    // we need to clear streaming state immediately, so we do it here instead.
    isLiveStreamRef.current = false;
    streamingChatIdRef.current = null;
    stopAGUIStream();
    setIsStreaming(false, targetChatId ?? undefined);
    clearParts();
    // Reload chat from DB so the partial response (saved by backend on cancel)
    // appears immediately — prevents the blank flash while waiting for DB commit.
    if (targetChatId) {
      setTimeout(() => { try { loadChat(targetChatId, { force: true }); } catch { } }, 500);
    }

    stopInFlightRef.current = false;

    if (!targetChatId) return;

    try {
      const res = await fetch(`${getApiBase()}/chat/stop`, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ chat_id: targetChatId, reason: 'User requested stop' })
      });
      if (!res.ok) {
        console.error('Stop request failed:', res.status, res.statusText);
      }
    } catch (error) {
      console.error('Error sending stop request:', error);
    }
  };

  // Handle file click from chat messages
  const handleFileClick = useCallback(async (path: string, name: string, shiftKey?: boolean) => {
    // Let the click animation finish first
    await new Promise(resolve => setTimeout(resolve, 150));

    // Check if file exists before opening (silently fail if not)
    try {
      // Use helper to include volumes in existence check
      const queryParams = getSandboxParams(currentChatId || '', path, config.sandbox_volumes);
      const response = await fetch(`${getApiBase()}/sandbox/serve?${queryParams}`, {
        method: 'HEAD'
      });

      if (!response.ok) {
        // File doesn't exist - do nothing (animation already played)
        return;
      }

      if (shiftKey) {
        // Shift+Click: Open full-screen modal directly
        setViewingFile({ path, name });
      } else {
        // Normal click: Open in right sidebar
        setSidebarFilePreview({ path, name });
        if (!isRightSidebarOpen) {
          onRightSidebarToggle(true);
        }
      }
    } catch (error) {
      // Error checking file - do nothing (animation already played)
    }
  }, [currentChatId, config.sandbox_volumes, isRightSidebarOpen, onRightSidebarToggle]);

  const handleInlineAction = useCallback((surfaceId: string, action: string, context: Record<string, unknown>) => {
    handleCanvasDispatch(action, context, surfaceId);
  }, [handleCanvasDispatch]);

  // Handle maximize button from sidebar
  const handleMaximizeFile = (path: string, name: string) => {
    setViewingFile({ path, name });
  };

  return (
    <div
      className="flex flex-row flex-1 h-full min-h-0 overflow-x-hidden bg-neutral-50 dark:bg-zinc-900 relative"
      onDragEnter={handleDragEnter}
      onDragLeave={handleDragLeave}
      onDragOver={handleDragOver}
      onDrop={handleDrop}
    >
      {isDragging && <DragOverlay />}

      {/* Main Chat Column */}
      <div className="flex flex-col flex-1 min-w-0 min-h-0 h-full relative">
        {/* Project board full-screen overlay */}
        {isBoardFullscreen && (
          <div className="absolute inset-0 z-20 bg-neutral-50 dark:bg-zinc-900 overflow-hidden">
            <ProjectKanbanView
              projectName={chats.find(c => c.id === currentChatId)?.projectName ?? null}
              projectId={chats.find(c => c.id === currentChatId)?.projectId ?? null}
              kanban={kanban}
              chatTitles={Object.fromEntries(chats.map(c => [c.id, c.title]))}
              onClose={() => setIsBoardFullscreen(false)}
            />
          </div>
        )}
        <div className="relative flex-1 min-h-0">
          <div
            ref={scrollContainerRef}
            className={safeMessages.length === 0
              ? "h-full overflow-hidden p-4 md:p-6 pb-2 bg-neutral-50 dark:bg-zinc-900"
              : "h-full overflow-y-auto overflow-x-hidden px-4 md:px-6 pt-3 pb-2 scrollbar-thin bg-neutral-50 dark:bg-zinc-900"
            }
          >
            {isProbablyLoadingChatMessages ? (
              <LoadingIndicator />
            ) : safeMessages.length === 0 && !showTransientAssistant ? (
              <NewChatView
                input={input}
                setInput={setInput}
                selectedFiles={selectedFiles}
                handleFileSelect={handleFileSelect}
                removeFile={removeFile}
                uploadProgress={uploadProgress}
                isUploading={isUploading}
                fileError={fileError}
                send={send}
                config={safeConfig}
                setConfig={setConfig}
                backendConfig={safeBackendConfig}
                fileInputRef={fileInputRef}
                textareaRef={textareaRef}
                configReady={configReady}
                streamingForCurrentChat={streamingForCurrentChat}
                onPaste={handlePaste}
                onImageClick={setViewingImage}
                currentChatId={currentChatId}
                onFileMentionSelected={(mention) => setFileMentions(prev => [...prev.filter(item => item.name !== mention.name), mention])}
              />
            ) : (
              <>
                {safeMessages.length > 0 && (
                  <MessageListMemo
                    messages={visibleMessages}
                    streamingForCurrentChat={false}
                    messageIndexOffset={visibleMessageStartIndex}
                    chatId={currentChatId ?? undefined}
                    onImageClick={setViewingImage}
                    onFileClick={handleFileClick}
                    onToolApproval={handleToolApproval}
                    toolApprovalPolicy={safeConfig.tool_approval_policy}
                    onRemoveApprovalPolicy={handleRemoveApprovalPolicy}
                    onInlineAction={handleInlineAction}
                    subAgentTasks={subAgentTasks}
                    onOpenSubAgentSidebar={handleOpenSubAgentSidebar}
                    onStopSubAgent={handleStopSubAgent}
                    onForceWebContext={handleForceWebContext}
                    onRetry={!isStreaming ? handleRetry : undefined}
                  />
                )}
                {compactNotice && (
                  <div className="space-y-6 mt-6">
                    <div className="w-full flex flex-col group/message">
                      <div className="flex justify-start w-full">
                        <NoticeMessage message={{ role: 'notice', content: compactNotice }} />
                      </div>
                    </div>
                  </div>
                )}
                {/* Streaming/transient message from AG-UI */}
                {showTransientAssistant && (
                  <div className="space-y-6 mt-6">
                    <div className="w-full flex flex-col group/message">
                      <div className="flex justify-start w-full">
                        {streamDisplayRole === 'notice' ? (
                          <NoticeMessage message={aguiPartsToStoreMessage(streamingParts, currentUsage, 'notice')} />
                        ) : (
                          <AssistantMessage
                            message={{ role: 'assistant', content: '' }}
                            previousMessageTimestamp={getLastMessageTimestamp(safeMessages)}
                            messageIndex={safeMessages.length}
                            isStreaming={streamingForCurrentChat}
                            isLastMessage={true}
                            onFileClick={handleFileClick}
                            aguiParts={streamingParts}
                            streamStartedAtMs={currentChatId ? streamStartByChatRef.current.get(currentChatId) : undefined}
                            usage={currentUsage}
                            toolApprovalPolicy={safeConfig.tool_approval_policy}
                            onRemoveApprovalPolicy={handleRemoveApprovalPolicy}
                            onInlineAction={handleInlineAction}
                            subAgentTasks={subAgentTasks}
                            onOpenSubAgentSidebar={handleOpenSubAgentSidebar}
                            onStopSubAgent={handleStopSubAgent}
                            onForceWebContext={handleForceWebContext}
                          />
                        )}
                      </div>
                    </div>
                  </div>
                )}
              </>
            )}

            {!configReady && <LoadingIndicator />}
            <div ref={bottomRef} className="h-0" />
          </div>

          {showScrollButton && <ScrollToBottomButton onClick={scrollToBottom} />}
        </div>

        {/* Input Panel (shown when messages exist) */}
        {safeMessages.length > 0 && (
          <div className="px-2 pt-2 pb-1 flex flex-col gap-2 bg-neutral-50 dark:bg-zinc-900 relative z-10 shrink-0">
            {hasPendingTransientApprovals && (
              <PermissionApprovalDock
                parts={streamingParts}
                onDecision={handleToolApproval}
              />
            )}
            <ChatInputPanel
              input={input}
              setInput={setInput}
              selectedFiles={selectedFiles}
              handleFileSelect={handleFileSelect}
              removeFile={removeFile}
              uploadProgress={uploadProgress}
              isUploading={isUploading}
              fileError={fileError}
              send={send}
              config={safeConfig}
              setConfig={setConfig}
              backendConfig={safeBackendConfig}
              fileInputRef={fileInputRef}
              textareaRef={textareaRef}
              configReady={configReady}
              streamingForCurrentChat={streamingForCurrentChat}
              stopStreaming={stopStreaming}
              stopInFlight={stopInFlightRef.current}
              modelSelectDropUp={true}
              hideConfigSelector={_isPlatformChat}
              onPaste={handlePaste}
              onImageClick={setViewingImage}
              currentChatId={currentChatId}
              onFileMentionSelected={(mention) => setFileMentions(prev => [...prev.filter(item => item.name !== mention.name), mention])}
            />
          </div>
        )}
      </div>

      {/* Right Sidebar */}
      <RightSidebar
        isOpen={isRightSidebarOpen}
        onClose={() => onRightSidebarToggle(false)}
        onOpen={() => onRightSidebarToggle(true)}
        onWidthChange={onRightSidebarWidthChange}
        maxWidthPx={rightSidebarMaxWidthPx}
        viewportWidthPx={viewportWidthPx}
        forceFullView={rightSidebarForceFullView}
        goal={goal}
        tasks={tasks}
        kanban={kanban}
        currentProjectName={chats.find(c => c.id === currentChatId)?.projectName ?? null}
        currentProjectId={chats.find(c => c.id === currentChatId)?.projectId ?? null}
        chatTitles={Object.fromEntries(chats.map(c => [c.id, c.title]))}
        onProjectBoardChange={setIsBoardFullscreen}
        isNewChat={safeMessages.length === 0 && !showTransientAssistant && !isProbablyLoadingChatMessages}
        fileToPreview={sidebarFilePreview}
        onMaximizeFile={handleMaximizeFile}
        canvas={canvas}
        onCanvasDispatch={handleCanvasDispatch}
        viewingSubAgentTaskId={viewingSubAgentTaskId}
        onCloseSubAgent={() => setViewingSubAgentTaskId(null)}
        onSelectSubAgent={(taskId) => setViewingSubAgentTaskId(taskId)}
        currentChatId={currentChatId}
        hasSubAgents={
          Object.keys(subAgentTasks).length > 0 ||
          currentChatSummary?.platform === 'subagent' ||
          safeConfig?.platform === 'subagent'
        }
        messages={safeMessages}
        forcedWebContextId={forcedWebContextId}
        onClearForcedWebContext={() => setForcedWebContextId(null)}
      />

      <ImageViewer
        src={viewingImage}
        onClose={() => setViewingImage(null)}
      />

      <FileViewer
        filePath={viewingFile?.path ?? null}
        fileName={viewingFile?.name ?? null}
        chatId={currentChatId}
        onClose={() => setViewingFile(null)}
      />
    </div>
  );
};
