/**
 * ChatStateProvider — React Context Provider that holds all chat state,
 * processes WS events, and survives route navigation.
 *
 * Lifted from useAgentRuntime.ts. The WS event handler stays registered
 * regardless of which page is active, buffering stream events.
 */
import React, {
  createContext,
  useCallback,
  useContext,
  useEffect,
  useRef,
  useState,
} from 'react';
import client from '../api/client';
import { gatewayAPI } from '../api/endpoints';
import { authAPI } from '../api/auth';
import { useAuthStore } from './AuthContext';
import { isOwner } from '../utils/authz';
import {
  extractThinkingChunk,
  isControlOnlyAssistantContent,
  mergeAssistantStream,
  mergeThinkingStream,
  sanitizeAssistantContent,
  sanitizeAssistantChunk,
  stripOpenClawReplyTags,
} from '../utils/chatStream';
import {
  OpenClawGatewayClient,
  createGatewayDirectUrl,
  type GatewayEvent,
  type GatewayChatMessage,
} from '../utils/openclawGatewayClient';
import { canonicalizePortalModelId } from '../utils/modelId';
import { usePublicSettings } from '../hooks/usePublicSettings';
import {
  pruneExpiredExecApprovals,
  removeExecApproval,
  upsertExecApproval,
} from '../utils/execApprovalQueue';
import { getToolStatusText, resolveToolName, isCompactionNotice } from '../utils/toolPresentation';
import { resolveMaintenanceRailStatus } from '../components/chat/maintenanceRailLifecycle';

const DEBUG_CHAT_STATE = import.meta.env.DEV;
const BUILD_TIME_USE_DIRECT_GATEWAY = import.meta.env.VITE_USE_DIRECT_GATEWAY === 'true';
const debugLog = (...args: unknown[]) => {
  if (DEBUG_CHAT_STATE) console.debug('[ChatState]', ...args);
};

/* ═══ Types ═══ */

export interface ToolCall {
  id: string;
  name: string;
  startedAt: number;
  endedAt?: number;
  result?: string;
  status: 'running' | 'done' | 'error';
  arguments?: any;
}

export interface ExecApprovalRequest {
  id: string;
  request: {
    command: string;
    cwd?: string;
    host?: string;
    security?: string;
    ask?: string;
    agentId?: string;
    sessionKey?: string;
    resolvedPath?: string;
  };
  createdAtMs: number;
  expiresAtMs: number;
}

export interface TextSegment {
  text: string;
  position: 'before' | 'after' | 'between';
  kind?: 'text' | 'thinking';
}

interface StreamSegment {
  text: string;
  ts: number;
  kind: 'text' | 'thinking';
}

export interface ChatMessage {
  id: string;
  role: 'user' | 'assistant' | 'system' | 'toolResult';
  content: string;
  createdAt: Date;
  queued?: boolean;
  pendingAck?: boolean;
  provenance?: string;
  model?: string;
  toolCalls?: ToolCall[];
  thinkingContent?: string;
  toolCallId?: string;
  toolName?: string;
  /** Text segments with their position relative to tool calls (for history reconstruction) */
  segments?: TextSegment[];
}

export interface MessageQueueItem {
  id: string;
  text: string;
  createdAt: number;
}

export type StreamingPhase = 'idle' | 'thinking' | 'tool' | 'streaming';

/* ═══ WS Manager (singleton — identical to original) ═══ */

type WsEventHandler = (data: any) => void;

export interface WsManager {
  ws: WebSocket | null;
  send: (data: any) => boolean;
  addHandler: (handler: WsEventHandler) => void;
  removeHandler: (handler: WsEventHandler) => void;
  onDisconnect: (cb: () => void) => (() => void);
  onReconnect: (cb: () => void) => (() => void);
  isConnected: () => boolean;
  reconnect: () => void;
  close: () => void;
}

function createWsManager(url: string): WsManager {
  let ws: WebSocket | null = null;
  let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
  let reconnectAttempts = 0;
  let intentionallyClosed = false;
  let wasConnectedBefore = false;
  const handlers = new Set<WsEventHandler>();
  const disconnectCallbacks = new Set<() => void>();
  const reconnectCallbacks = new Set<() => void>();

  function connect() {
    if (intentionallyClosed) return;
    if (ws && (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING)) return;

    try {
      ws = new WebSocket(url);
    } catch {
      scheduleReconnect();
      return;
    }

    ws.onopen = () => {
      debugLog('[ws-manager] Connected');
      const isReconnect = wasConnectedBefore;
      wasConnectedBefore = true;
      reconnectAttempts = 0;
      if (isReconnect) {
        for (const cb of reconnectCallbacks) {
          try { cb(); } catch (e) { console.error('[ws-manager] reconnect callback error:', e); }
        }
      }
    };

    ws.onmessage = (event) => {
      let data: any;
      try { data = JSON.parse(event.data); } catch { return; }
      for (const handler of handlers) {
        try { handler(data); } catch (err) { console.error('[ws-manager] Handler error:', err); }
      }
    };

    ws.onclose = (event) => {
      debugLog('[ws-manager] Closed: code=' + event.code + ' reason=' + event.reason + ' intentionallyClosed=' + intentionallyClosed);
      ws = null;

      // Check for auth failure close codes (4001 = unauthorized, or HTTP-style 401/403 in reason)
      // These indicate the token may be expired — try refreshing before giving up
      const isAuthFailure = event.code === 4001 || event.code === 4003 ||
        event.reason?.toLowerCase().includes('unauthorized') ||
        event.reason?.toLowerCase().includes('forbidden') ||
        event.reason?.toLowerCase().includes('expired');

      if (isAuthFailure && !intentionallyClosed) {
        debugLog('[ws-manager] Auth failure detected, attempting token refresh before reconnect');
        // Attempt to refresh the token before reconnecting
        authAPI.refresh()
          .then(() => {
            debugLog('[ws-manager] Token refresh succeeded, scheduling reconnect');
            reconnectAttempts = 0; // Reset backoff after successful refresh
            scheduleReconnect();
          })
          .catch((err) => {
            console.warn('[ws-manager] Token refresh failed, stopping reconnect:', err);
            intentionallyClosed = true; // Give up — user will need to re-login
            for (const cb of disconnectCallbacks) {
              try { cb(); } catch (e) { console.error('[ws-manager] disconnect callback error:', e); }
            }
          });
        return;
      }

      if (!intentionallyClosed) {
        for (const cb of disconnectCallbacks) {
          try { cb(); } catch (e) { console.error('[ws-manager] disconnect callback error:', e); }
        }
        scheduleReconnect();
      }
    };

    ws.onerror = () => {};
  }

  function scheduleReconnect() {
    if (reconnectTimer || intentionallyClosed) return;
    const delay = Math.min(1000 * Math.pow(2, reconnectAttempts), 30000);
    reconnectAttempts++;
    debugLog('[ws-manager] Reconnecting in ' + delay + 'ms (attempt ' + reconnectAttempts + ')');
    reconnectTimer = setTimeout(() => {
      reconnectTimer = null;
      connect();
    }, delay);
  }

  connect();

  return {
    get ws() { return ws; },
    send(data: any): boolean {
      if (!ws || ws.readyState !== WebSocket.OPEN) return false;
      try { ws.send(JSON.stringify(data)); return true; } catch { return false; }
    },
    addHandler(handler: WsEventHandler) { handlers.add(handler); },
    removeHandler(handler: WsEventHandler) { handlers.delete(handler); },
    onDisconnect(cb: () => void) {
      disconnectCallbacks.add(cb);
      return () => { disconnectCallbacks.delete(cb); };
    },
    onReconnect(cb: () => void) {
      reconnectCallbacks.add(cb);
      return () => { reconnectCallbacks.delete(cb); };
    },
    isConnected() { return ws !== null && ws.readyState === WebSocket.OPEN; },
    reconnect() {
      intentionallyClosed = false;
      if (reconnectTimer) { clearTimeout(reconnectTimer); reconnectTimer = null; }
      if (ws) {
        try { ws.close(); } catch {}
        ws = null;
      }
      connect();
    },
    close() {
      intentionallyClosed = true;
      if (reconnectTimer) { clearTimeout(reconnectTimer); reconnectTimer = null; }
      if (ws) { try { ws.close(); } catch {} ws = null; }
      handlers.clear();
      disconnectCallbacks.clear();
      reconnectCallbacks.clear();
    },
  };
}

// Singleton WS manager — shared across the app lifetime
let sharedWsManager: WsManager | null = null;
let wsManagerRefCount = 0;

function getWsManager(): WsManager {
  if (!sharedWsManager) {
    const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
    const apiUrl = import.meta.env.VITE_API_URL || '';
    let wsUrl: string;
    if (apiUrl) {
      if (apiUrl.startsWith('http')) {
        wsUrl = apiUrl.replace(/^http/, 'ws') + '/gateway/ws';
      } else {
        wsUrl = protocol + '//' + window.location.host + apiUrl + '/gateway/ws';
      }
    } else {
      wsUrl = protocol + '//' + window.location.host + '/api/gateway/ws';
    }
    sharedWsManager = createWsManager(wsUrl);
  }
  wsManagerRefCount++;
  return sharedWsManager;
}

function releaseWsManager() {
  wsManagerRefCount--;
  if (wsManagerRefCount <= 0 && sharedWsManager) {
    sharedWsManager.close();
    sharedWsManager = null;
    wsManagerRefCount = 0;
  }
}

/* ═══ Helpers ═══ */

let msgCounter = 0;
function nextId() {
  return 'msg-' + Date.now() + '-' + (++msgCounter);
}

const HISTORY_ENVELOPE_TIMESTAMP_RE = /\[(?:Mon|Tue|Wed|Thu|Fri|Sat|Sun)\s+\d{4}-\d{2}-\d{2}\s+\d{1,2}:\d{2}\s+[A-Z]{2,4}\]\s*/;

function stripHistoryEnvelope(text: string): string {
  if (!text) return text;
  const match = text.match(HISTORY_ENVELOPE_TIMESTAMP_RE);
  if (match && match.index !== undefined) {
    const beforeTimestamp = text.substring(0, match.index);
    if (
      beforeTimestamp.includes('Conversation info (untrusted metadata)')
      || beforeTimestamp.includes('Sender (untrusted metadata)')
    ) {
      return text.substring(match.index + match[0].length).trim();
    }
  }
  return text;
}

function sanitizeHistoryMessageText(text: string): string {
  return stripOpenClawReplyTags(stripHistoryEnvelope(text || ''))
    .replace(/\r\n/g, '\n')
    .trim();
}

function isHiddenHistoryArtifactText(text: string): boolean {
  const normalized = String(text || '').trim();
  if (!normalized) return false;

  return [
    /^System \(untrusted\):/i,
    /^An async command you ran earlier has completed\./i,
    /^Read HEARTBEAT\.md if it exists/i,
    /^HEARTBEAT_OK$/i,
    /^Heartbeat check complete(?:d)?\.?$/i,
    /^Pre-compaction memory flush\./i,
    /^Memory flush complete(?:d)?\.?$/i,
    /<<<BEGIN_OPENCLAW_INTERNAL_CONTEXT>>>/i,
    /Handle the result internally\./i,
    /Sender \(untrusted metadata\):/i,
    /Conversation info \(untrusted metadata\):/i,
  ].some((pattern) => pattern.test(normalized));
}

function summarizeHiddenHistoryArtifactText(text: string): string | null {
  const normalized = String(text || '').trim();
  if (!normalized) return null;

  if (/<<<BEGIN_OPENCLAW_INTERNAL_CONTEXT>>>/i.test(normalized) && /\[Internal task completion event\]/i.test(normalized)) {
    const sourceMatch = normalized.match(/^source:\s*(.+)$/im);
    const source = sourceMatch?.[1]?.trim().toLowerCase() || '';
    if (source === 'subagent') return 'Delegated task completed';
    if (source) return 'Background task completed';
    return 'Background work completed';
  }

  if (/^An async command you ran earlier has completed\./i.test(normalized)) {
    return 'Earlier async command completed';
  }

  if (/^Read HEARTBEAT\.md if it exists/i.test(normalized)) {
    return 'Heartbeat check started';
  }

  if (/^HEARTBEAT_OK$/i.test(normalized) || /^Heartbeat check complete(?:d)?\.?$/i.test(normalized)) {
    return 'Heartbeat check completed';
  }

  if (/^Pre-compaction memory flush\./i.test(normalized)) {
    return 'Memory flush started';
  }

  if (/^Memory flush complete(?:d)?\.?$/i.test(normalized)) {
    return 'Memory flush completed';
  }

  return null;
}

const MODEL_STORAGE_PREFIX = 'agentChats.lastModel.';
const CHAT_HISTORY_OMITTED_PLACEHOLDER = '[chat.history omitted: message too large]';

function normalizeProviderModel(provider: string, rawModel: string): string {
  const model = String(rawModel || '').trim();
  if (!model) return '';
  if (provider === 'OPENCLAW' || provider === 'OLLAMA' || provider === 'GEMINI') {
    return canonicalizePortalModelId(model);
  }

  const lower = model.toLowerCase();
  if (provider === 'CLAUDE_CODE' && (lower.startsWith('anthropic/') || lower.startsWith('claude/'))) {
    return model.split('/').slice(1).join('/') || model;
  }
  if (provider === 'CODEX' && (lower.startsWith('openai-codex/') || lower.startsWith('openai/'))) {
    return model.split('/').slice(1).join('/') || model;
  }
  return model;
}

function normalizeToolCalls(toolCalls: any, defaultStatus: ToolCall['status'] = 'done'): ToolCall[] | undefined {
  if (!Array.isArray(toolCalls) || toolCalls.length === 0) return undefined;

  return toolCalls
    .filter((tc) => tc && typeof tc === 'object' && typeof tc.name === 'string' && tc.name.trim())
    .map((tc) => {
      const startedAt = typeof tc.startedAt === 'number' && Number.isFinite(tc.startedAt)
        ? tc.startedAt
        : Date.now();
      const endedAt = typeof tc.endedAt === 'number' && Number.isFinite(tc.endedAt)
        ? tc.endedAt
        : (tc.status === 'running' ? undefined : Date.now());
      return {
        id: typeof tc.id === 'string' && tc.id.trim() ? tc.id : nextId(),
        name: tc.name,
        arguments: tc.arguments,
        startedAt,
        endedAt,
        result: typeof tc.result === 'string' ? tc.result : undefined,
        status: tc.status === 'running' || tc.status === 'error' || tc.status === 'done'
          ? tc.status
          : defaultStatus,
      };
    });
}

function defaultCompactionNoticeText(meta?: Record<string, any> | null): string {
  const signal = String(meta?.phase || meta?.status || '').trim().toLowerCase();
  if (signal === 'start' || signal === 'started' || signal === 'compacting' || signal === 'compaction_start') {
    return 'Compacting context…';
  }
  if (meta?.completed === false || signal === 'incomplete' || signal === 'did_not_complete') {
    return 'Context maintenance finished.';
  }
  return 'Context compacted';
}

function resolveCompactionNoticeText(text: string, meta?: Record<string, any> | null): string {
  const normalized = sanitizeHistoryMessageText(text);
  return normalized || defaultCompactionNoticeText(meta);
}

function parseHistoryMessage(m: any): ChatMessage | null {
  if (m?.__openclaw?.kind === 'compaction') {
    return {
      id: m.id || `compaction-${m.__openclaw.id || Date.now()}`,
      role: 'system',
      content: resolveCompactionNoticeText(typeof m.content === 'string' ? m.content : '', m.__openclaw),
      createdAt: new Date(m.timestamp || Date.now()),
      provenance: 'compaction',
    };
  }

  const rawContent = typeof m.content === 'string' ? m.content : '';
  const sanitizedHistoryText = sanitizeHistoryMessageText(rawContent);
  const rawThinkingContent = typeof m.thinkingContent === 'string' ? sanitizeAssistantContent(m.thinkingContent) : '';
  const isTruncationPlaceholder = m.role === 'assistant' && rawContent === CHAT_HISTORY_OMITTED_PLACEHOLDER;
  if (!isTruncationPlaceholder && isAssistantMaintenanceNoticeMessage({ ...m, content: sanitizedHistoryText, thinkingContent: rawThinkingContent })) {
    return null;
  }
  if (m.role === 'assistant' && !isTruncationPlaceholder && isControlOrMaintenanceAssistantContent(rawContent) && !rawThinkingContent && !(Array.isArray(m.toolCalls) && m.toolCalls.length > 0)) {
    return null;
  }
  if (m.role === 'assistant' && !isTruncationPlaceholder && isHiddenHistoryArtifactText(sanitizedHistoryText) && !rawThinkingContent && !(Array.isArray(m.toolCalls) && m.toolCalls.length > 0)) {
    return null;
  }
  if ((m.role === 'user' || m.role === 'system') && isHiddenHistoryArtifactText(sanitizedHistoryText)) {
    const summary = summarizeHiddenHistoryArtifactText(sanitizedHistoryText);
    if (!summary) return null;
    return {
      id: m.id || nextId(),
      role: 'system',
      content: summary,
      createdAt: new Date(m.timestamp || Date.now()),
      provenance: 'hidden-history-artifact',
    };
  }

  const msg: ChatMessage = {
    id: m.id || nextId(),
    role: isTruncationPlaceholder ? 'system' : m.role,
    content: isTruncationPlaceholder
      ? 'Earlier assistant output was omitted from history because the message was too large.'
      : (m.role === 'assistant' ? sanitizeAssistantContent(rawContent) : sanitizedHistoryText),
    createdAt: new Date(m.timestamp || Date.now()),
    provenance: m.provenance || (m.__openclaw?.kind === 'compaction' ? 'compaction' : undefined),
    model: typeof m.model === 'string' ? m.model : undefined,
    thinkingContent: rawThinkingContent || undefined,
  };
  if (m.toolCalls) {
    msg.toolCalls = normalizeToolCalls(m.toolCalls, 'done');
  }
  // Preserve segments for graduated timeline reconstruction
  if (m.segments && Array.isArray(m.segments)) {
    msg.segments = m.segments;
  }
  if (m.role === 'toolResult') {
    msg.toolCallId = m.toolCallId;
    msg.toolName = m.toolName;
  }
  return msg;
}

function isGatewayTextBlock(block: any): boolean {
  return Boolean(
    block
    && typeof block === 'object'
    && (block.type === 'text' || block.type === 'input_text' || block.type === 'output_text')
    && typeof block.text === 'string'
  );
}

function gatewayTextFromBlocks(blocks: any[]): string {
  return blocks
    .filter(isGatewayTextBlock)
    .map((block) => block.text as string)
    .join('\n');
}

/**
 * Extract text content from a gateway message.
 * Gateway/WebChat history can contain OpenAI-style `input_text`/`output_text`
 * blocks as well as generic `text` blocks. Match Control UI by treating all
 * three as visible transcript text.
 */
function extractTextFromGatewayMessage(msg: GatewayChatMessage): string {
  const rawText = typeof msg.content === 'string'
    ? msg.content
    : Array.isArray(msg.content)
      ? gatewayTextFromBlocks(msg.content)
      : '';

  return sanitizeHistoryMessageText(rawText);
}

/**
 * Extract tool calls from a gateway message.
 */
function extractToolCallsFromGatewayMessage(msg: GatewayChatMessage): ToolCall[] | undefined {
  const blockCalls = Array.isArray(msg.content)
    ? msg.content
        .filter((block) => block.type === 'toolCall' && block.name)
        .map((block) => ({
          id: block.id || nextId(),
          name: block.name as string,
          arguments: block.arguments,
          startedAt: Date.now(),
          endedAt: Date.now(),
          status: 'done' as const,
        }))
    : [];

  const explicitCalls = Array.isArray(msg.toolCalls)
    ? msg.toolCalls.map((toolCall) => ({
        id: toolCall.id || nextId(),
        name: toolCall.name,
        arguments: toolCall.arguments,
        startedAt: Date.now(),
        endedAt: Date.now(),
        status: 'done' as const,
      }))
    : [];

  const mergedCalls = [...explicitCalls, ...blockCalls];
  if (mergedCalls.length === 0) return undefined;

  const deduped = mergedCalls.filter((toolCall, index, allCalls) => {
    const callId = typeof toolCall.id === 'string' ? toolCall.id.trim() : '';
    const key = callId || `${toolCall.name}:${index}`;
    return allCalls.findIndex((candidate, candidateIndex) => {
      const candidateId = typeof candidate.id === 'string' ? candidate.id.trim() : '';
      const candidateKey = candidateId || `${candidate.name}:${candidateIndex}`;
      return candidateKey === key;
    }) === index;
  });

  return normalizeToolCalls(deduped, 'done');
}

/**
 * Extract thinking content from a gateway message.
 */
function extractThinkingFromGatewayMessage(msg: GatewayChatMessage): string | undefined {
  if (!Array.isArray(msg.content)) return undefined;

  const thinking = sanitizeAssistantContent(msg.content
    .filter((block) => block.type === 'thinking' && (typeof block.thinking === 'string' || typeof block.text === 'string'))
    .map((block) => (typeof block.thinking === 'string' ? block.thinking : block.text) as string)
    .join('\n'));

  return thinking || undefined;
}

function normalizePortalNewSessionAlias(rawSession: string): string {
  const sessionKey = String(rawSession || '').trim();
  if (!sessionKey) return '';
  if (sessionKey.startsWith('portal-new-')) return sessionKey.replace(/^portal-/, '');
  if (!sessionKey.startsWith('agent:')) return sessionKey;

  const parts = sessionKey.split(':');
  if (parts.length < 3) return sessionKey;

  const agentId = parts[1]?.trim() || 'main';
  const sessionName = parts.slice(2).join(':').trim();
  if (!sessionName.startsWith('portal-new-')) return sessionKey;
  return `agent:${agentId}:${sessionName.replace(/^portal-/, '')}`;
}

function toConcreteOpenClawSessionKey(rawSession: string, rawAgentId?: string | null): string {
  const sessionKey = normalizePortalNewSessionAlias(String(rawSession || '').trim());
  if (!sessionKey) return 'agent:main:main';
  if (sessionKey.startsWith('agent:')) return sessionKey;

  const agentKey = String(rawAgentId || '').trim() || 'main';
  if (sessionKey === 'main') return `agent:${agentKey}:main`;
  if (sessionKey.startsWith('new-')) return `agent:${agentKey}:${sessionKey}`;
  return sessionKey;
}

function normalizeRunId(rawRunId: unknown): string | null {
  const runId = typeof rawRunId === 'string' ? rawRunId.trim() : '';
  return runId || null;
}

/**
 * Map a gateway message to our ChatMessage format.
 */
function mapGatewayMessage(msg: GatewayChatMessage): ChatMessage | null {
  if (msg.__openclaw?.kind === 'compaction') {
    return {
      id: msg.id || msg.messageId || `compaction-${msg.__openclaw.id || Date.now()}`,
      role: 'system',
      content: resolveCompactionNoticeText(extractTextFromGatewayMessage(msg), msg.__openclaw),
      createdAt: new Date(msg.timestamp || Date.now()),
      provenance: 'compaction',
    };
  }

  const text = extractTextFromGatewayMessage(msg);
  const toolCalls = extractToolCallsFromGatewayMessage(msg);
  const thinking = extractThinkingFromGatewayMessage(msg);
  const isTruncationPlaceholder = msg.role === 'assistant' && text === CHAT_HISTORY_OMITTED_PLACEHOLDER;
  if (!isTruncationPlaceholder && isAssistantMaintenanceNoticeMessage({ role: msg.role, content: text, thinkingContent: thinking, toolCalls })) {
    return null;
  }
  if (msg.role === 'assistant' && !isTruncationPlaceholder && isControlOrMaintenanceAssistantContent(text) && !thinking && !toolCalls?.length) {
    return null;
  }
  if (msg.role === 'assistant' && !isTruncationPlaceholder && isHiddenHistoryArtifactText(text) && !thinking && !toolCalls?.length) {
    return null;
  }
  if ((msg.role === 'user' || msg.role === 'system') && isHiddenHistoryArtifactText(text)) {
    const summary = summarizeHiddenHistoryArtifactText(text);
    if (!summary) return null;
    return {
      id: msg.id || msg.messageId || `gw-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
      role: 'system',
      content: summary,
      createdAt: new Date(msg.timestamp || Date.now()),
      provenance: 'hidden-history-artifact',
    };
  }
  if (!text && msg.role !== 'assistant' && msg.role !== 'toolResult') {
    return null;
  }

  return {
    id: msg.id || msg.messageId || `gw-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
    role: isTruncationPlaceholder ? 'system' : (msg.role as 'user' | 'assistant' | 'system' | 'toolResult'),
    content: isTruncationPlaceholder
      ? 'Earlier assistant output was omitted from history because the message was too large.'
      : (msg.role === 'assistant' ? sanitizeAssistantContent(text) : text),
    createdAt: new Date(msg.timestamp || Date.now()),
    provenance: typeof (msg as any).provenance === 'string' ? (msg as any).provenance : undefined,
    model: typeof (msg as any).model === 'string' ? (msg as any).model : undefined,
    toolCalls,
    thinkingContent: thinking,
  };
}

const HISTORY_REPLAY_DUPLICATE_WINDOW_MS = 5_000;
const LOCAL_PENDING_ACK_WINDOW_MS = 120_000;
const LOCAL_OPTIMISTIC_ASSISTANT_TAIL_WINDOW_MS = 30_000;

function normalizeHistoryReplayContent(content: string): string {
  return (content || '').replace(/\r\n/g, '\n').trim();
}

function isStandaloneMaintenanceNoticeContent(text: string): boolean {
  const normalized = sanitizeHistoryMessageText(text || '').replace(/\s+/g, ' ').trim();
  if (!normalized || !isCompactionNotice(normalized)) return false;

  const marker = normalized.replace(/^[^\p{L}\p{N}]+/u, '').trim();
  return [
    /^context compacted\.?$/i,
    /^context maintenance (?:in progress|finished|complete(?:d)?)\.?$/i,
    /^compacting context[.…]*$/i,
    /^preparing (?:context maintenance|compaction)[.…]*$/i,
    /^memory flush(?: started| complete(?:d)?| in progress)?[.…]*$/i,
    /^heartbeat check (?:started|complete(?:d)?)[.…]*$/i,
    /^compacted\s*\([^)]{1,80}\)(?:\s*[•-]\s*context\b.*)?$/i,
    /^compaction (?:complete(?:d)?|finished|in progress|started|incomplete|did not complete)\.?$/i,
    /^compaction skipped(?::.*)?$/i,
  ].some((pattern) => pattern.test(marker));
}

function isAssistantMaintenanceNoticeMessage(message: {
  role?: unknown;
  content?: unknown;
  thinkingContent?: unknown;
  toolCalls?: unknown;
}): boolean {
  if (message?.role !== 'assistant') return false;
  const content = typeof message.content === 'string' ? sanitizeHistoryMessageText(message.content) : '';
  if (!content || !isStandaloneMaintenanceNoticeContent(content)) return false;
  if (typeof message.thinkingContent === 'string' && message.thinkingContent.trim()) return false;
  if (Array.isArray(message.toolCalls) && message.toolCalls.length > 0) return false;
  return true;
}

function isControlOrMaintenanceAssistantContent(text: string): boolean {
  return isControlOnlyAssistantContent(text || '') || isStandaloneMaintenanceNoticeContent(text || '');
}

function isEquivalentCompactionNotice(previous: ChatMessage | undefined, next: ChatMessage): boolean {
  if (!previous || previous.role !== 'system' || next.role !== 'system') return false;
  if (!(previous.provenance === 'compaction' || next.provenance === 'compaction')) return false;
  if (!isCompactionNotice(previous.content) || !isCompactionNotice(next.content)) return false;

  const previousContent = normalizeHistoryReplayContent(previous.content);
  const nextContent = normalizeHistoryReplayContent(next.content);
  if (!previousContent || previousContent !== nextContent) return false;

  const previousTs = previous.createdAt instanceof Date ? previous.createdAt.getTime() : NaN;
  const nextTs = next.createdAt instanceof Date ? next.createdAt.getTime() : NaN;
  return Number.isFinite(previousTs) && Number.isFinite(nextTs) && Math.abs(nextTs - previousTs) <= 30_000;
}

function isLikelyHistoryReplayDuplicate(previous: ChatMessage | undefined, next: ChatMessage): boolean {
  if (!previous || previous.role !== next.role || next.role !== 'user') return false;

  const previousContent = normalizeHistoryReplayContent(previous.content);
  const nextContent = normalizeHistoryReplayContent(next.content);
  if (!previousContent || previousContent !== nextContent) return false;

  const previousTs = previous.createdAt instanceof Date ? previous.createdAt.getTime() : NaN;
  const nextTs = next.createdAt instanceof Date ? next.createdAt.getTime() : NaN;
  if (!Number.isFinite(previousTs) || !Number.isFinite(nextTs) || nextTs < previousTs) return false;

  return (nextTs - previousTs) <= HISTORY_REPLAY_DUPLICATE_WINDOW_MS;
}

function dedupeHistoryMessages(messages: ChatMessage[]): ChatMessage[] {
  const seenIds = new Set<string>();
  const seenSignatures = new Set<string>();
  const deduped: ChatMessage[] = [];
  for (const msg of messages) {
    if (msg.id && seenIds.has(msg.id)) continue;
    const previous = deduped[deduped.length - 1];
    if (isLikelyHistoryReplayDuplicate(previous, msg)) continue;
    if (isEquivalentCompactionNotice(previous, msg)) continue;
    const ts = msg.createdAt instanceof Date ? msg.createdAt.getTime() : Date.now();
    const signature = `${msg.role}|${Number.isFinite(ts) ? ts : 0}|${msg.content}`;
    if (msg.role === 'assistant' && seenSignatures.has(signature)) continue;
    if (msg.id) seenIds.add(msg.id);
    seenSignatures.add(signature);
    deduped.push(msg);
  }
  return deduped;
}

function isLikelyCommittedPendingUser(localMessage: ChatMessage, committedMessage: ChatMessage): boolean {
  if (localMessage.role !== 'user' || committedMessage.role !== 'user') return false;

  const localContent = normalizeHistoryReplayContent(localMessage.content);
  const committedContent = normalizeHistoryReplayContent(committedMessage.content);
  if (!localContent || localContent !== committedContent) return false;

  const localTs = localMessage.createdAt instanceof Date ? localMessage.createdAt.getTime() : NaN;
  const committedTs = committedMessage.createdAt instanceof Date ? committedMessage.createdAt.getTime() : NaN;
  if (!Number.isFinite(localTs) || !Number.isFinite(committedTs)) return false;

  const earliestExpectedCommitTs = localTs - 10_000;
  const latestExpectedCommitTs = localTs + LOCAL_PENDING_ACK_WINDOW_MS;
  return committedTs >= earliestExpectedCommitTs && committedTs <= latestExpectedCommitTs;
}

function mergeLoadedHistoryWithLocalMessages(
  loadedMessages: ChatMessage[],
  currentMessages: ChatMessage[],
  options?: {
    activeAssistantId?: string | null;
    preserveActiveAssistant?: boolean;
  },
): ChatMessage[] {
  if (!currentMessages.length) return loadedMessages;

  const activeAssistantId = typeof options?.activeAssistantId === 'string' && options.activeAssistantId.trim()
    ? options.activeAssistantId.trim()
    : null;
  const preserveActiveAssistant = Boolean(options?.preserveActiveAssistant && activeAssistantId);
  const merged = [...loadedMessages];
  const now = Date.now();
  const latestLoadedTs = loadedMessages.reduce((latest, message) => {
    const ts = message.createdAt instanceof Date ? message.createdAt.getTime() : NaN;
    return Number.isFinite(ts) ? Math.max(latest, ts) : latest;
  }, 0);

  const isRecentOptimisticAssistant = (message: ChatMessage): boolean => {
    if (message.role !== 'assistant') return false;
    if (preserveActiveAssistant && message.id === activeAssistantId) return true;
    const ts = message.createdAt instanceof Date ? message.createdAt.getTime() : NaN;
    if (!Number.isFinite(ts)) return false;
    if (now - ts > LOCAL_OPTIMISTIC_ASSISTANT_TAIL_WINDOW_MS) return false;
    if (latestLoadedTs && ts < latestLoadedTs - HISTORY_REPLAY_DUPLICATE_WINDOW_MS) return false;
    return Boolean(
      normalizeHistoryReplayContent(message.content)
      || message.thinkingContent
      || (Array.isArray(message.segments) && message.segments.length > 0)
      || (Array.isArray(message.toolCalls) && message.toolCalls.length > 0)
    );
  };

  const shouldKeepLocalMessage = (message: ChatMessage): boolean => {
    if (message.queued) return true;
    if (message.role === 'system' && message.provenance === 'live-steer') return true;
    if (message.role === 'user' && message.pendingAck) return true;
    if (preserveActiveAssistant && message.id === activeAssistantId) return true;
    if (isRecentOptimisticAssistant(message)) return true;
    return false;
  };

  const alreadyRepresented = (candidate: ChatMessage): boolean => {
    return merged.some((existing) => {
      if (existing.id && candidate.id && existing.id === candidate.id) return true;
      if (candidate.role === 'user' && candidate.pendingAck) {
        return isLikelyCommittedPendingUser(candidate, existing);
      }
      if (candidate.role === 'assistant' && existing.role === 'assistant') {
        const candidateContent = normalizeHistoryReplayContent(candidate.content);
        const existingContent = normalizeHistoryReplayContent(existing.content);
        const candidateTs = candidate.createdAt instanceof Date ? candidate.createdAt.getTime() : NaN;
        const existingTs = existing.createdAt instanceof Date ? existing.createdAt.getTime() : NaN;
        if (candidateContent && candidateContent === existingContent) return true;
        if (
          candidateContent
          && existingContent
          && Number.isFinite(candidateTs)
          && Number.isFinite(existingTs)
          && Math.abs(candidateTs - existingTs) <= LOCAL_PENDING_ACK_WINDOW_MS
          && (candidateContent.includes(existingContent) || existingContent.includes(candidateContent))
        ) return true;
      }
      if (isEquivalentCompactionNotice(existing, candidate)) return true;
      return false;
    });
  };

  const localTail = currentMessages
    .filter(shouldKeepLocalMessage)
    .sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime());

  for (const localMessage of localTail) {
    if (alreadyRepresented(localMessage)) continue;

    const delegatedArtifactIndex = localMessage.role === 'user' && localMessage.pendingAck
      ? merged.findIndex((candidate) => {
          if (candidate.role !== 'system' || candidate.provenance !== 'hidden-history-artifact') return false;
          return candidate.createdAt.getTime() >= localMessage.createdAt.getTime();
        })
      : -1;

    if (delegatedArtifactIndex >= 0) {
      merged.splice(delegatedArtifactIndex, 0, {
        ...localMessage,
        pendingAck: false,
      });
      continue;
    }

    merged.push(localMessage);
  }

  return dedupeHistoryMessages(merged);
}

/**
 * Merge toolResult messages back into the preceding assistant message's toolCalls.
 * The JSONL stores assistant messages (with toolCall blocks) and separate toolResult
 * messages. During live streaming, tool_end events set .result on the call directly.
 * For history, we need to do this post-hoc so the tool pills have their results.
 * Also removes the standalone toolResult messages since they render as null.
 *
 * Fix: More resilient matching — if toolCallId doesn't match, try by toolName,
 * then fall back to positional matching (the most recent running/unresolved tool call).
 */
function mergeToolResultsIntoToolCalls(messages: ChatMessage[]): ChatMessage[] {
  const result: ChatMessage[] = [];
  let lastAssistant: ChatMessage | null = null;
  let lastAssistantIdx = -1;

  for (const msg of messages) {
    if (msg.role === 'assistant') {
      lastAssistant = msg;
      lastAssistantIdx = result.length;
      result.push(msg);
    } else if (msg.role === 'toolResult' && lastAssistant && lastAssistant.toolCalls) {
      // Try to match by toolCallId first, then by toolName, then by position
      const calls = [...lastAssistant.toolCalls];
      let matched = false;
      
      // 1. Try exact toolCallId match
      if (msg.toolCallId) {
        for (let i = 0; i < calls.length; i++) {
          if (calls[i].id === msg.toolCallId) {
            calls[i] = { ...calls[i], result: msg.content, status: 'done' as const };
            matched = true;
            break;
          }
        }
      }
      
      // 2. Try toolName match on an unresolved call
      if (!matched && msg.toolName) {
        for (let i = 0; i < calls.length; i++) {
          if (calls[i].name === msg.toolName && !calls[i].result) {
            calls[i] = { ...calls[i], result: msg.content, status: 'done' as const };
            matched = true;
            break;
          }
        }
      }
      
      // 3. Fallback: positional match — assign to the first unresolved tool call
      if (!matched) {
        for (let i = 0; i < calls.length; i++) {
          if (!calls[i].result && (calls[i].status === 'running' || calls[i].status === 'done')) {
            calls[i] = { ...calls[i], result: msg.content, status: 'done' as const };
            matched = true;
            break;
          }
        }
      }
      
      // Update the assistant message in the result array
      if (matched && lastAssistantIdx >= 0) {
        result[lastAssistantIdx] = { ...lastAssistant, toolCalls: calls };
        // Update lastAssistant reference so subsequent toolResults use the updated calls
        lastAssistant = result[lastAssistantIdx];
      }
      // Don't add toolResult to result — it renders as null anyway
    } else {
      result.push(msg);
    }
  }
  return result;
}

/* ═══ Context shape ═══ */

export interface ChatStateContextValue {
  messages: ChatMessage[];
  messageQueue: MessageQueueItem[];
  queueCount: number;
  setMessages: React.Dispatch<React.SetStateAction<ChatMessage[]>>;
  clearMessages: () => void;
  clearQueue: () => void;
  removeQueuedMessage: (id: string) => void;
  isRunning: boolean;
  isLoadingHistory: boolean;
  isSwitchingSession: boolean;
  streamingPhase: StreamingPhase;
  activeToolName: string | null;
  statusText: string | null;
  lastProvenance: string | null;
  thinkingContent: string;
  streamSegments: Array<{text: string; ts: number; kind: 'text' | 'thinking'}>;
  compactionPhase: 'idle' | 'compacting' | 'compacted';
  wsConnected: boolean;
  pendingApproval: ExecApprovalRequest | null;
  pendingApprovals: ExecApprovalRequest[];
  pendingApprovalCount: number;
  resolveApproval: (approvalId: string, decision: 'allow-once' | 'deny' | 'allow-always') => Promise<void>;
  dismissApproval: (approvalId?: string) => void;
  provider: string;
  setProvider: (p: string) => void;
  session: string;
  setSession: (s: string) => void;
  agentId: string | undefined;
  setAgentId: (a: string | undefined) => void;
  selectedModel: string;
  setSelectedModel: (m: string) => void;
  switchModel: (m: string) => Promise<{ deferred: boolean }>;
  sendMessage: (text: string) => Promise<void>;
  injectNote: (text: string, sessionKey?: string) => Promise<void>;
  cancelStream: () => Promise<void>;
  loadHistory: (sessionKey: string, providerName?: string) => Promise<void>;
  selectSession: (sessionKey: string) => Promise<void>;
  refreshChat: () => Promise<void>;
  wsManager: WsManager | null;
  reconnectSocket: () => void;
  // Session controls (OpenClaw session thinking + fast mode)
  thinkingLevel: 'off' | 'minimal' | 'low' | 'medium' | 'high' | 'xhigh' | 'adaptive';
  setThinkingLevel: (level: 'off' | 'minimal' | 'low' | 'medium' | 'high' | 'xhigh' | 'adaptive') => Promise<void>;
  fastModeEnabled: boolean;
  toggleFastMode: () => Promise<void>;
  compactionModelOverride: string;
  setCompactionModelOverride: (model: string) => Promise<void>;
  compactionModelLoading: boolean;
  compactionModelError: string | null;
  sessionControlsSupported: boolean;
  ensureSessionControlsMetadataLoaded: () => Promise<void>;
  sessionTelemetry: OpenClawSessionTelemetry | null;
  sessionAvailability: 'unknown' | 'present' | 'missing';
}

const ChatStateContext = createContext<ChatStateContextValue | null>(null);

export function useChatState(): ChatStateContextValue {
  const ctx = useContext(ChatStateContext);
  if (!ctx) throw new Error('useChatState must be used within ChatStateProvider');
  return ctx;
}

/* ═══ Provider Component ═══ */

const LEGACY_SESSION_STORAGE_KEY = 'agent-chat-session';
const SESSION_STORAGE_PREFIX = 'agent-chat-session:';

function getProviderSessionStorageKey(provider: string): string {
  return `${SESSION_STORAGE_PREFIX}${String(provider || 'OPENCLAW').trim().toUpperCase() || 'OPENCLAW'}`;
}

function normalizeInitialSession(provider: string, session: string): string {
  const p = String(provider || '').trim().toUpperCase();
  const aliased = normalizePortalNewSessionAlias(String(session || '').trim() || 'main');
  const s = aliased || 'main';
  if (p === 'OPENCLAW' && s === 'main') return 'agent:main:main';
  if (p === 'OPENCLAW' && s.startsWith('new-')) return `agent:main:${s}`;
  if (p !== 'OPENCLAW' && s.startsWith('agent:')) return 'main';
  return s;
}

function readStoredSession(provider: string): string {
  if (typeof window === 'undefined') return normalizeInitialSession(provider, 'main');
  const providerScoped = localStorage.getItem(getProviderSessionStorageKey(provider));
  if (providerScoped && providerScoped.trim()) {
    return normalizeInitialSession(provider, providerScoped);
  }
  const legacy = localStorage.getItem(LEGACY_SESSION_STORAGE_KEY) || 'main';
  return normalizeInitialSession(provider, legacy);
}

function persistStoredSession(provider: string, session: string): string {
  const normalized = normalizeInitialSession(provider, session);
  if (typeof window !== 'undefined') {
    localStorage.setItem(getProviderSessionStorageKey(provider), normalized);
    localStorage.setItem(LEGACY_SESSION_STORAGE_KEY, normalized);
  }
  return normalized;
}

const LIFECYCLE_CONTROL_TOKENS = new Set([
  'start',
  'started',
  'running',
  'end',
  'ended',
  'complete',
  'completed',
  'error',
  'failed',
  'idle',
  'compacting',
  'compacted',
]);

const LIFECYCLE_FLUSH_PREPARING_RE = /\b(memory flush (?:about to start|starting|started|queued|pending)|preparing (?:for )?(?:a )?memory flush|preparing context maintenance|preparing compaction|preparing to store durable memor(?:y|ies)|about to compact|pre-compaction|heartbeat check (?:started|starting|running|queued|pending)|checking heartbeat|reading heartbeat\.md|read heartbeat\.md)\b/i;
const LIFECYCLE_FLUSH_RUNNING_RE = /\b(memory flush(?:ing)?|flush in progress|flushing memory|storing durable memor(?:y|ies)|writing durable memor(?:y|ies)|context maintenance|refreshing (?:context|memory)|summariz(?:ing|ation) (?:context|conversation|history)|trimming context)\b/i;
const LIFECYCLE_FLUSH_DONE_RE = /\b(memory flush complete(?:d)?|durable memor(?:y|ies) (?:stored|written)|context refreshed|context maintenance (?:finished|complete(?:d)?)|compaction (?:incomplete|did not complete)|heartbeat check complete(?:d)?|heartbeat_ok)\b/i;
const LIFECYCLE_COMPACTING_RE = /^(?:compacting context[.…]*|auto-compaction(?: started| in progress)?[.…]*|context compaction(?: started| in progress)?[.…]*|compaction (?:in progress|started)\.?)$/i;
const LIFECYCLE_COMPACTED_RE = /^(?:context compacted\.?|auto-compaction complete(?:d)?\.?|context compaction complete(?:d)?\.?|compaction (?:complete(?:d)?|finished)\.?)$/i;

function normalizeLifecycleMarker(text: string): string {
  return String(text || '').replace(/\s+/g, ' ').trim().replace(/^[^\p{L}\p{N}]+/u, '').trim();
}

type LifecycleMaintenanceSignal = 'idle' | 'maintenance' | 'maintenance_done' | 'compacting' | 'compacted';

export interface OpenClawSessionTelemetry {
  contextTokens: number | null;
  totalTokens: number | null;
  pressureRatio: number | null;
  compactionCount: number | null;
  model: string | null;
  updatedAt: number;
}

function firstFiniteNumber(...candidates: unknown[]): number | null {
  for (const candidate of candidates) {
    if (typeof candidate === 'number' && Number.isFinite(candidate) && candidate >= 0) {
      return candidate;
    }
  }
  return null;
}

function getLastRunningToolCall(toolCalls: ToolCall[]): ToolCall | null {
  for (let i = toolCalls.length - 1; i >= 0; i--) {
    if (toolCalls[i]?.status === 'running') {
      return toolCalls[i];
    }
  }
  return null;
}

function extractOpenClawSessionTelemetry(sessionInfo: any, modelHint?: string): OpenClawSessionTelemetry | null {
  if (!sessionInfo || typeof sessionInfo !== 'object') return null;

  const contextTokens = firstFiniteNumber(
    sessionInfo.contextTokens,
    sessionInfo.contextWindowTokens,
    sessionInfo.contextWindow,
    sessionInfo.modelContextTokens,
    sessionInfo.modelContextWindow,
    sessionInfo.currentModel?.contextTokens,
    sessionInfo.currentModel?.contextWindow,
    sessionInfo.usage?.contextTokens,
    sessionInfo.meta?.contextTokens,
  );

  const totalTokens = firstFiniteNumber(
    sessionInfo.totalTokens,
    sessionInfo.usage?.totalTokens,
    sessionInfo.lastRun?.totalTokens,
    sessionInfo.lastRun?.usage?.totalTokens,
    sessionInfo.lastCallUsage?.totalTokens,
    sessionInfo.meta?.totalTokens,
  );

  const compactionCount = firstFiniteNumber(
    sessionInfo.compactionCount,
    sessionInfo.agentMeta?.compactionCount,
    sessionInfo.lastRun?.compactionCount,
    sessionInfo.lastRunStatus?.compactionCount,
    sessionInfo.meta?.compactionCount,
  );

  const model = typeof modelHint === 'string' && modelHint.trim() ? modelHint.trim() : null;
  if (contextTokens == null && totalTokens == null && compactionCount == null && !model) return null;

  return {
    contextTokens,
    totalTokens,
    pressureRatio: contextTokens && totalTokens != null ? Math.max(0, Math.min(1, totalTokens / contextTokens)) : null,
    compactionCount,
    model,
    updatedAt: Date.now(),
  };
}

function extractLifecycleStatusText(data: any): string | null {
  const candidates = [
    data?.statusText,
    data?.message,
    data?.text,
    data?.content,
    data?.detail,
    data?.description,
    data?.status,
  ];

  for (const candidate of candidates) {
    if (typeof candidate !== 'string') continue;
    const next = candidate.trim();
    if (!next) continue;
    if (LIFECYCLE_CONTROL_TOKENS.has(next.toLowerCase())) continue;
    return next;
  }

  return null;
}

function inferLifecycleMaintenanceSignal(phase: string, statusText: string | null): LifecycleMaintenanceSignal {
  const normalizedPhase = String(phase || '').trim().toLowerCase();
  const normalizedStatus = normalizeLifecycleMarker(String(statusText || ''));

  const phaseClaimsCompaction = normalizedPhase === 'compacted'
    || normalizedPhase === 'compaction_end'
    || normalizedPhase === 'compaction_completed'
    || normalizedPhase === 'compacting'
    || normalizedPhase === 'compaction_start'
    || normalizedPhase === 'compaction_started';
  const statusContradictsCompaction = Boolean(normalizedStatus && phaseClaimsCompaction && !isCompactionNotice(normalizedStatus));
  if (!statusContradictsCompaction && (normalizedPhase === 'compacted' || normalizedPhase === 'compaction_end' || normalizedPhase === 'compaction_completed')) {
    return 'compacted';
  }
  if (!statusContradictsCompaction && (normalizedPhase === 'compacting' || normalizedPhase === 'compaction_start' || normalizedPhase === 'compaction_started')) {
    return 'compacting';
  }
  if (!normalizedStatus) {
    return 'idle';
  }
  if (LIFECYCLE_COMPACTED_RE.test(normalizedStatus)) {
    return 'compacted';
  }
  if (LIFECYCLE_COMPACTING_RE.test(normalizedStatus)) {
    return 'compacting';
  }
  if (LIFECYCLE_FLUSH_DONE_RE.test(normalizedStatus)) {
    return 'maintenance_done';
  }
  if (LIFECYCLE_FLUSH_PREPARING_RE.test(normalizedStatus) || LIFECYCLE_FLUSH_RUNNING_RE.test(normalizedStatus)) {
    return 'maintenance';
  }
  return 'idle';
}

function defaultLifecycleStatusText(signal: LifecycleMaintenanceSignal): string {
  if (signal === 'compacting') return 'Compacting context…';
  if (signal === 'compacted') return 'Context compacted';
  if (signal === 'maintenance') return 'Preparing context maintenance…';
  if (signal === 'maintenance_done') return 'Context maintenance finished.';
  return 'Agent is thinking…';
}

export function ChatStateProvider({ children }: { children: React.ReactNode }) {
  type ThinkingLevel = 'off' | 'minimal' | 'low' | 'medium' | 'high' | 'xhigh' | 'adaptive';
  const THINKING_LEVELS: ThinkingLevel[] = ['off', 'minimal', 'low', 'medium', 'high', 'xhigh', 'adaptive'];
  const publicSettings = usePublicSettings();
  const useDirectGateway = publicSettings?.useDirectGateway ?? BUILD_TIME_USE_DIRECT_GATEWAY;

  // Persisted selection state
  const [provider, setProviderRaw] = useState(
    () => localStorage.getItem('agent-chat-provider') || 'OPENCLAW',
  );
  const [session, setSessionRaw] = useState(() => {
    const storedProvider = localStorage.getItem('agent-chat-provider') || 'OPENCLAW';
    return readStoredSession(storedProvider);
  });
  const [agentId, setAgentIdRaw] = useState<string | undefined>(
    () => localStorage.getItem('agent-chat-agentId') || undefined,
  );
  const [selectedModel, setSelectedModelRaw] = useState(() => {
    const p = localStorage.getItem('agent-chat-provider') || 'OPENCLAW';
    const stored = normalizeProviderModel(p, localStorage.getItem(MODEL_STORAGE_PREFIX + p) || '');
    // Only OpenClaw models require provider prefixes. Native providers use bare IDs.
    if (stored && p === 'OPENCLAW' && !stored.includes('/')) {
      localStorage.removeItem(MODEL_STORAGE_PREFIX + p);
      return '';
    }
    return stored;
  });
  const user = useAuthStore((state) => state.user);

  // Wrapped setters with localStorage persistence
  const setProvider = useCallback((p: string) => {
    localStorage.setItem('agent-chat-provider', p);
    setProviderRaw(p);
    const nextSession = readStoredSession(p);
    sessionRef.current = nextSession;
    setSessionRaw(nextSession);
    const stored = normalizeProviderModel(p, localStorage.getItem(MODEL_STORAGE_PREFIX + p) || '');
    // Same guard, but only for OpenClaw. Native providers legitimately use bare IDs.
    if (stored && p === 'OPENCLAW' && !stored.includes('/')) {
      localStorage.removeItem(MODEL_STORAGE_PREFIX + p);
      setSelectedModelRaw('');
    } else {
      setSelectedModelRaw(stored);
    }
  }, []);
  const setSession = useCallback((s: string) => {
    const normalized = persistStoredSession(providerRef.current, s);
    sessionRef.current = normalized;
    setSessionRaw(normalized);
  }, []);
  const setAgentId = useCallback((a: string | undefined) => {
    if (a) localStorage.setItem('agent-chat-agentId', a);
    else localStorage.removeItem('agent-chat-agentId');
    setAgentIdRaw(a);
  }, []);
  const setSelectedModel = useCallback((m: string) => {
    const normalized = normalizeProviderModel(provider, m);
    setSelectedModelRaw(normalized);
    if (normalized) localStorage.setItem(MODEL_STORAGE_PREFIX + provider, normalized);
    else localStorage.removeItem(MODEL_STORAGE_PREFIX + provider);
  }, [provider]);

  useEffect(() => {
    const normalized = normalizeInitialSession(provider, session);
    if (normalized !== session) {
      persistStoredSession(provider, normalized);
      sessionRef.current = normalized;
      setSessionRaw(normalized);
      return;
    }
    persistStoredSession(provider, session);
  }, [provider, session]);

  const deriveSessionModel = useCallback((sessionInfo: any): string => {
    const joinModel = (providerName: string, modelName: string): string => {
      const providerKey = providerName.trim();
      const modelKey = modelName.trim();
      if (!providerKey || !modelKey) return '';
      return providerRef.current === 'OPENCLAW' && !modelKey.includes('/')
        ? `${providerKey}/${modelKey}`
        : modelKey;
    };

    const resolvedProvider = typeof sessionInfo?.resolved?.modelProvider === 'string' ? sessionInfo.resolved.modelProvider.trim() : '';
    const resolvedModel = typeof sessionInfo?.resolved?.model === 'string' ? sessionInfo.resolved.model.trim() : '';
    if (resolvedProvider && resolvedModel) return joinModel(resolvedProvider, resolvedModel);

    const providerName = typeof sessionInfo?.modelProvider === 'string' ? sessionInfo.modelProvider.trim() : '';
    const modelName = typeof sessionInfo?.model === 'string' ? sessionInfo.model.trim() : '';
    if (providerName && modelName) return joinModel(providerName, modelName);
    if (modelName && modelName.includes('/')) return modelName;

    const nestedProvider = typeof sessionInfo?.currentModel?.provider === 'string' ? sessionInfo.currentModel.provider.trim() : '';
    const nestedModel = typeof sessionInfo?.currentModel?.model === 'string' ? sessionInfo.currentModel.model.trim() : '';
    if (nestedProvider && nestedModel) return joinModel(nestedProvider, nestedModel);
    if (nestedModel && nestedModel.includes('/')) return nestedModel;

    const overrideProvider = typeof sessionInfo?.providerOverride === 'string' ? sessionInfo.providerOverride.trim() : '';
    const overrideModel = typeof sessionInfo?.modelOverride === 'string' ? sessionInfo.modelOverride.trim() : '';
    if (overrideProvider && overrideModel) return joinModel(overrideProvider, overrideModel);
    if (overrideModel && overrideModel.includes('/')) return overrideModel;

    return '';
  }, []);

  const switchModel = useCallback(async (m: string) => {
    setSelectedModel(m);

    const currentProvider = providerRef.current;
    const currentSession = sessionRef.current;
    if (!m) return { deferred: false };

    const needsConcreteOpenClawSession = currentProvider === 'OPENCLAW';
    const hasConcreteSession = needsConcreteOpenClawSession
      ? Boolean(currentSession && currentSession.startsWith('agent:'))
      : Boolean(currentSession && currentSession !== 'main' && !currentSession.startsWith('new-'));

    if (!hasConcreteSession) {
      return { deferred: true };
    }

    try {
      await gatewayAPI.patchSessionModel(currentSession, m, currentProvider);
      return { deferred: false };
    } catch (err: any) {
      const status = err?.response?.status;
      const isSyntheticOpenClawSession = currentProvider === 'OPENCLAW'
        && typeof currentSession === 'string'
        && currentSession.includes(':new-');
      if ((status === 404 || status === 409) && isSyntheticOpenClawSession) {
        await gatewayAPI.createSession(currentSession, currentProvider);
        await gatewayAPI.patchSessionModel(currentSession, m, currentProvider);
        return { deferred: false };
      }
      throw err;
    }
  }, [setSelectedModel]);

  // Chat state
  const [messages, setMessages] = useState<ChatMessage[]>([]);
  const messagesRef = useRef<ChatMessage[]>([]);
  useEffect(() => { messagesRef.current = messages; }, [messages]);
  const [messageQueue, setMessageQueue] = useState<MessageQueueItem[]>([]);
  const [isRunning, setIsRunning] = useState(false);
  const [isLoadingHistory, setIsLoadingHistory] = useState(false);
  const [isSwitchingSession, setIsSwitchingSession] = useState(false);
  const [statusText, setStatusText] = useState<string | null>(null);
  const [lastProvenance, setLastProvenance] = useState<string | null>(null);
  const [streamingPhase, setStreamingPhase] = useState<StreamingPhase>('idle');
  const [activeToolName, setActiveToolName] = useState<string | null>(null);
  const [thinkingContent, setThinkingContent] = useState<string>('');
  const thinkingContentRef = useRef('');
  useEffect(() => { thinkingContentRef.current = thinkingContent; }, [thinkingContent]);
  const [startupReady, setStartupReady] = useState(false);
  const [directGatewayBootstrapReady, setDirectGatewayBootstrapReady] = useState(false);
  const [directGatewayDemanded, setDirectGatewayDemanded] = useState(false);
  // Graduated streaming segments — when a tool call starts, current accumulated text
  // gets "graduated" into a segment so it renders as a finalized bubble. This matches
  // the OpenClaw web UI v2 pattern where thoughts don't disappear on tool transitions.
  const [streamSegments, setStreamSegments] = useState<StreamSegment[]>([]);
  const streamSegmentsRef = useRef<StreamSegment[]>([]);
  useEffect(() => { streamSegmentsRef.current = streamSegments; }, [streamSegments]);
  const [pendingApprovals, setPendingApprovals] = useState<ExecApprovalRequest[]>([]);
  const [wsConnected, setWsConnected] = useState(false);
  const [compactionPhase, setCompactionPhase] = useState<'idle' | 'compacting' | 'compacted'>('idle');
  const [sessionTelemetry, setSessionTelemetry] = useState<OpenClawSessionTelemetry | null>(null);
  const [sessionAvailability, setSessionAvailability] = useState<'unknown' | 'present' | 'missing'>('unknown');
  const compactionPhaseRef = useRef<'idle' | 'compacting' | 'compacted'>('idle');

  // Session controls state (thinking/fast mode)
  const [thinkingLevel, setThinkingLevelState] = useState<ThinkingLevel>('high');
  const [fastModeEnabled, setFastModeEnabled] = useState(false);
  const [compactionModelOverride, setCompactionModelOverrideState] = useState<string>('');
  const [compactionModelLoading, setCompactionModelLoading] = useState(false);
  const [compactionModelError, setCompactionModelError] = useState<string | null>(null);
  const [sessionControlsMetadataLoaded, setSessionControlsMetadataLoaded] = useState(false);

  // Refs
  const streamWatchdogRef = useRef<ReturnType<typeof setTimeout> | null>(null);
  const compactionTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
  const sessionTelemetryRefreshTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
  const postTurnHistorySyncTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
  const toolCounterRef = useRef(0);
  const hasRealToolEventsRef = useRef(false);
  const sessionControlsMetadataPromiseRef = useRef<Promise<void> | null>(null);
  const wsManagerRef = useRef<WsManager | null>(null);
  // Direct gateway client for OPENCLAW provider (bypasses portal WS middleman)
  const directClientRef = useRef<OpenClawGatewayClient | null>(null);
  const streamTransportRef = useRef<'portal' | 'direct' | null>(null);
  const currentRunIdRef = useRef<string | null>(null);
  const lastTerminalRunIdRef = useRef<string | null>(null);
  const suppressedRunIdsRef = useRef<Map<string, number>>(new Map());
  const streamingAssistantIdRef = useRef<string | null>(null);
  const activeStreamToolCallsRef = useRef<ToolCall[]>([]);
  const activeToolNameRef = useRef<string | null>(null);
  const assembledRef = useRef('');
  const lastSegmentStartRef = useRef(0);
  const lastRawTextLenRef = useRef(0); // Track raw gateway text length for accurate graduation
  const isStreamActiveRef = useRef(false);
  const isQueueDrainActiveRef = useRef(false);
  const sessionRef = useRef(session);
  const providerRef = useRef(provider);
  const agentIdRef = useRef(agentId);
  const modelRef = useRef(selectedModel);
  const messageQueueRef = useRef<MessageQueueItem[]>([]);
  // Monotonically-incrementing generation counter — incremented on every session
  // switch or clearMessages. Any async history load that started in a previous
  // generation simply discards its result, eliminating race conditions.
  const historyGenRef = useRef(0);
  const loadHistoryInternalRef = useRef<((sessionKey: string, prov?: string, options?: { force?: boolean; refreshActiveSnapshot?: boolean; preserveLocalMessages?: boolean }) => Promise<boolean>) | null>(null);
  const lastForegroundReconcileAtRef = useRef(0);
  // Throttle refs for streaming text updates — batch text deltas to reduce re-renders
  const textThrottleTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
  const pendingTextUpdateRef = useRef<string | null>(null);
  const TEXT_THROTTLE_MS = 50; // 20fps is plenty smooth for text streaming

  // Sync refs immediately when state changes. Note: the refs are ALSO updated
  // synchronously in the event handlers (see case 'session' below) to avoid races.
  useEffect(() => { sessionRef.current = session; }, [session]);
  useEffect(() => { providerRef.current = provider; }, [provider]);
  useEffect(() => { agentIdRef.current = agentId; }, [agentId]);
  useEffect(() => { modelRef.current = selectedModel; }, [selectedModel]);
  useEffect(() => { activeToolNameRef.current = activeToolName; }, [activeToolName]);
  useEffect(() => { messageQueueRef.current = messageQueue; }, [messageQueue]);
  useEffect(() => {
    const activeAssistantId = streamingAssistantIdRef.current;
    if (!activeAssistantId) return;
    const activeMessage = messages.find((message) => message.id === activeAssistantId);
    const normalizedToolCalls = normalizeToolCalls(activeMessage?.toolCalls, 'running') || [];
    if (normalizedToolCalls.length) {
      activeStreamToolCallsRef.current = normalizedToolCalls;
    }
    const runningToolCall = getLastRunningToolCall(normalizedToolCalls);
    if (streamingPhase === 'tool' && runningToolCall) {
      const runningToolName = resolveToolName(runningToolCall.name);
      if (runningToolName && runningToolName !== activeToolNameRef.current) {
        activeToolNameRef.current = runningToolName;
        setActiveToolName(runningToolName);
      }
    }
  }, [messages, streamingPhase]);

  const getRunningToolName = useCallback((): string | null => {
    const runningToolCall = getLastRunningToolCall(activeStreamToolCallsRef.current);
    if (runningToolCall?.name) return resolveToolName(runningToolCall.name);
    return activeToolNameRef.current ? resolveToolName(activeToolNameRef.current) : null;
  }, []);

  const setLiveRunPhase = useCallback((preferredPhase: 'thinking' | 'streaming', nextStatusText?: string | null) => {
    const runningToolName = getRunningToolName();
    const normalizedStatus = typeof nextStatusText === 'string' ? normalizeLifecycleMarker(nextStatusText) : '';
    const isMaintenanceStatus = normalizedStatus
      ? (
          LIFECYCLE_FLUSH_PREPARING_RE.test(normalizedStatus)
          || LIFECYCLE_FLUSH_RUNNING_RE.test(normalizedStatus)
          || LIFECYCLE_FLUSH_DONE_RE.test(normalizedStatus)
          || LIFECYCLE_COMPACTING_RE.test(normalizedStatus)
          || LIFECYCLE_COMPACTED_RE.test(normalizedStatus)
        )
      : false;

    if (runningToolName) {
      setStreamingPhase('tool');
      setActiveToolName(runningToolName);
      setStatusText(
        isMaintenanceStatus
          ? getToolStatusText(runningToolName)
          : getToolStatusText(runningToolName, normalizedStatus || null)
      );
      return;
    }

    setStreamingPhase(preferredPhase);
    setActiveToolName(null);
    setStatusText(normalizedStatus || null);
  }, [getRunningToolName]);

  const getStreamWatchdogTimeoutMs = useCallback(() => {
    if (getRunningToolName()) return 15 * 60_000;
    if (compactionPhaseRef.current !== 'idle') return 10 * 60_000;
    if (assembledRef.current.trim()) return 180_000;
    return 10 * 60_000;
  }, [getRunningToolName]);

  useEffect(() => {
    if (provider !== 'OPENCLAW') {
      setSessionAvailability('present');
      return;
    }
    if (!session || !session.startsWith('agent:')) {
      setSessionAvailability('unknown');
      return;
    }
    setSessionAvailability('unknown');
  }, [provider, session]);

  const applySessionTelemetry = useCallback((sessionInfo: any) => {
    const actualModel = deriveSessionModel(sessionInfo);
    const next = extractOpenClawSessionTelemetry(sessionInfo, actualModel);
    if (actualModel) {
      setSelectedModelRaw((prev) => (prev === actualModel ? prev : actualModel));
    }
    setSessionTelemetry((prev) => {
      if (!prev && !next) return prev;
      if (!prev || !next) return next;
      if (
        prev.contextTokens === next.contextTokens
        && prev.totalTokens === next.totalTokens
        && prev.pressureRatio === next.pressureRatio
        && prev.compactionCount === next.compactionCount
        && prev.model === next.model
      ) {
        return prev;
      }
      return next;
    });
  }, [deriveSessionModel]);

  const refreshSessionTelemetry = useCallback(async (sessionKey?: string) => {
    const targetSession = typeof sessionKey === 'string' && sessionKey.trim() ? sessionKey.trim() : sessionRef.current;
    if (!startupReady || providerRef.current !== 'OPENCLAW' || !targetSession || !targetSession.startsWith('agent:')) {
      setSessionTelemetry(null);
      return;
    }

    try {
      const data = await gatewayAPI.sessionInfo(targetSession, { silent: true });
      setSessionAvailability('present');
      applySessionTelemetry(data?.session);
    } catch (err: any) {
      if (err?.response?.status === 404) {
        setSessionAvailability('missing');
        setSessionTelemetry(null);
        return;
      }
      console.warn('[ChatState] Failed to refresh session telemetry:', err);
    }
  }, [applySessionTelemetry, startupReady]);

  const scheduleSessionTelemetryRefresh = useCallback((delayMs = 0, sessionKey?: string) => {
    if (sessionTelemetryRefreshTimerRef.current) {
      clearTimeout(sessionTelemetryRefreshTimerRef.current);
      sessionTelemetryRefreshTimerRef.current = null;
    }
    sessionTelemetryRefreshTimerRef.current = setTimeout(() => {
      sessionTelemetryRefreshTimerRef.current = null;
      void refreshSessionTelemetry(sessionKey);
    }, Math.max(0, delayMs));
  }, [refreshSessionTelemetry]);

  const clearPostTurnHistorySync = useCallback(() => {
    if (postTurnHistorySyncTimerRef.current) {
      clearTimeout(postTurnHistorySyncTimerRef.current);
      postTurnHistorySyncTimerRef.current = null;
    }
  }, []);

  const schedulePostTurnHistorySync = useCallback((delayMs = 1200, followUpDelayMs = 3500) => {
    clearPostTurnHistorySync();
    const targetSession = sessionRef.current || 'main';
    const targetProvider = providerRef.current;
    postTurnHistorySyncTimerRef.current = setTimeout(() => {
      postTurnHistorySyncTimerRef.current = null;
      if (isStreamActiveRef.current) return;
      if (providerRef.current !== targetProvider) return;
      if ((sessionRef.current || 'main') !== targetSession) return;
      if (targetProvider === 'OPENCLAW') {
        void loadHistoryInternalRef.current?.(targetSession, targetProvider, { force: true, refreshActiveSnapshot: false })
          ?.finally(() => {
            if (followUpDelayMs <= 0) return;
            if (isStreamActiveRef.current) return;
            if (providerRef.current !== targetProvider) return;
            if ((sessionRef.current || 'main') !== targetSession) return;
            postTurnHistorySyncTimerRef.current = setTimeout(() => {
              postTurnHistorySyncTimerRef.current = null;
              if (isStreamActiveRef.current) return;
              if (providerRef.current !== targetProvider) return;
              if ((sessionRef.current || 'main') !== targetSession) return;
              void loadHistoryInternalRef.current?.(targetSession, targetProvider, { force: true, refreshActiveSnapshot: false });
            }, Math.max(0, followUpDelayMs));
          });
      }
    }, Math.max(0, delayMs));
  }, [clearPostTurnHistorySync]);

  useEffect(() => {
    if (provider !== 'OPENCLAW' || !session || !session.startsWith('agent:') || !session.includes(':new-')) {
      return;
    }
    let cancelled = false;
    void (async () => {
      try {
        await gatewayAPI.createSession(session, 'OPENCLAW');
        if (!cancelled) {
          setSessionAvailability('present');
          void refreshSessionTelemetry(session);
        }
      } catch (err) {
        console.warn('[ChatState] Failed to materialize synthetic OpenClaw session:', err);
      }
    })();
    return () => {
      cancelled = true;
    };
  }, [provider, refreshSessionTelemetry, session]);

  const ensureSessionControlsMetadataLoaded = useCallback(async () => {
    if (!startupReady || provider !== 'OPENCLAW' || !session || !session.startsWith('agent:')) return;
    if (sessionControlsMetadataLoaded) return;
    if (sessionControlsMetadataPromiseRef.current) {
      await sessionControlsMetadataPromiseRef.current;
      return;
    }

    const loadPromise = (async () => {
      setCompactionModelError(null);
      const [sessionInfoResult, compactionResult] = await Promise.allSettled([
        gatewayAPI.sessionInfo(session, { silent: true }),
        gatewayAPI.getConfigPath('agents.defaults.compaction.model'),
      ]);

      if (sessionInfoResult.status === 'fulfilled') {
        const data = sessionInfoResult.value;
        setSessionAvailability('present');
        applySessionTelemetry(data?.session);
        const actualModel = deriveSessionModel(data?.session);
        if (actualModel) {
          setSelectedModelRaw((prev) => (prev === actualModel ? prev : actualModel));
        }
        const sessionThinking = String(
          data?.session?.thinkingLevel
          || data?.session?.thinking
          || data?.session?.settings?.thinking
          || '',
        ).toLowerCase();
        if (THINKING_LEVELS.includes(sessionThinking as ThinkingLevel)) {
          setThinkingLevelState(sessionThinking as ThinkingLevel);
        } else {
          const modelStr = String(actualModel || '').toLowerCase();
          const isAdaptiveDefault = /claude-(opus|sonnet)-4[._-](5|6|7|8|9)|claude-(opus|sonnet)-[5-9]/.test(modelStr);
          setThinkingLevelState(isAdaptiveDefault ? 'adaptive' : 'high');
        }
        setFastModeEnabled(Boolean(
          data?.session?.fastMode
          ?? data?.session?.settings?.fastMode
          ?? false,
        ));
      }

      if (sessionInfoResult.status === 'rejected' && (sessionInfoResult.reason as any)?.response?.status === 404) {
        setSessionAvailability('missing');
        setSessionTelemetry(null);
      }

      if (compactionResult.status === 'fulfilled') {
        const value = typeof compactionResult.value?.value === 'string' ? compactionResult.value.value.trim() : '';
        setCompactionModelOverrideState(value);
      } else {
        setCompactionModelOverrideState('');
      }

      setSessionControlsMetadataLoaded(true);
    })();

    sessionControlsMetadataPromiseRef.current = loadPromise;
    try {
      await loadPromise;
    } finally {
      sessionControlsMetadataPromiseRef.current = null;
    }
  }, [startupReady, provider, session, sessionControlsMetadataLoaded, deriveSessionModel, applySessionTelemetry]);

  useEffect(() => {
    setSessionControlsMetadataLoaded(false);
    sessionControlsMetadataPromiseRef.current = null;
    setThinkingLevelState('high');
    setFastModeEnabled(false);
    setCompactionModelOverrideState('');
    setCompactionModelError(null);
    if (provider !== 'OPENCLAW' || !session || !session.startsWith('agent:') || !startupReady) {
      setSessionTelemetry(null);
      if (sessionTelemetryRefreshTimerRef.current) {
        clearTimeout(sessionTelemetryRefreshTimerRef.current);
        sessionTelemetryRefreshTimerRef.current = null;
      }
      return;
    }
    scheduleSessionTelemetryRefresh(0, session);
  }, [provider, session, scheduleSessionTelemetryRefresh, startupReady]);

  useEffect(() => () => {
    if (sessionTelemetryRefreshTimerRef.current) {
      clearTimeout(sessionTelemetryRefreshTimerRef.current);
      sessionTelemetryRefreshTimerRef.current = null;
    }
  }, []);

  const normalizeAgentError = useCallback((err: unknown, fallback = 'Agent request failed') => {
    const raw = err instanceof Error ? err.message : String(err || '').trim();
    if (!raw) return fallback;
    if (/not logged in|please run \/login/i.test(raw)) return 'This provider is installed but not logged in on the server yet.';
    if (/GEMINI_API_KEY|GOOGLE_GENAI_USE_VERTEXAI|GOOGLE_GENAI_USE_GCA|Auth method/i.test(raw)) return 'Gemini is installed but not authenticated on the server yet.';
    if (/ECONNREFUSED|Cannot connect to OpenClaw gateway|gateway.*not connected/i.test(raw)) return 'OpenClaw is reconnecting right now. Give it a few seconds and retry.';
    return raw;
  }, []);

  // Stream watchdog
  const resetStreamWatchdog = useCallback(() => {
    if (streamWatchdogRef.current) clearTimeout(streamWatchdogRef.current);
    if (!isStreamActiveRef.current) return;
    const timeoutMs = getStreamWatchdogTimeoutMs();
    streamWatchdogRef.current = setTimeout(async () => {
      if (!isStreamActiveRef.current) return;
      console.warn('[ChatState] Stream watchdog: no activity for 180s — verifying stream status');

      const currentSession = sessionRef.current || 'main';
      const currentProvider = providerRef.current;
      const usingDirectOpenClaw = useDirectGateway && currentProvider === 'OPENCLAW';
      const directClient = directClientRef.current;

      if (usingDirectOpenClaw && !directClient?.isConnected) {
        directClient?.connect();
        setIsRunning(true);
        setStreamingPhase(prev => prev === 'idle' ? (activeToolName ? 'tool' : 'thinking') : prev);
        setStatusText(prev => prev || 'Reconnecting to stream…');
      } else if (!usingDirectOpenClaw && wsManagerRef.current && !wsManagerRef.current.isConnected()) {
        wsManagerRef.current.reconnect();
      }

      try {
        const params: Record<string, string> = { session: currentSession };
        if (currentProvider) params.provider = currentProvider;
        const { data } = await client.get('/gateway/stream-status', { params, _silent: true } as any);
        if (data?.active) {
          const phase = data.phase === 'tool' ? 'tool' : data.phase === 'streaming' ? 'streaming' : 'thinking';
          const snapshotToolName = resolveToolName(data.toolName, data.name, data.content, 'tool');
          const fallbackStatus = snapshotToolName
            ? `Using ${snapshotToolName}…`
            : phase === 'streaming'
              ? 'Still responding…'
              : 'Still working…';
          const hydrated = applyOpenClawActiveStreamSnapshot(data, {
            statusTextWhenNoTool: fallbackStatus,
            source: usingDirectOpenClaw ? 'direct' : 'portal',
          });
          if (!hydrated) {
            directClientRef.current?.setActiveStreamSession(currentSession);
            setIsRunning(true);
            setStreamingPhase(phase);
            setActiveToolName(snapshotToolName || null);
            setStatusText(
              snapshotToolName
                ? getToolStatusText(snapshotToolName, typeof data.statusText === 'string' ? data.statusText : null)
                : (typeof data.statusText === 'string' && data.statusText.trim() ? data.statusText.trim() : fallbackStatus)
            );
            applyCompactionSnapshotState(data.compactionPhase);
            resetStreamWatchdog();
          }
          return;
        }
      } catch (err) {
        console.warn('[ChatState] Stream watchdog verification failed:', err);
        if (usingDirectOpenClaw && (currentRunIdRef.current || activeToolName || hasRealToolEventsRef.current)) {
          setIsRunning(true);
          setStreamingPhase(prev => prev === 'idle' ? (activeToolName ? 'tool' : 'thinking') : prev);
          setStatusText(prev => prev || (activeToolName ? getToolStatusText(activeToolName) : 'Reconnecting to stream…'));
          resetStreamWatchdog();
          return;
        }
      }

      const ft = assembledRef.current;
      const shouldReloadHistoryIfIdle = currentProvider === 'OPENCLAW'
        && (
          Boolean(streamingAssistantIdRef.current)
          || Boolean(ft.trim())
          || streamSegmentsRef.current.length > 0
          || hasRealToolEventsRef.current
        );

      isStreamActiveRef.current = false;
      streamTransportRef.current = null;
      setIsRunning(false);
      setStreamingPhase('idle');
      setStatusText(null);
      setThinkingContent('');
      setActiveToolName(null);
      setCompactionPhase('idle');
      if (compactionTimerRef.current) { clearTimeout(compactionTimerRef.current); compactionTimerRef.current = null; }
      const cid = streamingAssistantIdRef.current;
      streamingAssistantIdRef.current = null;
      currentRunIdRef.current = null;
      directClientRef.current?.setActiveStreamSession(null);
      assembledRef.current = '';
      lastSegmentStartRef.current = 0;
      lastRawTextLenRef.current = 0;

      if (shouldReloadHistoryIfIdle) {
        void loadHistoryInternalRef.current?.(currentSession, currentProvider, { force: true, refreshActiveSnapshot: true });
        return;
      }

      if (cid && ft) {
        setMessages(prev => prev.map(m =>
          m.id === cid ? { ...m, content: ft + '\n\n*(stream interrupted)*' } : m
        ));
      }
    }, timeoutMs);
  }, [activeToolName, getRunningToolName, getStreamWatchdogTimeoutMs, useDirectGateway]);
  const clearStreamWatchdog = useCallback(() => {
    if (streamWatchdogRef.current) { clearTimeout(streamWatchdogRef.current); streamWatchdogRef.current = null; }
  }, []);

  const resolveOpenClawSessionKey = useCallback((rawSession?: string | null): string => {
    const sessionKey = typeof rawSession === 'string' ? rawSession.trim() : '';
    if (providerRef.current !== 'OPENCLAW') return sessionKey;
    if (sessionKey.startsWith('agent:')) return sessionKey;
    if (!sessionKey || sessionKey === 'main') {
      return isOwner(user) ? 'agent:main:main' : toConcreteOpenClawSessionKey('main', agentIdRef.current);
    }
    if (sessionKey.startsWith('new-')) {
      return toConcreteOpenClawSessionKey(sessionKey, agentIdRef.current);
    }
    return sessionKey;
  }, [user]);

  const appendSystemNotice = useCallback((content: string, provenance?: string) => {
    const now = Date.now();
    setMessages(prev => {
      const last = prev[prev.length - 1];
      if (last?.role === 'system' && last.content === content && now - last.createdAt.getTime() < 4000) {
        return prev;
      }
      return [...prev, { id: nextId(), role: 'system', content, createdAt: new Date(now), provenance }];
    });
  }, []);

  const markRunIdSuppressed = useCallback((rawRunId?: string | null) => {
    const runId = normalizeRunId(rawRunId);
    if (!runId) return;
    const now = Date.now();
    const expiresAt = now + 120_000;
    const suppressed = suppressedRunIdsRef.current;
    for (const [key, expiry] of suppressed.entries()) {
      if (expiry <= now) suppressed.delete(key);
    }
    suppressed.set(runId, expiresAt);
  }, []);

  const isRunIdSuppressed = useCallback((rawRunId?: string | null) => {
    const runId = normalizeRunId(rawRunId);
    if (!runId) return false;
    const now = Date.now();
    const suppressed = suppressedRunIdsRef.current;
    for (const [key, expiry] of suppressed.entries()) {
      if (expiry <= now) suppressed.delete(key);
    }
    return suppressed.has(runId);
  }, []);

  const clearSuppressedRunId = useCallback((rawRunId?: string | null) => {
    const runId = normalizeRunId(rawRunId);
    if (!runId) return;
    suppressedRunIdsRef.current.delete(runId);
  }, []);

  const applyCompactionState = useCallback((update: {
    phase: 'start' | 'end';
    content?: string | null;
    completed?: boolean;
    maintenanceKind?: 'compaction' | 'maintenance';
  } | 'start' | 'end') => {
    if (providerRef.current !== 'OPENCLAW') return;

    const phase = typeof update === 'string' ? update : update.phase;
    const content = typeof update === 'string' ? '' : String(update.content || '').trim();
    const completed = typeof update === 'string' ? phase === 'end' : update.completed !== false;
    const maintenanceKind = typeof update === 'string' ? 'compaction' : (update.maintenanceKind || 'compaction');

    if (phase === 'start') {
      const noticeText = content || (maintenanceKind === 'maintenance' ? 'Context maintenance in progress…' : 'Compacting context…');
      if (compactionTimerRef.current) { clearTimeout(compactionTimerRef.current); compactionTimerRef.current = null; }
      compactionPhaseRef.current = 'compacting';
      setCompactionPhase('compacting');
      setStatusText(noticeText);
      setThinkingContent('');
      return;
    }

    if (completed && maintenanceKind === 'compaction') {
      const noticeText = content || 'Context compacted';
      compactionPhaseRef.current = 'compacted';
      setCompactionPhase('compacted');
      setStatusText(noticeText);
      appendSystemNotice(noticeText, 'compaction');
      if (compactionTimerRef.current) clearTimeout(compactionTimerRef.current);
      compactionTimerRef.current = setTimeout(() => {
        compactionPhaseRef.current = 'idle';
        setCompactionPhase('idle');
        setStatusText((prev) => (prev === noticeText ? null : prev));
        compactionTimerRef.current = null;
      }, 3000);
      return;
    }

    const noticeText = content || 'Context maintenance finished.';
    compactionPhaseRef.current = 'idle';
    setCompactionPhase('idle');
    setStatusText(noticeText);
    appendSystemNotice(noticeText, 'hidden-history-artifact');
    if (compactionTimerRef.current) clearTimeout(compactionTimerRef.current);
    compactionTimerRef.current = setTimeout(() => {
      setStatusText((prev) => (prev === noticeText ? null : prev));
      compactionTimerRef.current = null;
    }, 3000);
  }, [appendSystemNotice]);

  const applyCompactionSnapshotState = useCallback((phase?: unknown) => {
    if (phase !== 'idle' && phase !== 'compacting' && phase !== 'compacted') return;
    // Snapshot hydration is replay/reconnect state, not a live lifecycle event.
    // Replaying a completed compaction marker on every refresh creates a fake rail.
    // Live compaction_end events still use applyCompactionState('end') and render once.
    const effectivePhase = phase === 'compacted' ? 'idle' : phase;
    if (compactionTimerRef.current) {
      clearTimeout(compactionTimerRef.current);
      compactionTimerRef.current = null;
    }
    compactionPhaseRef.current = effectivePhase;
    setCompactionPhase(effectivePhase);
    if (effectivePhase === 'compacting') {
      setStatusText('Compacting context…');
    } else {
      setStatusText(prev => (prev === 'Compacting context…' || prev === 'Context compacted' ? null : prev));
    }
  }, []);

  const mergeStreamText = useCallback((incoming?: string, opts?: { replace?: boolean }) => {
    const chunk = typeof incoming === 'string' ? incoming : '';
    if (!chunk) return assembledRef.current;
    assembledRef.current = mergeAssistantStream(assembledRef.current, chunk, opts);
    return assembledRef.current;
  }, []);

  const upsertStreamingAssistant = useCallback((text: string) => {
    const cid = streamingAssistantIdRef.current;
    if (!cid) return;
    setMessages(prev => prev.map(m => m.id === cid ? { ...m, content: text } : m));
  }, []);

  const clearPendingTextRender = useCallback(() => {
    if (textThrottleTimerRef.current) {
      clearTimeout(textThrottleTimerRef.current);
      textThrottleTimerRef.current = null;
    }
    pendingTextUpdateRef.current = null;
  }, []);

  const schedulePendingTextRender = useCallback((text: string) => {
    pendingTextUpdateRef.current = text;
    if (textThrottleTimerRef.current) return;
    textThrottleTimerRef.current = setTimeout(() => {
      textThrottleTimerRef.current = null;
      if (pendingTextUpdateRef.current !== null) {
        upsertStreamingAssistant(pendingTextUpdateRef.current);
        pendingTextUpdateRef.current = null;
      }
    }, TEXT_THROTTLE_MS);
  }, [upsertStreamingAssistant]);

  const ensureStreamingAssistantBubble = useCallback((params?: {
    idPrefix?: string;
    content?: string;
    resetIfCreated?: boolean;
  }) => {
    const prefix = params?.idPrefix || 'stream';
    let assistantId = streamingAssistantIdRef.current;
    let created = false;
    if (!assistantId) {
      assistantId = `${prefix}-${Date.now()}`;
      streamingAssistantIdRef.current = assistantId;
      created = true;
      if (params?.resetIfCreated) {
        assembledRef.current = '';
        lastSegmentStartRef.current = 0;
        lastRawTextLenRef.current = 0;
        toolCounterRef.current = 0;
        hasRealToolEventsRef.current = false;
        setThinkingContent('');
        setStreamSegments([]);
      }
    }

    const maybeContent = typeof params?.content === 'string' ? params.content : null;
    setMessages(prev => {
      const index = prev.findIndex(m => m.id === assistantId);
      if (index >= 0) {
        if (maybeContent === null) return prev;
        const next = [...prev];
        next[index] = { ...next[index], content: maybeContent };
        return next;
      }
      return [...prev, {
        id: assistantId!,
        role: 'assistant' as const,
        content: maybeContent ?? '',
        createdAt: new Date(),
        toolCalls: [],
      }];
    });
    return { assistantId, created };
  }, []);

  const appendStreamSegment = useCallback((kind: StreamSegment['kind'], text: string) => {
    const value = typeof text === 'string' ? text : '';
    if (!value.trim()) return false;
    setStreamSegments(prev => [...prev, { text: value, ts: Date.now(), kind }]);
    return true;
  }, []);

  const graduateLiveTextSegment = useCallback((assistantId?: string | null) => {
    const currentText = assembledRef.current;
    if (!currentText.trim()) return false;
    clearPendingTextRender();
    appendStreamSegment('text', currentText);
    assembledRef.current = '';
    if (assistantId) {
      setMessages(prev => prev.map(m => (
        m.id === assistantId ? { ...m, content: '' } : m
      )));
    }
    return true;
  }, [appendStreamSegment, clearPendingTextRender]);

  const graduateLiveThinkingSegment = useCallback(() => {
    const currentThinking = thinkingContentRef.current;
    if (!currentThinking.trim()) return false;
    appendStreamSegment('thinking', currentThinking);
    setThinkingContent('');
    return true;
  }, [appendStreamSegment]);

  const buildGraduatedSegments = useCallback((segments: StreamSegment[], finalContent: string): TextSegment[] => {
    const graduatedSegments: TextSegment[] = [];
    for (const seg of segments) {
      graduatedSegments.push({ text: seg.text, position: 'before', kind: seg.kind });
    }
    if (finalContent && finalContent.trim()) {
      graduatedSegments.push({ text: finalContent, position: 'after', kind: 'text' });
    }
    return graduatedSegments;
  }, []);

  const appendThinkingChunk = useCallback((_assistantId: string | null, chunk: string) => {
    if (!chunk) return;
    setThinkingContent(prev => mergeThinkingStream(prev, chunk));
  }, []);

  const clearActiveStreamState = useCallback(() => {
    clearStreamWatchdog();
    clearPendingTextRender();
    isStreamActiveRef.current = false;
    streamTransportRef.current = null;
    currentRunIdRef.current = null;
    streamingAssistantIdRef.current = null;
    assembledRef.current = '';
    lastSegmentStartRef.current = 0;
    lastRawTextLenRef.current = 0;
    directClientRef.current?.setActiveStreamSession(null);
    setIsRunning(false);
    setStreamingPhase('idle');
    setStatusText(null);
    setThinkingContent('');
    setStreamSegments([]);
    setActiveToolName(null);
    activeStreamToolCallsRef.current = [];
    applyCompactionSnapshotState('idle');
  }, [applyCompactionSnapshotState, clearPendingTextRender, clearStreamWatchdog]);

  const applyOpenClawActiveStreamSnapshot = useCallback((snapshot: any, options?: {
    statusTextWhenNoTool?: string | null;
    source?: 'portal' | 'direct';
  }) => {
    if (!snapshot?.active) return false;
    const snapshotContent = typeof snapshot.content === 'string' && !isControlOrMaintenanceAssistantContent(snapshot.content)
      ? sanitizeAssistantContent(snapshot.content)
      : '';
    const snapshotToolCalls = normalizeToolCalls(snapshot.toolCalls, 'running') || [];
    const preservedToolCalls = activeStreamToolCallsRef.current;
    const effectiveToolCalls = snapshotToolCalls.length > 0 ? snapshotToolCalls : preservedToolCalls;
    const runningToolCall = effectiveToolCalls.find((toolCall) => toolCall.status === 'running') || effectiveToolCalls[effectiveToolCalls.length - 1];
    const toolNameCandidate = snapshot.toolName || snapshot.name || runningToolCall?.name || activeToolNameRef.current;
    const snapshotToolName = typeof toolNameCandidate === 'string' && toolNameCandidate.trim()
      ? resolveToolName(toolNameCandidate)
      : null;
    const rawStatusText = typeof snapshot.statusText === 'string' ? snapshot.statusText.trim() : '';
    const isMaintenanceStatusOnly = Boolean(rawStatusText && isControlOrMaintenanceAssistantContent(rawStatusText));
    const currentStreamText = assembledRef.current;
    const normalizedSnapshotContent = normalizeHistoryReplayContent(snapshotContent);
    const snapshotDuplicatesGraduatedText = Boolean(normalizedSnapshotContent)
      && !currentStreamText.trim()
      && streamSegmentsRef.current.some((segment) => (
        segment.kind === 'text'
        && normalizeHistoryReplayContent(segment.text) === normalizedSnapshotContent
      ));
    const effectiveSnapshotContent = snapshotDuplicatesGraduatedText ? '' : snapshotContent;
    const shouldReplaceSnapshotText = Boolean(effectiveSnapshotContent)
      && (!currentStreamText || effectiveSnapshotContent.length >= currentStreamText.length || effectiveSnapshotContent.includes(currentStreamText));
    let assistantId = streamingAssistantIdRef.current;
    const hasStatusSignal = Boolean(rawStatusText && !isMaintenanceStatusOnly);
    const hasMaintenanceSignal = isMaintenanceStatusOnly
      || snapshot.compactionPhase === 'compacting';
    const hasLiveSnapshotSignal = Boolean(effectiveSnapshotContent)
      || Boolean(snapshotToolName)
      || effectiveToolCalls.length > 0
      || hasStatusSignal;
    if (!hasLiveSnapshotSignal && hasMaintenanceSignal) {
      applyCompactionSnapshotState(snapshot.compactionPhase);
      return true;
    }
    const hasMeaningfulSnapshotSignal = hasLiveSnapshotSignal;
    const shouldHydrateLiveState = hasMeaningfulSnapshotSignal || Boolean(assistantId);
    if (!shouldHydrateLiveState) {
      return false;
    }
    const shouldMaterializeBubble = Boolean(effectiveSnapshotContent)
      || Boolean(snapshotToolName)
      || effectiveToolCalls.length > 0
      || Boolean(assistantId);

    isStreamActiveRef.current = true;
    streamTransportRef.current = options?.source || 'portal';
    if (snapshot.runId) {
      currentRunIdRef.current = String(snapshot.runId);
    }
    directClientRef.current?.setActiveStreamSession(sessionRef.current || null);
    const snapshotPhase = runningToolCall
      ? 'tool'
      : snapshot.phase === 'tool'
        ? 'tool'
        : snapshot.phase === 'streaming'
          ? 'streaming'
          : 'thinking';
    const liveStatusText = isMaintenanceStatusOnly ? '' : rawStatusText;
    const compactionStatusText = hasLiveSnapshotSignal && snapshot.compactionPhase === 'compacting'
      ? (liveStatusText || 'Compacting context…')
      : hasLiveSnapshotSignal && snapshot.compactionPhase === 'compacted'
        ? (liveStatusText || 'Context compacted')
        : '';
    const fallbackStatusText = snapshotToolName
      ? getToolStatusText(snapshotToolName, liveStatusText || compactionStatusText || null)
      : liveStatusText || options?.statusTextWhenNoTool || (snapshotPhase === 'streaming' ? 'Still responding…' : 'Still working…');
    setIsRunning(true);
    setSessionAvailability('present');
    setStreamingPhase(snapshotPhase);
    setActiveToolName(snapshotToolName || null);
    setStatusText(fallbackStatusText);
    applyCompactionSnapshotState(snapshot.compactionPhase);
    if (snapshot.provenance) setLastProvenance(String(snapshot.provenance));
    if (shouldReplaceSnapshotText) {
      mergeStreamText(effectiveSnapshotContent, { replace: true });
    }
    if (effectiveToolCalls.length > 0) {
      hasRealToolEventsRef.current = true;
      toolCounterRef.current = Math.max(toolCounterRef.current, effectiveToolCalls.length);
      activeStreamToolCallsRef.current = effectiveToolCalls;
    }
    if (shouldMaterializeBubble) {
      assistantId = ensureStreamingAssistantBubble({
        idPrefix: 'stream-resume',
        content: shouldReplaceSnapshotText ? effectiveSnapshotContent : (snapshotDuplicatesGraduatedText ? '' : (currentStreamText || '')),
      }).assistantId;
    }
    if (assistantId) {
      setMessages(prev => prev.map(m => {
        if (m.id !== assistantId) return m;
        return {
          ...m,
          content: shouldReplaceSnapshotText ? effectiveSnapshotContent : (snapshotDuplicatesGraduatedText ? '' : m.content),
          toolCalls: effectiveToolCalls.length > 0 ? effectiveToolCalls : (m.toolCalls || []),
          model: snapshot.model ? normalizeProviderModel(providerRef.current, String(snapshot.model)) : m.model,
        };
      }));
    }
    resetStreamWatchdog();
    return true;
  }, [applyCompactionSnapshotState, ensureStreamingAssistantBubble, mergeStreamText, resetStreamWatchdog]);

  const hydrateActiveStream = useCallback(async (
    sessionKey: string,
    prov?: string,
    snapshot?: any,
    options?: { clearIfInactive?: boolean; reconnect?: boolean; refreshIfActive?: boolean },
  ) => {
    if (!sessionKey || prov !== 'OPENCLAW') return false;
    const expectedSession = sessionKey;
    const expectedProvider = prov;
    const expectedHistoryGen = historyGenRef.current;
    try {
      let data = snapshot;
      if (!data) {
        const params: Record<string, string> = { session: sessionKey };
        if (prov) params.provider = prov;
        const response = await client.get('/gateway/stream-status', { params, _silent: true } as any);
        data = response.data;
      }
      if (
        sessionRef.current !== expectedSession
        || providerRef.current !== expectedProvider
        || historyGenRef.current !== expectedHistoryGen
      ) {
        debugLog('[ChatState] Ignoring stale active-stream snapshot', {
          expectedSession,
          currentSession: sessionRef.current,
          expectedProvider,
          currentProvider: providerRef.current,
          expectedHistoryGen,
          currentHistoryGen: historyGenRef.current,
        });
        return false;
      }
      const manager = wsManagerRef.current;
      if (!data?.active) {
        if (options?.clearIfInactive && isStreamActiveRef.current) {
          debugLog('[ChatState] Active-stream snapshot is idle — clearing stale stream UI');
          clearActiveStreamState();
        }
        return false;
      }
      if (data.runId && !currentRunIdRef.current) {
        currentRunIdRef.current = String(data.runId);
      }
      directClientRef.current?.setActiveStreamSession(sessionKey);
      const shouldRefreshLiveState = options?.refreshIfActive || !isStreamActiveRef.current || !streamingAssistantIdRef.current;
      if (!shouldRefreshLiveState) {
        resetStreamWatchdog();
        return true;
      }
      debugLog('[ChatState] Active stream found during history load — hydrating');
      setDirectGatewayDemanded(true);
      applyOpenClawActiveStreamSnapshot(data, {
        statusTextWhenNoTool: 'Reconnecting to stream…',
        source: useDirectGateway ? 'direct' : 'portal',
      });
      if (options?.reconnect !== false && manager?.isConnected()) {
        manager.send({ type: 'reconnect', session: sessionKey, provider: prov });
      }
      return true;
    } catch {
      return false;
    }
  }, [applyOpenClawActiveStreamSnapshot, clearActiveStreamState, resetStreamWatchdog, useDirectGateway]);

  // History loader
  const loadHistoryInternal = useCallback(async (sessionKey: string, prov?: string, options?: { force?: boolean; refreshActiveSnapshot?: boolean; preserveLocalMessages?: boolean }): Promise<boolean> => {
    if (!sessionKey || (isStreamActiveRef.current && !options?.force)) return false;
    // Snapshot the current generation — if it changes while we await, discard results.
    const myGen = ++historyGenRef.current;
    setIsLoadingHistory(true);

    let historyActiveStream: any = undefined;
    const loadViaHttp = async (): Promise<ChatMessage[]> => {
      const params: Record<string, string> = { session: sessionKey, enhanced: '1' };
      if (prov) params.provider = prov;
      const { data } = await client.get('/gateway/history', { params });
      historyActiveStream = data?.activeStream;
      return data.messages ? data.messages.map(parseHistoryMessage).filter(Boolean) as ChatMessage[] : [];
    };

    // Load via direct gateway client for OPENCLAW
    const loadViaDirect = async (): Promise<ChatMessage[]> => {
      const directClient = directClientRef.current;
      if (!directClient?.isConnected) {
        throw new Error('Direct gateway not connected');
      }
      const resolvedSessionKey = resolveOpenClawSessionKey(sessionKey);
      if (resolvedSessionKey && resolvedSessionKey !== sessionRef.current) {
        sessionRef.current = resolvedSessionKey;
        setSessionRaw(resolvedSessionKey);
        persistStoredSession(prov || providerRef.current, resolvedSessionKey);
      }
      const targetSessionKey = resolvedSessionKey || sessionKey;
      const [result, streamStatusResult] = await Promise.all([
        directClient.loadHistory(targetSessionKey),
        client.get('/gateway/stream-status', {
          params: { session: targetSessionKey, provider: prov },
          _silent: true,
        } as any).catch(() => null),
      ]);
      historyActiveStream = streamStatusResult?.data?.active ? streamStatusResult.data : { active: false };
      return result.messages.map(mapGatewayMessage).filter(Boolean) as ChatMessage[];
    };

    try {
      let loaded: ChatMessage[];

      // For OPENCLAW, prefer gateway-native history whenever the direct gateway is connected.
      // That keeps transcript rendering aligned with upstream semantics, preserves thinking
      // blocks, and avoids the portal's JSONL fallback from reshaping live turns.
      const directClient = directClientRef.current;
      const useDirectHistory = useDirectGateway && prov === 'OPENCLAW' && directClient?.isConnected;
      debugLog('loadHistoryInternal', {
        useDirectGateway,
        provider: prov,
        directConnected: Boolean(directClient?.isConnected),
        sessionKey,
        force: Boolean(options?.force),
      });
      if (useDirectHistory) {
        try {
          debugLog('loading history via direct gateway');
          loaded = await loadViaDirect();
          if (loaded.length === 0 && !(historyActiveStream?.active)) {
            debugLog('direct gateway history was empty; falling back to HTTP history');
            loaded = await loadViaHttp();
          }
        } catch (err) {
          console.warn('[ChatState] Direct gateway history failed; falling back to HTTP', err);
          loaded = await loadViaHttp();
        }
      } else if (prov === 'OPENCLAW') {
        loaded = await loadViaHttp();
      } else {
        // Non-OpenClaw providers can still use the existing portal WS fast path.
        const manager = wsManagerRef.current;
        if (manager && manager.isConnected()) {
          try {
            loaded = await new Promise<ChatMessage[]>((resolve, reject) => {
              const requestId = 'hist-' + Date.now() + '-' + Math.random().toString(36).slice(2, 8);
              const handler = (data: any) => {
                if (data.type === 'history' && data.requestId === requestId) {
                  clearTimeout(timeout);
                  manager.removeHandler(handler);
                  resolve((data.messages || []).map(parseHistoryMessage).filter(Boolean) as ChatMessage[]);
                } else if (data.type === 'error' && data.requestId === requestId) {
                  clearTimeout(timeout);
                  manager.removeHandler(handler);
                  reject(new Error(data.content || 'History request failed'));
                }
              };
              const timeout = setTimeout(() => {
                manager.removeHandler(handler);
                reject(new Error('History timeout'));
              }, 1500);
              manager.addHandler(handler);
              const sent = manager.send({ type: 'history', session: sessionKey, provider: prov, requestId });
              if (!sent) {
                clearTimeout(timeout);
                manager.removeHandler(handler);
                reject(new Error('History send failed'));
              }
            });
          } catch (err) {
            console.warn('[ChatState] WS history failed; falling back to HTTP', err);
            loaded = await loadViaHttp();
          }
        } else {
          loaded = await loadViaHttp();
        }
      }

      // Only apply if still the current generation
      if (historyGenRef.current === myGen) {
        if (prov === 'OPENCLAW' && loaded.length > 0) {
          setSessionAvailability('present');
        }
        const normalizedLoadedHistory = mergeToolResultsIntoToolCalls(dedupeHistoryMessages(loaded));
        const reconciledHistory = options?.preserveLocalMessages === false
          ? normalizedLoadedHistory
          : mergeLoadedHistoryWithLocalMessages(
              normalizedLoadedHistory,
              messagesRef.current,
              {
                activeAssistantId: streamingAssistantIdRef.current,
                preserveActiveAssistant: isStreamActiveRef.current,
              },
            );
        setMessages(reconciledHistory);
        if (prov === 'OPENCLAW') {
          return await hydrateActiveStream(sessionKey, prov, historyActiveStream, {
            clearIfInactive: Boolean(options?.force),
            reconnect: !useDirectGateway,
            refreshIfActive: Boolean(options?.refreshActiveSnapshot) || Boolean(options?.force),
          });
        }
      }
    } catch (err) {
      console.error('[ChatState] History load failed:', err);
      if (historyGenRef.current === myGen) setMessages([]);
      return false;
    } finally {
      if (historyGenRef.current === myGen) {
        setIsLoadingHistory(false);
        setIsSwitchingSession(false);
        setStartupReady(true);
      }
    }
    return false;
  }, [hydrateActiveStream, resolveOpenClawSessionKey, useDirectGateway]);

  useEffect(() => {
    loadHistoryInternalRef.current = loadHistoryInternal;
  }, [loadHistoryInternal]);

  const loadHistory = useCallback(async (sessionKey: string, prov?: string) => {
    await loadHistoryInternal(sessionKey, prov);
  }, [loadHistoryInternal]);

  // Explicitly select a session from the sidebar — resets any stale stream state
  // and force-loads history, bypassing the isStreamActive guard.
  const selectSession = useCallback(async (sessionKey: string) => {
    if (!sessionKey) return;
    historyGenRef.current++;
    setIsSwitchingSession(true);
    setIsLoadingHistory(true);
    clearActiveStreamState();
    setLastProvenance(null);
    compactionPhaseRef.current = 'idle';
    // Clear the previous thread immediately. Otherwise a pending user message from
    // the old session can be merged onto the newly selected session while history loads.
    messagesRef.current = [];
    messageQueueRef.current = [];
    setMessages([]);
    setMessageQueue([]);
    setSessionRaw(sessionKey);
    persistStoredSession(providerRef.current, sessionKey);
    // Force-load history bypassing the isStreamActive guard, but do not preserve
    // transient local messages from the previously selected session.
    await loadHistoryInternal(sessionKey, providerRef.current, { force: true, preserveLocalMessages: false });
  }, [clearActiveStreamState, loadHistoryInternal]);

  // Refresh: reload history + check for active stream and resubscribe
  const refreshChat = useCallback(async () => {
    const currentSession = sessionRef.current;
    const currentProvider = providerRef.current;
    if (!currentSession) return;
    debugLog('[ChatState] Manual refresh — reloading history');
    const usingDirectGateway = useDirectGateway && currentProvider === 'OPENCLAW';
    const directClient = directClientRef.current;
    if (usingDirectGateway && isStreamActiveRef.current && !directClient?.isConnected) {
      directClient?.connect();
      setStatusText('Reconnecting to stream…');
    }
    try {
      await loadHistoryInternal(currentSession, currentProvider, { force: true, refreshActiveSnapshot: true });
    } catch (err) {
      console.error('[ChatState] Refresh error:', err);
      try { await loadHistoryInternal(currentSession, currentProvider); } catch {}
    }
  }, [loadHistoryInternal, useDirectGateway]);

  // Load history when session/provider changes.
  // We intentionally do NOT call clearMessages here — the caller (handleSelectSession,
  // handleSelectAgent, etc.) already clears before setting the new session, so the
  // messages array is already empty by the time this effect fires.
  useEffect(() => {
    if (session && !isStreamActiveRef.current) {
      setStartupReady(false);
      setIsLoadingHistory(true); // show spinner immediately, before async fetch
      void loadHistoryInternal(session, provider);
    }
  }, [session, provider, loadHistoryInternal]);

  useEffect(() => {
    if (typeof window === 'undefined') return;

    const reconcileForegroundState = () => {
      const currentSession = sessionRef.current;
      const currentProvider = providerRef.current;
      if (currentProvider !== 'OPENCLAW' || !currentSession) return;
      const now = Date.now();
      if (now - lastForegroundReconcileAtRef.current < 1500) return;
      lastForegroundReconcileAtRef.current = now;
      void loadHistoryInternalRef.current?.(currentSession, currentProvider, { force: true, refreshActiveSnapshot: true });
    };

    const onVisibilityChange = () => {
      if (document.visibilityState === 'visible') {
        reconcileForegroundState();
      }
    };

    window.addEventListener('focus', reconcileForegroundState);
    document.addEventListener('visibilitychange', onVisibilityChange);
    return () => {
      window.removeEventListener('focus', reconcileForegroundState);
      document.removeEventListener('visibilitychange', onVisibilityChange);
    };
  }, []);

  // WS event handler — processes events even when chat page is unmounted
  const handleWsEvent = useCallback((data: any) => {
    // Session events with a new sessionId should update our ref IMMEDIATELY,
    // before the React state update queues. This prevents subsequent events
    // (that arrive before the useEffect fires) from being dropped.
    if (data?.type === 'session' && data.sessionId) {
      sessionRef.current = data.sessionId;
    }

    const incomingPortalRunId = normalizeRunId(data?.runId);
    if (incomingPortalRunId && isRunIdSuppressed(incomingPortalRunId)) {
      if (data?.type === 'done' || data?.type === 'error' || data?.type === 'stream_ended') {
        clearSuppressedRunId(incomingPortalRunId);
      }
      debugLog('[ChatState] Ignoring suppressed portal run event', {
        type: data?.type,
        runId: incomingPortalRunId,
      });
      return;
    }

    // When the direct gateway client is connected, it handles all streaming events
    // (chat, agent) directly. The Socket.IO/WS path should NOT also process them,
    // otherwise the browser receives the same text twice causing stutter/cascade.
    if (directClientRef.current?.isConnected && streamTransportRef.current === 'direct') {
      const isPortalTerminalEvent = data?.type === 'done' || data?.type === 'stream_ended';
      if (isPortalTerminalEvent && !isStreamActiveRef.current && !streamingAssistantIdRef.current) {
        if (!incomingPortalRunId || lastTerminalRunIdRef.current === incomingPortalRunId) {
          return;
        }
      }
      // Still allow non-streaming events (session, exec_approval, connected, keepalive).
      // Maintenance/lifecycle status is intentionally allowed through even in
      // direct mode because it may arrive on the portal-global WS path while the
      // direct stream is busy. That is the visible clue for lag/compaction.
      const directHandledTypes = ['text', 'thinking', 'tool_start', 'tool_end', 'tool_used', 'status', 'segment_break', 'stream_resume', 'run_resumed'];
      const isMaintenancePortalStatus = data?.type === 'status' && resolveMaintenanceRailStatus(data).isMaintenanceStatus;
      if (directHandledTypes.includes(data?.type) && !isMaintenancePortalStatus) {
        return; // Direct gateway already handling this
      }
    }
    const portalStreamTypes = ['text', 'thinking', 'tool_start', 'tool_end', 'tool_used', 'status', 'segment_break', 'done', 'error', 'stream_resume', 'stream_ended', 'run_resumed'];
    if (incomingPortalRunId && portalStreamTypes.includes(data?.type)) {
      if (!currentRunIdRef.current || !isStreamActiveRef.current) {
        currentRunIdRef.current = incomingPortalRunId;
      } else if (currentRunIdRef.current !== incomingPortalRunId) {
        debugLog('[ChatState] Ignoring portal event for stale run', {
          type: data?.type,
          expectedRunId: currentRunIdRef.current,
          incomingRunId: incomingPortalRunId,
        });
        return;
      }
    }

    // Filter events by session key. Allow events that match our current session,
    // OR events that don't have a sessionKey (global events like connected/keepalive).
    // Also allow compaction events through regardless of session key — they're important
    // system notifications that should display even if the sessionKey hasn't resolved yet.
    const alwaysPassthroughTypes = ['connected', 'keepalive'];
    const resolvedPortalSession = typeof data?.sessionKey === 'string' ? resolveOpenClawSessionKey(data.sessionKey) : '';
    const resolvedCurrentSession = resolveOpenClawSessionKey(sessionRef.current);
    if (resolvedPortalSession && resolvedCurrentSession && resolvedPortalSession !== resolvedCurrentSession && !alwaysPassthroughTypes.includes(data.type)) {
      return;
    }
    if (data?.type && ['text', 'thinking', 'tool_start', 'tool_end', 'tool_used', 'status', 'segment_break', 'done', 'stream_resume', 'stream_ended', 'run_resumed', 'compaction_start', 'compaction_end'].includes(data.type)) {
      setWsConnected(true);
    }
    // Temp debug: log tool-related events to diagnose missing tool cards
    if (data.type && (data.type.startsWith('tool') || data.type === 'text' || data.type === 'done')) {
      debugLog('ws event', {
        type: data.type,
        assistantId: streamingAssistantIdRef.current || null,
        toolName: data.toolName || null,
        contentLength: typeof data.content === 'string' ? data.content.length : 0,
      });
    }
    // Only process stream events if we have an active assistant message.
    // Some event types are allowed without a bubble so we can wait for visible
    // content before materializing a resumed turn.
    const passthrough = ['session', 'exec_approval', 'exec_approval_resolved', 'connected', 'keepalive', 'compaction_start', 'compaction_end', 'stream_resume', 'stream_ended', 'run_resumed'];
    const autoCreateBubbleTypes = ['text', 'thinking', 'tool_start', 'tool_end', 'tool_used', 'toolCall', 'toolResult', 'segment_break'];
    const waitForVisibleStreamTypes = ['status', 'thinking', 'done', 'error'];
    if (!streamingAssistantIdRef.current && data.type === 'text' && typeof data.content === 'string' && isControlOrMaintenanceAssistantContent(data.content)) {
      return;
    }
    if (!streamingAssistantIdRef.current && !passthrough.includes(data.type)) {
      if (autoCreateBubbleTypes.includes(data.type)) {
        ensureStreamingAssistantBubble({ idPrefix: 'resume', content: '', resetIfCreated: true });
        isStreamActiveRef.current = true;
        if (!streamTransportRef.current) streamTransportRef.current = 'portal';
        directClientRef.current?.setActiveStreamSession(sessionRef.current || null);
        setIsRunning(true);
      } else if (!waitForVisibleStreamTypes.includes(data.type)) {
        console.warn(`[ChatState] DROPPED event: type=${data.type} (no assistantId)`);
        return;
      }
    }
    // Read assistantId AFTER potential bubble creation so it picks up the new ref
    const assistantId = streamingAssistantIdRef.current;
    if (assistantId || isStreamActiveRef.current) {
      resetStreamWatchdog();
    }

    switch (data.type) {
      case 'session': {
        if (data.sessionId) {
          setSessionAvailability('present');
          setSessionRaw(data.sessionId);
          persistStoredSession(providerRef.current, data.sessionId);
        }
        if (data.provenance) setLastProvenance(data.provenance);
        if (data.model) {
          setMessages(prev => prev.map(m => (
            m.id === streamingAssistantIdRef.current
              ? { ...m, model: normalizeProviderModel(providerRef.current, String(data.model)) }
              : m
          )));
        }
        break;
      }
      case 'status': {
        const nextStatusText = typeof data.content === 'string' ? data.content : null;
        const normalizedStatus = typeof nextStatusText === 'string' ? normalizeLifecycleMarker(nextStatusText) : '';
        const isMaintenanceStatus = data.maintenanceKind === 'maintenance'
          || LIFECYCLE_FLUSH_PREPARING_RE.test(normalizedStatus)
          || LIFECYCLE_FLUSH_RUNNING_RE.test(normalizedStatus)
          || LIFECYCLE_FLUSH_DONE_RE.test(normalizedStatus);
        if (isMaintenanceStatus) {
          applyCompactionState({
            phase: LIFECYCLE_FLUSH_DONE_RE.test(normalizedStatus) ? 'end' : 'start',
            content: nextStatusText,
            completed: false,
            maintenanceKind: 'maintenance',
          });
        }
        if (!assistantId && !isStreamActiveRef.current) break;
        // OpenClaw emits dedicated `thinking` events; avoid mixing generic
        // status text into the thought bubble (live-only divergence vs refresh).
        if (providerRef.current !== 'OPENCLAW') {
          appendThinkingChunk(
            assistantId,
            extractThinkingChunk('status', data.content, assembledRef.current.length > 0),
          );
        }
        if (!assembledRef.current || getRunningToolName()) {
          setLiveRunPhase('thinking', nextStatusText);
        }
        break;
      }
      case 'thinking': {
        if (!assistantId && !isStreamActiveRef.current) break;
        appendThinkingChunk(assistantId, extractThinkingChunk('thinking', data.content, assembledRef.current.length > 0));
        if (!assembledRef.current || getRunningToolName()) {
          setLiveRunPhase('thinking', typeof data.content === 'string' ? data.content : null);
        }
        break;
      }
      case 'compaction_start': {
        applyCompactionState({
          phase: 'start',
          content: typeof data.content === 'string' ? data.content : null,
          maintenanceKind: data.maintenanceKind === 'maintenance' ? 'maintenance' : 'compaction',
        });
        break;
      }
      case 'compaction_end': {
        applyCompactionState({
          phase: 'end',
          content: typeof data.content === 'string' ? data.content : null,
          completed: data.completed !== false,
          maintenanceKind: data.maintenanceKind === 'maintenance' ? 'maintenance' : 'compaction',
        });
        scheduleSessionTelemetryRefresh(250);
        break;
      }
      case 'tool_start': {
        hasRealToolEventsRef.current = true;
        graduateLiveThinkingSegment();
        // Graduate current streaming text into a finalized segment before tool call.
        // This preserves the agent's thoughts as visible bubbles instead of wiping them.
        if (assembledRef.current && assembledRef.current.trim().length > 0) {
          graduateLiveTextSegment(assistantId);
        }
        const toolName = resolveToolName(data.toolName, data.name, data.content, 'tool');
        setStatusText(getToolStatusText(toolName, data.content));
        setStreamingPhase('tool');
        setActiveToolName(toolName);
        const toolId = 'tool-' + (++toolCounterRef.current);
        const toolArgs = data.toolArgs || undefined;
        setMessages(prev => prev.map(m => {
          if (m.id !== assistantId) return m;
          const nextToolCalls = [...(m.toolCalls || []), { id: toolId, name: toolName, arguments: toolArgs, startedAt: Date.now(), status: 'running' as const }];
          activeStreamToolCallsRef.current = nextToolCalls;
          return { ...m, toolCalls: nextToolCalls };
        }));
        break;
      }
      case 'tool_end': {
        const toolResult = data.toolResult || data.content || 'Completed';
        let nextRunningToolName: string | null = null;
        setMessages(prev => prev.map(m => {
          if (m.id !== assistantId) return m;
          const calls = [...(m.toolCalls || [])];
          for (let i = calls.length - 1; i >= 0; i--) {
            if (calls[i].status === 'running') {
              calls[i] = { ...calls[i], endedAt: Date.now(), result: toolResult, status: 'done' };
              break;
            }
          }
          const nextRunningTool = getLastRunningToolCall(calls);
          nextRunningToolName = nextRunningTool ? resolveToolName(nextRunningTool.name) : null;
          activeStreamToolCallsRef.current = calls;
          return { ...m, toolCalls: calls };
        }));
        if (nextRunningToolName) {
          setStreamingPhase('tool');
          setActiveToolName(nextRunningToolName);
          setStatusText(getToolStatusText(nextRunningToolName));
        } else {
          setStreamingPhase(assembledRef.current ? 'streaming' : 'thinking');
          setActiveToolName(null);
          setStatusText(null);
        }
        break;
      }
      case 'tool_used': {
        if (hasRealToolEventsRef.current) break;
        const tn = resolveToolName(data.toolName, data.name, data.content, 'tool');
        graduateLiveThinkingSegment();
        if (assembledRef.current && assembledRef.current.trim().length > 0) {
          graduateLiveTextSegment(assistantId);
        }
        setMessages(prev => {
          const exists = prev.some(m =>
            m.role === 'assistant' && (m.toolCalls || []).some(
              tc => tc.status === 'done' && tc.name === tn && tc.endedAt && (Date.now() - tc.endedAt < 5000)
            )
          );
          if (exists) return prev;
          const tid = 'tool-' + (++toolCounterRef.current);
          const now = Date.now();
          return prev.map(m => {
            if (m.id !== assistantId) return m;
            const nextToolCalls = [...(m.toolCalls || []), { id: tid, name: tn, startedAt: now - 1000, endedAt: now, status: 'done' as const }];
            activeStreamToolCallsRef.current = nextToolCalls;
            return { ...m, toolCalls: nextToolCalls };
          });
        });
        break;
      }
      case 'toolCall': {
        const tid = 'tool-' + (++toolCounterRef.current);
        const toolName = resolveToolName(data.toolName, data.name, 'tool');
        graduateLiveThinkingSegment();
        if (assembledRef.current && assembledRef.current.trim().length > 0) {
          graduateLiveTextSegment(assistantId);
        }
        setStreamingPhase('tool');
        setActiveToolName(toolName);
        setStatusText(getToolStatusText(toolName));
        setMessages(prev => prev.map(m => {
          if (m.id !== assistantId) return m;
          const nextToolCalls = [...(m.toolCalls || []), { id: data.id || tid, name: toolName, arguments: data.arguments, startedAt: Date.now(), status: 'running' as const }];
          activeStreamToolCallsRef.current = nextToolCalls;
          return { ...m, toolCalls: nextToolCalls };
        }));
        break;
      }
      case 'toolResult': {
        const resolvedToolName = resolveToolName(data.toolName, data.name, data.content, 'tool');
        let nextRunningToolName: string | null = null;
        setMessages(prev => prev.map(m => {
          if (m.id !== assistantId) return m;
          const calls = [...(m.toolCalls || [])];
          const idx = calls.findIndex(c => c.id === data.toolCallId || c.name === resolvedToolName);
          if (idx >= 0) calls[idx] = { ...calls[idx], endedAt: Date.now(), result: data.content, status: 'done' };
          const nextRunningTool = getLastRunningToolCall(calls);
          nextRunningToolName = nextRunningTool ? resolveToolName(nextRunningTool.name) : null;
          activeStreamToolCallsRef.current = calls;
          return { ...m, toolCalls: calls };
        }));
        if (nextRunningToolName) {
          setStreamingPhase('tool');
          setActiveToolName(nextRunningToolName);
          setStatusText(getToolStatusText(nextRunningToolName));
        } else {
          setStreamingPhase(assembledRef.current ? 'streaming' : 'thinking');
          setActiveToolName(null);
          setStatusText(null);
        }
        break;
      }
      case 'segment_break': {
        // Don't create a new bubble — keep all text in a single streaming message.
        // Just acknowledge that a new segment started (for tool call boundaries).
        break;
      }
      case 'text': {
        const rawChunk = typeof data.content === 'string' ? data.content : '';
        if (rawChunk && isControlOrMaintenanceAssistantContent(rawChunk)) {
          break;
        }
        const safeChunk = typeof data.content === 'string'
          ? (data.replace === true ? sanitizeAssistantContent(data.content) : sanitizeAssistantChunk(data.content))
          : data.content;
        graduateLiveThinkingSegment();
        const nextText = mergeStreamText(safeChunk, { replace: data.replace === true });
        setStatusText(null);
        setStreamingPhase('streaming');
        setActiveToolName(null);
        // Throttle UI updates to reduce re-renders during fast streaming.
        // Text is accumulated synchronously in assembledRef, but React state
        // updates are batched to TEXT_THROTTLE_MS intervals (default 50ms = 20fps).
        schedulePendingTextRender(nextText);
        break;
      }
      case 'done': {
        clearStreamWatchdog();
        clearPendingTextRender();
        lastTerminalRunIdRef.current = incomingPortalRunId || currentRunIdRef.current;

        const rawFinal = typeof data.content === 'string' ? data.content : '';
        const hasVisibleFinal = rawFinal.length > 0 && !isControlOrMaintenanceAssistantContent(rawFinal);
        const finalContent = hasVisibleFinal ? sanitizeAssistantContent(rawFinal) : assembledRef.current;
        assembledRef.current = finalContent;
        const prov = data.provenance || null;
        const model = normalizeProviderModel(providerRef.current, typeof data?.metadata?.model === 'string' ? data.metadata.model : (typeof data?.model === 'string' ? data.model : ''));
        const hadToolEvents = hasRealToolEventsRef.current;
        const currentStreamSegs = [...streamSegmentsRef.current];
        const finalStreamSegs = thinkingContentRef.current.trim()
          ? [...currentStreamSegs, { text: thinkingContentRef.current, ts: Date.now(), kind: 'thinking' as const }]
          : currentStreamSegs;
        const shouldHideTurn = !finalContent.trim() && finalStreamSegs.length === 0 && !hadToolEvents;
        let cid = streamingAssistantIdRef.current;
        if (!cid && !shouldHideTurn) {
          cid = ensureStreamingAssistantBubble({ idPrefix: 'resume-done', content: '', resetIfCreated: false }).assistantId;
        }

        setStatusText(null);
        setStreamingPhase('idle');
        setThinkingContent('');
        setStreamSegments([]);
        setActiveToolName(null);
        setLastProvenance(prov);
        setIsRunning(false);
        if (compactionPhaseRef.current === 'compacting') {
          compactionPhaseRef.current = 'idle';
          setCompactionPhase('idle');
          if (compactionTimerRef.current) { clearTimeout(compactionTimerRef.current); compactionTimerRef.current = null; }
        }

        isStreamActiveRef.current = false;
        streamTransportRef.current = null;
        streamingAssistantIdRef.current = null;
        activeStreamToolCallsRef.current = [];
        currentRunIdRef.current = null;
        directClientRef.current?.setActiveStreamSession(null);
        assembledRef.current = '';
        lastSegmentStartRef.current = 0;
        lastRawTextLenRef.current = 0;

        const graduatedSegments = finalStreamSegs.length > 0 || (cid && hadToolEvents)
          ? buildGraduatedSegments(finalStreamSegs, finalContent)
          : [];

        if (cid) {
          if (shouldHideTurn) {
            setMessages(prev => prev.filter(m => m.id !== cid));
          } else {
            setMessages(prev => prev.map(m => {
              if (m.id !== cid) return m;
              const update: Partial<ChatMessage> = { content: finalContent, provenance: prov || undefined, model: model || m.model };
              if (graduatedSegments.length > 0) {
                update.segments = graduatedSegments;
              }
              return { ...m, ...update };
            }));
          }
        }
        scheduleSessionTelemetryRefresh(400);
        if (providerRef.current === 'OPENCLAW' && (hadToolEvents || currentStreamSegs.length > 0 || !hasVisibleFinal)) {
          schedulePostTurnHistorySync();
        }
        break;
      }
      case 'error': {
        clearStreamWatchdog();
        clearPendingTextRender();
        lastTerminalRunIdRef.current = incomingPortalRunId || currentRunIdRef.current;
        if (compactionTimerRef.current) {
          clearTimeout(compactionTimerRef.current);
          compactionTimerRef.current = null;
        }
        if (assistantId) {
          setMessages(prev => prev.map(m =>
            m.id === assistantId ? { ...m, content: '⚠️ ' + normalizeAgentError(data.content, 'Unknown error') } : m
          ));
        }
        setStatusText(null);
        setStreamingPhase('idle');
        setActiveToolName(null);
        activeStreamToolCallsRef.current = [];
        compactionPhaseRef.current = 'idle';
        setCompactionPhase('idle');
        setIsRunning(false);
        currentRunIdRef.current = null;
        isStreamActiveRef.current = false;
        streamTransportRef.current = null;
        streamingAssistantIdRef.current = null;
        directClientRef.current?.setActiveStreamSession(null);
        assembledRef.current = '';
        break;
      }
      case 'exec_approval': {
        const approval = data.approval as ExecApprovalRequest;
        if (approval?.id) {
          setPendingApprovals((prev) => upsertExecApproval(prev, approval));
          setStatusText('\u23f3 Waiting for command approval\u2026');
        }
        break;
      }
      case 'exec_approval_resolved': {
        const resolved = data.resolved;
        if (resolved?.id) setPendingApprovals((prev) => removeExecApproval(prev, resolved.id));
        break;
      }
      case 'stream_resume': {
        applyOpenClawActiveStreamSnapshot({ ...data, active: true }, { statusTextWhenNoTool: 'Reconnecting to stream…', source: 'portal' });
        break;
      }
      case 'run_resumed': {
        debugLog(streamingAssistantIdRef.current ? 'run_resumed agent continuing after sub-agent' : 'run_resumed resumed without visible bubble');
        isStreamActiveRef.current = true;
        if (!streamTransportRef.current) streamTransportRef.current = 'portal';
        setIsRunning(true);
        setLiveRunPhase('thinking', null);
        resetStreamWatchdog();
        break;
      }
      case 'stream_ended': {
        clearStreamWatchdog();
        clearPendingTextRender();
        lastTerminalRunIdRef.current = incomingPortalRunId || currentRunIdRef.current;
        setStatusText(null);
        setStreamingPhase('idle');
        setThinkingContent('');
        setActiveToolName(null);
        setIsRunning(false);
        isStreamActiveRef.current = false;
        streamTransportRef.current = null;
        streamingAssistantIdRef.current = null;
        activeStreamToolCallsRef.current = [];
        currentRunIdRef.current = null;
        directClientRef.current?.setActiveStreamSession(null);
        clearPostTurnHistorySync();
        if (providerRef.current === 'OPENCLAW') {
          schedulePostTurnHistorySync(900);
        }
        break;
      }
      case 'connected':
      case 'keepalive':
        break;
    }
  }, [applyOpenClawActiveStreamSnapshot, clearSuppressedRunId, ensureStreamingAssistantBubble, isRunIdSuppressed, normalizeAgentError, resetStreamWatchdog, clearStreamWatchdog, appendThinkingChunk, applyCompactionState, buildGraduatedSegments, getRunningToolName, graduateLiveTextSegment, graduateLiveThinkingSegment, mergeStreamText, scheduleSessionTelemetryRefresh, setLiveRunPhase, upsertStreamingAssistant]);

  // Keep handleWsEvent in a ref so the WS handler always calls the latest version
  const handleWsEventRef = useRef(handleWsEvent);
  useEffect(() => { handleWsEventRef.current = handleWsEvent; }, [handleWsEvent]);

  /**
   * Handle events from the direct gateway client.
   * Maps native gateway events to our internal event format.
   */
  const handleDirectGatewayEvent = useCallback((evt: GatewayEvent) => {
    const isStreamEvent = evt.event === 'chat' || evt.event === 'agent';
    const payload = evt.payload;
    const currentSession = resolveOpenClawSessionKey(sessionRef.current);
    const payloadSession = typeof payload?.sessionKey === 'string'
      ? resolveOpenClawSessionKey(payload.sessionKey)
      : '';
    const incomingRunId = normalizeRunId(payload?.runId);
    const expectedRunId = currentRunIdRef.current;
    const isDirectTerminalEvent = evt.event === 'chat' && (payload?.state === 'final' || payload?.state === 'aborted' || payload?.state === 'error');
    if (incomingRunId && isRunIdSuppressed(incomingRunId)) {
      if (isDirectTerminalEvent) {
        clearSuppressedRunId(incomingRunId);
      }
      debugLog('[ChatState] Ignoring suppressed direct run event', {
        event: evt.event,
        state: payload?.state,
        runId: incomingRunId,
      });
      return;
    }
    const effectiveTerminalRunId = incomingRunId || expectedRunId;

    if (isDirectTerminalEvent && !isStreamActiveRef.current && !streamingAssistantIdRef.current) {
      if (!effectiveTerminalRunId || lastTerminalRunIdRef.current === effectiveTerminalRunId) {
        debugLog('[ChatState] Ignoring duplicate direct terminal event', {
          event: evt.event,
          state: payload?.state,
          runId: effectiveTerminalRunId,
        });
        return;
      }
    }

    if (isStreamEvent) {
      setWsConnected(true);
      if (payloadSession && currentSession && payloadSession !== currentSession) {
        debugLog('[ChatState] Ignoring direct event for different session', {
          event: evt.event,
          payloadSession,
          currentSession,
        });
        return;
      }
      if (incomingRunId) {
        if (!expectedRunId || !isStreamActiveRef.current) {
          currentRunIdRef.current = incomingRunId;
          directClientRef.current?.setActiveStreamSession(payloadSession || currentSession || null);
        } else if (expectedRunId !== incomingRunId) {
          debugLog('[ChatState] Ignoring direct event for stale run', {
            event: evt.event,
            expectedRunId,
            incomingRunId,
          });
          return;
        }
      }
    }
    if (streamTransportRef.current === 'portal' && isStreamActiveRef.current && isStreamEvent) {
      return;
    }

    if (evt.event === 'session.message') {
      setWsConnected(true);
      if (payloadSession && currentSession && payloadSession !== currentSession) {
        debugLog('[ChatState] Ignoring direct session.message for different session', {
          payloadSession,
          currentSession,
        });
        return;
      }
      if (providerRef.current === 'OPENCLAW') {
        scheduleSessionTelemetryRefresh(200);
        if (isStreamActiveRef.current || currentRunIdRef.current) {
          schedulePostTurnHistorySync(900, 2500);
        } else {
          void loadHistoryInternalRef.current?.(currentSession || sessionRef.current || 'main', 'OPENCLAW', {
            force: true,
            refreshActiveSnapshot: false,
          });
        }
      }
      return;
    }

    if (evt.event === 'sessions.changed') {
      const changedSession = typeof payload?.sessionKey === 'string'
        ? resolveOpenClawSessionKey(payload.sessionKey)
        : '';
      if (!changedSession || changedSession === currentSession) {
        scheduleSessionTelemetryRefresh(250);
      }
      return;
    }

    if (evt.event === 'chat') {
      const state = payload.state;
      const rawMessageContent = payload.message?.content;
      const contentBlocks = Array.isArray(rawMessageContent)
        ? rawMessageContent
        : [];
      const visibleMessageText = (
        typeof rawMessageContent === 'string'
          ? rawMessageContent
          : contentBlocks.length
            ? contentBlocks
                .map((b: any) => (
                  typeof b?.text === 'string'
                    ? b.text
                    : (typeof b?.content === 'string' ? b.content : '')
                ))
                .join('')
            : (typeof (payload as any).content === 'string' ? (payload as any).content : '')
      ).trim();
      const hasVisibleNonMaintenanceText = Boolean(visibleMessageText && !isStandaloneMaintenanceNoticeContent(visibleMessageText));
      const stateIsCompactionStart = state === 'compacting' || state === 'compaction_start';
      const stateIsCompactionEnd = state === 'compacted' || state === 'compaction_end';
      const effectiveState = (stateIsCompactionStart || stateIsCompactionEnd) && hasVisibleNonMaintenanceText
        ? 'delta'
        : state;

      if (stateIsCompactionStart && effectiveState !== 'delta') {
        applyCompactionState('start');
        return;
      }
      if (stateIsCompactionEnd && effectiveState !== 'delta') {
        applyCompactionState('end');
        return;
      }

      // Track current run for abort functionality
      if (incomingRunId) {
        currentRunIdRef.current = incomingRunId;
        directClientRef.current?.setActiveStreamSession(payloadSession || currentSession || null);
      }
      streamTransportRef.current = 'direct';

      switch (effectiveState) {
        case 'delta': {
          let assistantId = streamingAssistantIdRef.current;

          const thinkingText = contentBlocks
            .filter((b: any) => b.type === 'thinking')
            .map((b: any) => (typeof b.thinking === 'string' ? b.thinking : (b.text || '')))
            .join('');

          const text = gatewayTextFromBlocks(contentBlocks);

          if (text && isControlOrMaintenanceAssistantContent(text)) {
            if (assistantId || isStreamActiveRef.current) resetStreamWatchdog();
            break;
          }

          const safeChunk = text ? sanitizeAssistantChunk(text) : '';
          const hasVisibleText = Boolean(safeChunk);
          if (!assistantId && hasVisibleText) {
            assistantId = ensureStreamingAssistantBubble({ idPrefix: 'direct', content: '', resetIfCreated: true }).assistantId;
            isStreamActiveRef.current = true;
            streamTransportRef.current = 'direct';
            setIsRunning(true);
            directClientRef.current?.setActiveStreamSession(sessionRef.current || null);
          }

          if (thinkingText) {
            appendThinkingChunk(
              assistantId,
              extractThinkingChunk('thinking', thinkingText, assembledRef.current.length > 0),
            );
            if (!assembledRef.current && assistantId) setStreamingPhase('thinking');
          }

          if (hasVisibleText) {
            graduateLiveThinkingSegment();
            const fullText = safeChunk;
            lastRawTextLenRef.current = fullText.length;

            const sliced = lastSegmentStartRef.current > 0
              ? fullText.slice(lastSegmentStartRef.current)
              : fullText;

            assembledRef.current = sliced;
            setStatusText(null);
            setStreamingPhase('streaming');
            setActiveToolName(null);

            schedulePendingTextRender(sliced);
          }
          if (assistantId || isStreamActiveRef.current) resetStreamWatchdog();
          break;
        }
        case 'final': {
          clearStreamWatchdog();
          clearPendingTextRender();
          lastTerminalRunIdRef.current = incomingRunId || currentRunIdRef.current;

          const finalTextBlocks = Array.isArray(payload.message?.content)
            ? gatewayTextFromBlocks(payload.message.content)
            : '';
          let finalText = finalTextBlocks || assembledRef.current;
          if (lastSegmentStartRef.current > 0 && finalText.length > lastSegmentStartRef.current) {
            finalText = finalText.slice(lastSegmentStartRef.current);
          }

          const finalContent = isControlOrMaintenanceAssistantContent(finalText)
            ? assembledRef.current
            : sanitizeAssistantContent(finalText);
          assembledRef.current = finalContent;

          const hadToolEvents = hasRealToolEventsRef.current;
          const currentStreamSegs = [...streamSegmentsRef.current];
          const finalStreamSegs = thinkingContentRef.current.trim()
            ? [...currentStreamSegs, { text: thinkingContentRef.current, ts: Date.now(), kind: 'thinking' as const }]
            : currentStreamSegs;
          const shouldHideTurn = !finalContent.trim() && finalStreamSegs.length === 0 && !hadToolEvents;
          let cid = streamingAssistantIdRef.current;
          if (!cid && !shouldHideTurn) {
            cid = ensureStreamingAssistantBubble({ idPrefix: 'direct-final', content: '', resetIfCreated: false }).assistantId;
          }

          setStatusText(null);
          setStreamingPhase('idle');
          setThinkingContent('');
          setStreamSegments([]);
          setActiveToolName(null);
          setIsRunning(false);
          if (compactionPhaseRef.current === 'compacting') {
            compactionPhaseRef.current = 'idle';
            setCompactionPhase('idle');
            if (compactionTimerRef.current) {
              clearTimeout(compactionTimerRef.current);
              compactionTimerRef.current = null;
            }
          }

          isStreamActiveRef.current = false;
          streamTransportRef.current = null;
          streamingAssistantIdRef.current = null;
          activeStreamToolCallsRef.current = [];
          currentRunIdRef.current = null;
          directClientRef.current?.setActiveStreamSession(null);
          assembledRef.current = '';
          lastSegmentStartRef.current = 0;
          lastRawTextLenRef.current = 0;

          const graduatedSegments = finalStreamSegs.length > 0 || hadToolEvents
            ? buildGraduatedSegments(finalStreamSegs, finalContent)
            : [];

          if (cid) {
            if (shouldHideTurn) {
              setMessages(prev => prev.filter(m => m.id !== cid));
            } else {
              setMessages(prev => prev.map(m => {
                if (m.id !== cid) return m;
                const update: Partial<ChatMessage> = { content: finalContent };
                if (graduatedSegments.length > 0) {
                  update.segments = graduatedSegments;
                }
                return { ...m, ...update };
              }));
            }
          }
          scheduleSessionTelemetryRefresh(400);
          if (providerRef.current === 'OPENCLAW') {
            // Match Control UI: live events are delivery state; chat.history is
            // transcript truth. Refresh every completed turn, with one follow-up
            // pass for gateways whose durable JSONL write lags the final event.
            schedulePostTurnHistorySync(450, 2500);
          }
          break;
        }
        case 'aborted': {
          clearStreamWatchdog();
          clearPendingTextRender();
          lastTerminalRunIdRef.current = incomingRunId || currentRunIdRef.current;

          const cid = streamingAssistantIdRef.current;
          const currentText = assembledRef.current;

          setStatusText(null);
          setStreamingPhase('idle');
          setActiveToolName(null);
          setIsRunning(false);
          setStreamSegments([]);
          isStreamActiveRef.current = false;
          streamTransportRef.current = null;
          streamingAssistantIdRef.current = null;
          activeStreamToolCallsRef.current = [];
          currentRunIdRef.current = null;
          directClientRef.current?.setActiveStreamSession(null);

          if (cid && currentText) {
            setMessages(prev => prev.map(m =>
              m.id === cid ? { ...m, content: currentText + '\n\n*(cancelled)*' } : m
            ));
          }
          break;
        }
        case 'error': {
          clearStreamWatchdog();
          clearPendingTextRender();
          lastTerminalRunIdRef.current = incomingRunId || currentRunIdRef.current;
          if (compactionTimerRef.current) {
            clearTimeout(compactionTimerRef.current);
            compactionTimerRef.current = null;
          }
          const errorMsg = normalizeAgentError(payload.errorMessage, 'Unknown error');
          const cid = streamingAssistantIdRef.current;

          setStatusText(null);
          setStreamingPhase('idle');
          setActiveToolName(null);
          activeStreamToolCallsRef.current = [];
          compactionPhaseRef.current = 'idle';
          setCompactionPhase('idle');
          setIsRunning(false);
          isStreamActiveRef.current = false;
          streamTransportRef.current = null;
          streamingAssistantIdRef.current = null;
          currentRunIdRef.current = null;
          directClientRef.current?.setActiveStreamSession(null);
          assembledRef.current = '';

          if (cid) {
            setMessages(prev => prev.map(m =>
              m.id === cid ? { ...m, content: '⚠️ ' + errorMsg } : m
            ));
          }
          break;
        }
      }
    } else if (evt.event === 'agent') {
      const payload = evt.payload;
      const rawData = payload.data as any;
      const normalizedToolData = payload.stream === 'tool' && rawData
        ? rawData
        : (payload.stream === 'item' && rawData?.kind === 'tool'
            ? {
                ...rawData,
                phase: rawData.phase === 'end' ? 'result' : rawData.phase,
                result: rawData.result ?? rawData.partialResult ?? rawData.statusText ?? rawData.meta,
              }
            : null);

      if (normalizedToolData) {
        const data = normalizedToolData;
        let assistantId = streamingAssistantIdRef.current;

        switch (data.phase) {
          case 'start': {
            if (!assistantId) {
              assistantId = ensureStreamingAssistantBubble({ idPrefix: 'direct-tool', content: '', resetIfCreated: true }).assistantId;
              isStreamActiveRef.current = true;
              streamTransportRef.current = 'direct';
              setIsRunning(true);
              directClientRef.current?.setActiveStreamSession(sessionRef.current || null);
            }
            hasRealToolEventsRef.current = true;
            graduateLiveThinkingSegment();
            // Graduate current streaming text into a finalized segment.
            // Use lastRawTextLenRef (the full gateway text length seen so far)
            // as the graduation offset. This is the KEY fix for the cascade bug:
            // assembledRef holds sliced text, but lastSegmentStartRef must track
            // position in the gateway's full accumulated text stream.
            if (assembledRef.current && assembledRef.current.trim().length > 0) {
              // Set graduation offset to the full raw text length seen from gateway,
              // NOT accumulated sliced lengths which drift when sanitization strips chars
              lastSegmentStartRef.current = lastRawTextLenRef.current;
              graduateLiveTextSegment(assistantId);
            }
            const toolName = resolveToolName(data.toolName, data.name, 'tool');
            setStatusText(getToolStatusText(toolName));
            setStreamingPhase('tool');
            setActiveToolName(toolName);

            const toolId = data.toolCallId || 'tool-' + (++toolCounterRef.current);
            if (assistantId) {
              setMessages(prev => prev.map(m => {
                if (m.id !== assistantId) return m;
                const nextToolCalls = [
                  ...(m.toolCalls || []),
                  {
                    id: toolId,
                    name: toolName,
                    arguments: data.args,
                    startedAt: Date.now(),
                    status: 'running' as const,
                  },
                ];
                activeStreamToolCallsRef.current = nextToolCalls;
                return {
                  ...m,
                  toolCalls: nextToolCalls,
                };
              }));
            }
            break;
          }
          case 'update': {
            // Partial result update — could update the tool call if needed
            break;
          }
          case 'result': {
            const toolResult = typeof data.result === 'string'
              ? data.result
              : JSON.stringify(data.result);
            let nextRunningToolName: string | null = null;

            if (assistantId) {
              setMessages(prev => prev.map(m => {
                if (m.id !== assistantId) return m;
                const calls = [...(m.toolCalls || [])];
                // Find the matching tool call by ID or most recent running
                const idx = data.toolCallId
                  ? calls.findIndex(c => c.id === data.toolCallId)
                  : calls.findIndex(c => c.status === 'running');
                if (idx >= 0) {
                  calls[idx] = {
                    ...calls[idx],
                    endedAt: Date.now(),
                    result: toolResult,
                    status: 'done',
                  };
                }
                const nextRunningTool = getLastRunningToolCall(calls);
                nextRunningToolName = nextRunningTool ? resolveToolName(nextRunningTool.name) : null;
                activeStreamToolCallsRef.current = calls;
                return { ...m, toolCalls: calls };
              }));
            }
            if (nextRunningToolName) {
              setStreamingPhase('tool');
              setActiveToolName(nextRunningToolName);
              setStatusText(getToolStatusText(nextRunningToolName));
            } else {
              setStatusText(null);
              setActiveToolName(null);
              setStreamingPhase(assembledRef.current ? 'streaming' : 'thinking');
            }
            break;
          }
        }
      } else if (payload.stream === 'compaction') {
        const data = payload.data as any;
        const compactionSignal = String(data?.phase || data?.status || '').toLowerCase();
        if (compactionSignal === 'start' || compactionSignal === 'started' || compactionSignal === 'compacting') {
          applyCompactionState({
            phase: 'start',
            content: typeof data?.statusText === 'string' ? data.statusText : 'Compacting context…',
            maintenanceKind: 'compaction',
          });
        } else if (compactionSignal === 'end' || compactionSignal === 'completed' || compactionSignal === 'compacted') {
          const compactionStatusText = typeof data?.statusText === 'string' && data.statusText.trim()
            ? data.statusText
            : (data?.completed === false ? 'Context maintenance finished.' : 'Context compacted');
          applyCompactionState({
            phase: 'end',
            content: compactionStatusText,
            completed: data?.completed !== false,
            maintenanceKind: data?.completed === false ? 'maintenance' : 'compaction',
          });
          scheduleSessionTelemetryRefresh(250);
        }
      } else if (payload.stream === 'lifecycle') {
        const data = payload.data as any;
        const lifecyclePhase = String(data?.phase || data?.status || '').toLowerCase();
        const lifecycleStatusText = extractLifecycleStatusText(data);
        const lifecycleSignal = inferLifecycleMaintenanceSignal(lifecyclePhase, lifecycleStatusText);

        if (incomingRunId) {
          currentRunIdRef.current = incomingRunId;
          directClientRef.current?.setActiveStreamSession(payloadSession || currentSession || null);
        }

        if (lifecycleSignal === 'compacting') {
          applyCompactionState({
            phase: 'start',
            content: lifecycleStatusText || defaultLifecycleStatusText(lifecycleSignal),
            maintenanceKind: 'compaction',
          });
        } else if (lifecycleSignal === 'compacted') {
          applyCompactionState({
            phase: 'end',
            content: lifecycleStatusText || defaultLifecycleStatusText(lifecycleSignal),
            completed: true,
            maintenanceKind: 'compaction',
          });
          scheduleSessionTelemetryRefresh(250);
        } else if (lifecycleSignal === 'maintenance') {
          applyCompactionState({
            phase: 'start',
            content: lifecycleStatusText || defaultLifecycleStatusText(lifecycleSignal),
            maintenanceKind: 'maintenance',
          });
        } else if (lifecycleSignal === 'maintenance_done') {
          applyCompactionState({
            phase: 'end',
            content: lifecycleStatusText || defaultLifecycleStatusText(lifecycleSignal),
            completed: false,
            maintenanceKind: 'maintenance',
          });
          scheduleSessionTelemetryRefresh(250);
        }

        const lifecycleUiStatus = lifecycleSignal === 'idle'
          ? lifecycleStatusText
          : (lifecycleStatusText || defaultLifecycleStatusText(lifecycleSignal));

        const lifecycleBelongsToActiveRun = Boolean(incomingRunId)
          || isStreamActiveRef.current
          || Boolean(streamingAssistantIdRef.current)
          || Boolean(getRunningToolName());
        if (lifecycleBelongsToActiveRun && (lifecyclePhase === 'started' || lifecyclePhase === 'running' || lifecyclePhase === 'start' || lifecycleSignal !== 'idle')) {
          isStreamActiveRef.current = true;
          streamTransportRef.current = 'direct';
          setIsRunning(true);
          setSessionAvailability('present');
          setLiveRunPhase('thinking', lifecycleUiStatus || null);
          resetStreamWatchdog();
        }
      }
    }
  }, [clearSuppressedRunId, ensureStreamingAssistantBubble, isRunIdSuppressed, normalizeAgentError, resetStreamWatchdog, clearStreamWatchdog, mergeStreamText, upsertStreamingAssistant, appendThinkingChunk, applyCompactionState, buildGraduatedSegments, getRunningToolName, graduateLiveTextSegment, graduateLiveThinkingSegment, schedulePostTurnHistorySync, scheduleSessionTelemetryRefresh, resolveOpenClawSessionKey, setLiveRunPhase]);

  // WS setup — runs once on mount, survives entire app lifetime
  // Handler registration MUST happen in the same effect that creates the manager,
  // otherwise wsManagerRef.current is null when the handler effect runs.
  useEffect(() => {
    const manager = getWsManager();
    wsManagerRef.current = manager;

    // Register the main event handler via ref indirection so it always calls latest
    const stableHandler = (data: any) => handleWsEventRef.current(data);
    manager.addHandler(stableHandler);

    const statusHandler = (data: any) => {
      if (data.type === 'connected') {
        setWsConnected(true);
        if (isStreamActiveRef.current) {
          debugLog('[ChatState] WS connected while stream active — sending reconnect');
          wsManagerRef.current?.send({ type: 'reconnect', session: sessionRef.current, provider: providerRef.current });
          resetStreamWatchdog();
        }
      }
    };
    manager.addHandler(statusHandler);

    // Seed connection state immediately in case the singleton WS connected
    // before this component attached its handlers (fast-connect race).
    if (manager.isConnected()) {
      setWsConnected(true);
    }

    const unsubDisconnect = manager.onDisconnect(() => {
      setWsConnected(false);
      if (isStreamActiveRef.current) {
        console.warn('[ChatState] WS disconnected during active stream');
        setIsRunning(true);
        setStreamingPhase(prev => prev === 'idle' ? 'thinking' : prev);
        setStatusText('Reconnecting to stream…');
      }
    });
    // On reconnect: for OpenClaw, reconcile from HTTP history first so one request
    // can restore both committed messages and the active-stream snapshot.
    const unsubReconnect = manager.onReconnect(async () => {
      setWsConnected(true);
      debugLog('[ChatState] WS reconnected — reconciling session state');
      try {
        await loadHistoryInternal(sessionRef.current, providerRef.current, { force: true });
      } catch (err) {
        console.warn('[ChatState] Reconnect sync failed:', err);
      }
    });

    return () => {
      manager.removeHandler(stableHandler);
      manager.removeHandler(statusHandler);
      unsubDisconnect();
      unsubReconnect();
      wsManagerRef.current = null;
      releaseWsManager();
    };
  }, []); // eslint-disable-line react-hooks/exhaustive-deps

  // Only bootstrap the optional direct gateway transport after something has
  // actually asked for it, such as an active stream resume or a user-initiated run.
  // That keeps idle Agent Chat opens from spending a post-startup health probe and
  // direct WS setup before the page has delivered any user value.
  useEffect(() => {
    setDirectGatewayBootstrapReady(false);
    if (!useDirectGateway || provider !== 'OPENCLAW' || !startupReady || !directGatewayDemanded) {
      return;
    }
    const timer = window.setTimeout(() => {
      setDirectGatewayBootstrapReady(true);
    }, 1200);
    return () => window.clearTimeout(timer);
  }, [directGatewayDemanded, provider, startupReady, useDirectGateway]);

  // Direct gateway client setup for OPENCLAW provider
  // When useDirectGateway is enabled and provider is OPENCLAW, use the direct
  // gateway connection instead of the portal WS middleman.
  // Defer this until the initial history load finishes and the route has had a
  // moment to settle so direct transport metadata does not compete with first open.
  // Gate on gateway health check first to avoid reconnect loops on fresh installs.
  useEffect(() => {
    // Only create direct client when:
    // 1. Feature flag is enabled
    // 2. Provider is OPENCLAW
    // 3. Initial history bootstrap has completed
    // 4. Post-startup idle deferral has elapsed
    if (!useDirectGateway || provider !== 'OPENCLAW' || !startupReady || !directGatewayBootstrapReady) {
      // Disconnect existing direct client if switching away from OPENCLAW
      // or while a new session is still bootstrapping.
      if (directClientRef.current) {
        debugLog('[ChatState] Disconnecting direct gateway client (provider changed or startup deferred)');
        directClientRef.current.disconnect();
        directClientRef.current = null;
      }
      return;
    }

    // Already have a connected client
    if (directClientRef.current) {
      return;
    }

    let cancelled = false;

    // Check gateway health before attempting direct WS connection.
    // On fresh installs without a configured gateway, this prevents
    // a reconnect loop that spams console errors and wastes resources.
    async function initDirectClient() {
      try {
        const { data } = await client.get('/gateway/health', { _silent: true } as any);
        if (cancelled) return;
        if (!data?.wsConnected) {
          debugLog('[ChatState] Gateway not connected — skipping direct WS (falling back to portal WS)');
          return;
        }
      } catch {
        if (cancelled) return;
        debugLog('[ChatState] Gateway health check failed — skipping direct WS');
        return;
      }

      if (cancelled || directClientRef.current) return;

      debugLog('[ChatState] Creating direct gateway client');
      const directClient = new OpenClawGatewayClient({
        url: createGatewayDirectUrl(),
        onEvent: handleDirectGatewayEvent,
        onConnected: () => {
          setWsConnected(true);
          // Reconcile the current session from gateway history plus active-stream snapshot.
          const currentSession = resolveOpenClawSessionKey(sessionRef.current);
          const currentProvider = providerRef.current;
          if (currentSession && currentSession !== sessionRef.current) {
            sessionRef.current = currentSession;
            setSessionRaw(currentSession);
            persistStoredSession(currentProvider, currentSession);
          }
          if (currentSession) {
            directClient.setCurrentSession(currentSession);
            loadHistoryInternal(currentSession, currentProvider, { force: true, refreshActiveSnapshot: true }).catch((err) => {
              console.warn('[ChatState] Direct reconnect history sync failed:', err);
            });
          }
        },
        onDisconnected: () => {
          setWsConnected(false);
          if (isStreamActiveRef.current) {
            console.warn('[ChatState] Direct gateway disconnected during active stream');
            setIsRunning(true);
            setStreamingPhase(prev => prev === 'idle' ? 'thinking' : prev);
            setStatusText('Reconnecting to stream…');
          }
        },
        onError: (err) => {
          console.error('[ChatState] Direct gateway error:', err);
        },
      });

      directClientRef.current = directClient;
      directClient.connect();
    }

    initDirectClient();

    return () => {
      cancelled = true;
      if (directClientRef.current) {
        debugLog('[ChatState] Cleaning up direct gateway client');
        directClientRef.current.disconnect();
        directClientRef.current = null;
      }
    };
  }, [directGatewayBootstrapReady, provider, handleDirectGatewayEvent, loadHistoryInternal, resolveOpenClawSessionKey, startupReady, useDirectGateway]);

  // Fresh page loads can race transport/session setup. Give OpenClaw a short retry window
  // to detect an already-active stream so second-device reopen reliably attaches mid-turn.
  useEffect(() => {
    if (provider !== 'OPENCLAW' || useDirectGateway || isLoadingHistory) return;

    let cancelled = false;
    let timer: ReturnType<typeof setTimeout> | null = null;
    let attempts = 0;

    const tick = async () => {
      if (cancelled || isStreamActiveRef.current) return;
      attempts += 1;
      await hydrateActiveStream(session, provider);
      if (!cancelled && !isStreamActiveRef.current && attempts < 5) {
        timer = setTimeout(tick, 3000);
      }
    };

    timer = setTimeout(tick, 1200);
    return () => {
      cancelled = true;
      if (timer) clearTimeout(timer);
    };
  }, [hydrateActiveStream, isLoadingHistory, provider, session, useDirectGateway]);

  // Visibility change handler: when the tab becomes visible again, reopen the live
  // stream if we already know it's active; otherwise reconcile from history so
  // OpenClaw uses one HTTP round trip instead of a status probe + reload pair.
  useEffect(() => {
    const handleVisibilityChange = async () => {
      if (document.visibilityState !== 'visible') return;

      const manager = wsManagerRef.current;
      const directClient = directClientRef.current;
      const currentSession = resolveOpenClawSessionKey(sessionRef.current);
      const currentProvider = providerRef.current;

      if (!currentSession) return;

      debugLog('[ChatState] Tab became visible — reconciling session state');

      const usingDirectGateway = useDirectGateway && currentProvider === 'OPENCLAW';
      const transportConnected = usingDirectGateway
        ? Boolean(directClient?.isConnected)
        : Boolean(manager && manager.isConnected());

      if (!transportConnected) {
        debugLog('[ChatState] Transport disconnected on visibility — nudging reconnect');
        if (usingDirectGateway) {
          directClient?.connect();
          if (isStreamActiveRef.current) {
            setStatusText('Reconnecting to stream…');
          }
        } else {
          manager?.reconnect();
        }
      }

      if (isStreamActiveRef.current) {
        try {
          await loadHistoryInternal(currentSession, currentProvider, { force: true, refreshActiveSnapshot: true });
        } catch (err) {
          console.warn('[ChatState] Visibility active-stream sync failed:', err);
        }
        if (!usingDirectGateway) {
          manager?.send({ type: 'reconnect', session: currentSession, provider: currentProvider });
        }
        resetStreamWatchdog();
        return;
      }
      try {
        if (currentProvider === 'OPENCLAW' && !usingDirectGateway) {
          await loadHistoryInternal(currentSession, currentProvider, { force: true, refreshActiveSnapshot: true });
          return;
        }
        debugLog('[ChatState] No active stream on visibility — reloading history for missed messages');
        await loadHistoryInternal(currentSession, currentProvider, { force: true, refreshActiveSnapshot: true });
      } catch (err) {
        console.warn('[ChatState] Visibility check failed:', err);
      }
    };

    document.addEventListener('visibilitychange', handleVisibilityChange);
    return () => {
      document.removeEventListener('visibilitychange', handleVisibilityChange);
    };
  }, [loadHistoryInternal, resetStreamWatchdog, resolveOpenClawSessionKey, useDirectGateway]);

  // Clear messages helper — also invalidates any in-flight history load and
  // resets transient stream UI so switching sessions always starts clean.
  const clearMessages = useCallback(() => {
    historyGenRef.current++; // invalidate any in-flight loadHistoryInternal
    setMessages([]);
    setMessageQueue([]);
    setIsLoadingHistory(false);
    setStatusText(null);
    setLastProvenance(null);
    setStreamingPhase('idle');
    setActiveToolName(null);
    setThinkingContent('');
    setStreamSegments([]);
    compactionPhaseRef.current = 'idle';
    setCompactionPhase('idle');
    assembledRef.current = '';
    lastSegmentStartRef.current = 0;
    lastRawTextLenRef.current = 0;
    toolCounterRef.current = 0;
    hasRealToolEventsRef.current = false;
    activeStreamToolCallsRef.current = [];
    streamingAssistantIdRef.current = null;
    isStreamActiveRef.current = false;
    streamTransportRef.current = null;
    isQueueDrainActiveRef.current = false;
    if (compactionTimerRef.current) { clearTimeout(compactionTimerRef.current); compactionTimerRef.current = null; }
    if (streamWatchdogRef.current) { clearTimeout(streamWatchdogRef.current); streamWatchdogRef.current = null; }
    if (postTurnHistorySyncTimerRef.current) { clearTimeout(postTurnHistorySyncTimerRef.current); postTurnHistorySyncTimerRef.current = null; }
    setIsRunning(false);
  }, []);

  // Resolve exec approval
  const resolveApproval = useCallback(async (
    approvalId: string,
    decision: 'allow-once' | 'deny' | 'allow-always',
  ) => {
    try {
      const response = await client.post('/gateway/exec-approval/resolve', { approvalId, decision });
      if (response.data?.ok) {
        setPendingApprovals((prev) => removeExecApproval(prev, approvalId));
        setStatusText(decision === 'deny' ? '\u274c Command denied' : '\u2705 Command approved');
        setTimeout(() => setStatusText(null), 2000);
        return;
      }
      setStatusText('\u26a0\ufe0f Approval did not complete');
      setTimeout(() => setStatusText(null), 3000);
      throw new Error('Approval did not complete');
    } catch (err: any) {
      console.error('[ChatState] Failed to resolve approval:', err);
      setStatusText(`\u26a0\ufe0f Approval failed${err?.response?.data?.error ? `: ${err.response.data.error}` : ''}`);
      setTimeout(() => setStatusText(null), 4000);
      throw err;
    }
  }, []);

  const dismissApproval = useCallback((approvalId?: string) => {
    setPendingApprovals((prev) => {
      if (!prev.length) return prev;
      return approvalId ? removeExecApproval(prev, approvalId) : prev.slice(1);
    });
  }, []);

  useEffect(() => {
    if (!pendingApprovals.length) return;

    const pruneExpired = () => {
      setPendingApprovals((prev) => pruneExpiredExecApprovals(prev));
    };

    pruneExpired();
    const interval = setInterval(pruneExpired, 500);
    return () => clearInterval(interval);
  }, [pendingApprovals.length]);

  const clearQueue = useCallback(() => {
    setMessageQueue([]);
    setMessages(prev => prev.filter(m => !m.queued));
  }, []);

  const removeQueuedMessage = useCallback((id: string) => {
    setMessageQueue(prev => prev.filter(item => item.id !== id));
    setMessages(prev => prev.filter(m => m.id !== id));
  }, []);

  // Send message via WS (with SSE fallback)
  const sendMessage = useCallback(async (text: string) => {
    const normalized = String(text || '').trim();
    if (!normalized) return;

    const shouldSteerActiveTurn = providerRef.current === 'OPENCLAW' && isStreamActiveRef.current;
    const interruptedRunId = shouldSteerActiveTurn ? currentRunIdRef.current : null;
    if (shouldSteerActiveTurn) {
      if (interruptedRunId) {
        markRunIdSuppressed(interruptedRunId);
      }
      const interruptedAssistantId = streamingAssistantIdRef.current;
      const interruptedText = assembledRef.current;
      clearActiveStreamState();
      if (interruptedAssistantId) {
        setMessages(prev => prev.map((message) => {
          if (message.id !== interruptedAssistantId) return message;
          if (interruptedText.trim()) {
            return { ...message, content: `${interruptedText}\n\n*(interrupted by steering message)*` };
          }
          return { ...message, content: message.content || '*(interrupted by steering message)*' };
        }));
      }
    }

    const shouldQueue = isStreamActiveRef.current || (!isQueueDrainActiveRef.current && messageQueueRef.current.length > 0);
    if (shouldQueue) {
      const queuedId = nextId();
      const queuedAt = Date.now();
      setMessages(prev => [...prev, {
        id: queuedId,
        role: 'user',
        content: normalized,
        createdAt: new Date(queuedAt),
        queued: true,
      }]);
      setMessageQueue(prev => [...prev, { id: queuedId, text: normalized, createdAt: queuedAt }]);
      return;
    }

    // Add user message to UI
    const userMsg: ChatMessage = {
      id: nextId(),
      role: 'user',
      content: normalized,
      createdAt: new Date(),
      pendingAck: true,
    };
    setMessages(prev => [...prev, userMsg]);

    clearPostTurnHistorySync();
    clearPendingTextRender();

    // Reset streaming state
    assembledRef.current = '';
    lastSegmentStartRef.current = 0;
    lastRawTextLenRef.current = 0;
    toolCounterRef.current = 0;
    hasRealToolEventsRef.current = false;
    activeStreamToolCallsRef.current = [];
    setThinkingContent('');
    setStreamSegments([]);
    setStatusText(null);
    setStreamingPhase('thinking');
    setActiveToolName(null);

    // Add placeholder assistant message
    const assistantId = nextId();
    streamingAssistantIdRef.current = assistantId;
    const assistantMsg: ChatMessage = {
      id: assistantId,
      role: 'assistant',
      content: '',
      createdAt: new Date(),
    };
    setMessages(prev => [...prev, assistantMsg]);
    setIsRunning(true);
    setSessionAvailability('present');
    isStreamActiveRef.current = true;
    resetStreamWatchdog();

    if (useDirectGateway && providerRef.current === 'OPENCLAW') {
      setDirectGatewayDemanded(true);
    }

    // For OPENCLAW with direct gateway enabled, use the direct client
    const directClient = directClientRef.current;
    if (useDirectGateway && providerRef.current === 'OPENCLAW' && directClient?.isConnected) {
      try {
        streamTransportRef.current = 'direct';
        const currentSession = resolveOpenClawSessionKey(sessionRef.current || 'main');
        if (currentSession !== sessionRef.current) {
          sessionRef.current = currentSession;
          setSessionRaw(currentSession);
          persistStoredSession(providerRef.current, currentSession);
        }
        debugLog('[ChatState] Sending via direct gateway to session:', currentSession);
        const steerResult = shouldSteerActiveTurn
          ? await directClient.steerSession(currentSession, normalized)
          : null;
        const runId = shouldSteerActiveTurn
          ? steerResult?.runId || null
          : await directClient.sendMessage(currentSession, normalized);
        if (interruptedRunId && runId && interruptedRunId === runId) {
          clearSuppressedRunId(interruptedRunId);
        }
        currentRunIdRef.current = runId;
        debugLog('[ChatState] Direct send initiated, runId:', runId || '(pending)', 'steered:', shouldSteerActiveTurn);
        // Events will come through handleDirectGatewayEvent
      } catch (err: any) {
        console.error('[ChatState] Direct gateway send failed:', err);
        clearSuppressedRunId(interruptedRunId);
        setMessages(prev => prev.map(m =>
          m.id === assistantId ? { ...m, content: '⚠️ ' + normalizeAgentError(err, 'Send failed') } : m
        ));
        setIsRunning(false);
        setStreamingPhase('idle');
        isStreamActiveRef.current = false;
        streamTransportRef.current = null;
        streamingAssistantIdRef.current = null;
      }
      return;
    }

    // Send via WS (portal middleman path for non-OPENCLAW or when direct gateway unavailable)
    const manager = wsManagerRef.current;
    if (shouldSteerActiveTurn && providerRef.current === 'OPENCLAW') {
      try {
        streamTransportRef.current = 'portal';
        const currentSession = resolveOpenClawSessionKey(sessionRef.current || 'main') || 'main';
        const { data } = await client.post('/gateway/session-steer', {
          session: currentSession,
          message: normalized,
        });
        currentRunIdRef.current = typeof data?.runId === 'string' ? data.runId : null;
        debugLog('[ChatState] Portal steer initiated, runId:', currentRunIdRef.current || '(pending)');
      } catch (err: any) {
        clearSuppressedRunId(interruptedRunId);
        setMessages(prev => prev.map(m =>
          m.id === assistantId ? { ...m, content: '⚠️ ' + normalizeAgentError(err, 'Steer failed') } : m
        ));
        setIsRunning(false);
        setStreamingPhase('idle');
        isStreamActiveRef.current = false;
        streamTransportRef.current = null;
        streamingAssistantIdRef.current = null;
      }
      return;
    }

    if (manager && manager.isConnected()) {
      streamTransportRef.current = 'portal';
      const payload: Record<string, unknown> = {
        type: 'send',
        message: normalized,
        session: resolveOpenClawSessionKey(sessionRef.current || 'main') || 'main',
      };
      if (providerRef.current) payload.provider = providerRef.current;
      if (modelRef.current) payload.model = modelRef.current;
      if (agentIdRef.current) payload.agentId = agentIdRef.current;
      const sent = manager.send(payload);
      if (!sent) {
        try {
          await sendViaSSE(normalized, assistantId);
        } catch (err: any) {
          setMessages(prev => prev.map(m =>
            m.id === assistantId ? { ...m, content: '⚠️ ' + normalizeAgentError(err, 'Send failed') } : m
          ));
          setIsRunning(false);
          setStreamingPhase('idle');
          isStreamActiveRef.current = false;
          streamTransportRef.current = null;
          streamingAssistantIdRef.current = null;
        }
      }
    } else {
      // SSE fallback
      try {
        streamTransportRef.current = 'portal';
        await sendViaSSE(normalized, assistantId);
      } catch (err: any) {
        setMessages(prev => prev.map(m =>
          m.id === assistantId ? { ...m, content: '⚠️ ' + normalizeAgentError(err, 'Send failed') } : m
        ));
        setIsRunning(false);
        setStreamingPhase('idle');
        isStreamActiveRef.current = false;
        streamTransportRef.current = null;
        streamingAssistantIdRef.current = null;
      }
    }
  }, [clearActiveStreamState, clearSuppressedRunId, markRunIdSuppressed, normalizeAgentError, resetStreamWatchdog, resolveOpenClawSessionKey, useDirectGateway]);

  const drainNextQueuedMessage = useCallback(() => {
    if (isStreamActiveRef.current || isQueueDrainActiveRef.current) return;
    const next = messageQueueRef.current[0];
    if (!next) return;

    isQueueDrainActiveRef.current = true;
    setMessageQueue(prev => prev.slice(1));
    setMessages(prev => prev.filter(m => m.id !== next.id));
    void sendMessage(next.text).finally(() => {
      isQueueDrainActiveRef.current = false;
    });
  }, [sendMessage]);

  // SSE fallback sender
  const sendViaSSE = useCallback(async (text: string, initialAssistantId: string) => {
    let assembled = '';
    let assistantId = initialAssistantId;
    streamTransportRef.current = 'portal';

    const apiUrl = import.meta.env.VITE_API_URL || '';
    const body: Record<string, unknown> = {
      message: text,
      session: resolveOpenClawSessionKey(sessionRef.current || 'main') || 'main',
    };
    if (providerRef.current) body.provider = providerRef.current;
    if (modelRef.current) body.model = modelRef.current;
    if (agentIdRef.current) body.agentId = agentIdRef.current;

    const headers: Record<string, string> = { 'Content-Type': 'application/json' };

    const response = await fetch(apiUrl + '/gateway/send?stream=1', {
      method: 'POST',
      headers,
      credentials: 'include',
      body: JSON.stringify(body),
    });
    if (!response.ok) {
      let errorMessage = `Gateway error: ${response.status}`;
      try {
        const text = await response.text();
        if (text) {
          const parsed = JSON.parse(text);
          errorMessage = parsed?.error || parsed?.detail || errorMessage;
        }
      } catch {}
      throw new Error(errorMessage);
    }
    const reader = response.body?.getReader();
    if (!reader) throw new Error('No stream body');

    const decoder = new TextDecoder();
    let buffer = '';
    while (true) {
      const { done, value } = await reader.read();
      if (!done) { buffer += decoder.decode(value, { stream: true }); }
      else { if (buffer.trim()) buffer += '\n'; }
      const lines = buffer.split('\n');
      buffer = lines.pop() || '';
      for (const line of lines) {
        if (!line.startsWith('data: ')) continue;
        const payload = line.slice(6).trim();
        if (payload === '[DONE]') continue;
        try {
          const evt = JSON.parse(payload);
          if (evt.type === 'session') {
            if (evt.sessionId) {
              setSessionAvailability('present');
              setSessionRaw(evt.sessionId);
              persistStoredSession(providerRef.current, evt.sessionId);
            }
            if (evt.provenance) setLastProvenance(evt.provenance);
          } else if (evt.type === 'text') {
            const rawChunk = typeof evt.content === 'string' ? evt.content : '';
            if (rawChunk && isControlOrMaintenanceAssistantContent(rawChunk)) {
              continue;
            }
            const chunk = typeof evt.content === 'string'
              ? (evt.replace === true ? sanitizeAssistantContent(evt.content) : sanitizeAssistantChunk(evt.content))
              : '';
            graduateLiveThinkingSegment();
            assembled = mergeAssistantStream(assembled, chunk, { replace: evt.replace === true });
            assembledRef.current = assembled;
            setStreamingPhase('streaming');
            setStatusText(null);
            setMessages(prev => prev.map(m => m.id === assistantId ? { ...m, content: assembled } : m));
          } else if (evt.type === 'status') {
            const nextStatusText = typeof evt.content === 'string' ? evt.content : null;
            const normalizedStatus = typeof nextStatusText === 'string' ? normalizeLifecycleMarker(nextStatusText) : '';
            const isMaintenanceStatus = evt.maintenanceKind === 'maintenance'
              || LIFECYCLE_FLUSH_PREPARING_RE.test(normalizedStatus)
              || LIFECYCLE_FLUSH_RUNNING_RE.test(normalizedStatus)
              || LIFECYCLE_FLUSH_DONE_RE.test(normalizedStatus);
            if (isMaintenanceStatus) {
              applyCompactionState({
                phase: LIFECYCLE_FLUSH_DONE_RE.test(normalizedStatus) ? 'end' : 'start',
                content: nextStatusText,
                completed: false,
                maintenanceKind: 'maintenance',
              });
            }
            if (providerRef.current !== 'OPENCLAW') {
              const thinkingChunk = extractThinkingChunk('status', evt.content, assembled.length > 0);
              appendThinkingChunk(assistantId, thinkingChunk);
            }
            if (!assembled || getRunningToolName()) {
              setLiveRunPhase('thinking', nextStatusText);
            }
          } else if (evt.type === 'thinking') {
            const thinkingChunk = extractThinkingChunk('thinking', evt.content, assembled.length > 0);
            appendThinkingChunk(assistantId, thinkingChunk);
            if (!assembled || getRunningToolName()) {
              setLiveRunPhase('thinking', typeof evt.content === 'string' ? evt.content : null);
            }
          } else if (evt.type === 'compaction_start') {
            applyCompactionState({
              phase: 'start',
              content: typeof evt.content === 'string' ? evt.content : null,
              maintenanceKind: 'compaction',
            });
            if (!assembled) setStreamingPhase(prev => prev === 'idle' ? 'thinking' : prev);
          } else if (evt.type === 'compaction_end') {
            applyCompactionState({
              phase: 'end',
              content: typeof evt.content === 'string' ? evt.content : null,
              completed: evt.completed !== false,
              maintenanceKind: evt.completed === false ? 'maintenance' : 'compaction',
            });
          } else if (evt.type === 'stream_resume') {
            applyOpenClawActiveStreamSnapshot({ ...evt, active: true }, { statusTextWhenNoTool: 'Reconnecting to stream…', source: 'portal' });
          } else if (evt.type === 'run_resumed') {
            isStreamActiveRef.current = true;
            setIsRunning(true);
            setLiveRunPhase(assembled ? 'streaming' : 'thinking', null);
            resetStreamWatchdog();
          } else if (evt.type === 'tool_start') {
            hasRealToolEventsRef.current = true;
            graduateLiveThinkingSegment();
            if (assembled && assembled.trim().length > 0) {
              graduateLiveTextSegment(assistantId);
              assembled = '';
              assembledRef.current = '';
            }
            const toolName = resolveToolName(evt.toolName, evt.content, 'tool');
            const toolId = typeof evt.toolCallId === 'string' && evt.toolCallId.trim()
              ? evt.toolCallId.trim()
              : 'tool-' + (++toolCounterRef.current);
            setStatusText(getToolStatusText(toolName, typeof evt.content === 'string' ? evt.content : null));
            setStreamingPhase('tool');
            setActiveToolName(toolName);
            setMessages(prev => prev.map(m => {
              if (m.id !== assistantId) return m;
              const nextToolCalls = [
                ...(m.toolCalls || []),
                {
                  id: toolId,
                  name: toolName,
                  arguments: (evt as any).toolArgs,
                  startedAt: Date.now(),
                  status: 'running' as const,
                },
              ];
              activeStreamToolCallsRef.current = nextToolCalls;
              return { ...m, toolCalls: nextToolCalls };
            }));
          } else if (evt.type === 'tool_used') {
            const toolName = resolveToolName(evt.toolName, evt.content, 'tool');
            if (!hasRealToolEventsRef.current) {
              const toolId = typeof evt.toolCallId === 'string' && evt.toolCallId.trim()
                ? evt.toolCallId.trim()
                : 'tool-' + (++toolCounterRef.current);
              const now = Date.now();
              setMessages(prev => prev.map(m => {
                if (m.id !== assistantId) return m;
                const nextToolCalls = [
                  ...(m.toolCalls || []),
                  {
                    id: toolId,
                    name: toolName,
                    startedAt: now - 1000,
                    endedAt: now,
                    status: 'done' as const,
                  },
                ];
                activeStreamToolCallsRef.current = nextToolCalls;
                return { ...m, toolCalls: nextToolCalls };
              }));
            }
            setStreamingPhase('tool');
            setActiveToolName(toolName);
            setStatusText(getToolStatusText(toolName));
          } else if (evt.type === 'tool_end') {
            const toolResult = typeof (evt as any).toolResult === 'string'
              ? (evt as any).toolResult
              : (typeof evt.content === 'string' ? evt.content : 'Completed');
            let nextRunningToolName: string | null = null;
            setMessages(prev => prev.map(m => {
              if (m.id !== assistantId) return m;
              const calls = [...(m.toolCalls || [])];
              const idx = typeof evt.toolCallId === 'string' && evt.toolCallId.trim()
                ? calls.findIndex(c => c.id === evt.toolCallId)
                : calls.findIndex(c => c.status === 'running');
              if (idx >= 0) {
                calls[idx] = { ...calls[idx], endedAt: Date.now(), result: toolResult, status: 'done' };
              }
              const nextRunningTool = getLastRunningToolCall(calls);
              nextRunningToolName = nextRunningTool ? resolveToolName(nextRunningTool.name) : null;
              activeStreamToolCallsRef.current = calls;
              return { ...m, toolCalls: calls };
            }));
            if (nextRunningToolName) {
              setStreamingPhase('tool');
              setActiveToolName(nextRunningToolName);
              setStatusText(getToolStatusText(nextRunningToolName));
            } else {
              setStreamingPhase(assembled ? 'streaming' : 'thinking');
              setActiveToolName(null);
              setStatusText(null);
            }
          } else if (evt.type === 'segment_break') {
            // Don't create a new bubble — keep all text in a single streaming message.
          } else if (evt.type === 'stream_ended') {
            clearStreamWatchdog();
            clearPendingTextRender();
            setStatusText(null);
            setStreamingPhase('idle');
            setThinkingContent('');
            setActiveToolName(null);
            setIsRunning(false);
            isStreamActiveRef.current = false;
            streamTransportRef.current = null;
            streamingAssistantIdRef.current = null;
            activeStreamToolCallsRef.current = [];
            currentRunIdRef.current = null;
            directClientRef.current?.setActiveStreamSession(null);
            if (providerRef.current === 'OPENCLAW') {
              void loadHistoryInternalRef.current?.(sessionRef.current || 'main', providerRef.current, { force: true, refreshActiveSnapshot: true });
            }
          } else if (evt.type === 'done') {
            const rawFinal = typeof evt.content === 'string' ? evt.content : '';
            const hasFinal = rawFinal.length > 0 && !isControlOrMaintenanceAssistantContent(rawFinal);
            const finalContent = hasFinal ? sanitizeAssistantContent(rawFinal) : (assembled || '');
            assembled = finalContent;
            assembledRef.current = finalContent;
            const prov = evt.provenance || null;
            const currentStreamSegs = [...streamSegmentsRef.current];
            const finalStreamSegs = thinkingContentRef.current.trim()
              ? [...currentStreamSegs, { text: thinkingContentRef.current, ts: Date.now(), kind: 'thinking' as const }]
              : currentStreamSegs;
            const graduatedSegments = finalStreamSegs.length > 0 || hasRealToolEventsRef.current
              ? buildGraduatedSegments(finalStreamSegs, finalContent)
              : [];
            setMessages(prev => prev.map(m => {
              if (m.id !== assistantId) return m;
              const update: Partial<ChatMessage> = { content: finalContent, provenance: prov || undefined };
              if (graduatedSegments.length > 0) {
                update.segments = graduatedSegments;
              }
              return { ...m, ...update };
            }));
            setStreamingPhase('idle');
            setActiveToolName(null);
            setIsRunning(false);
            setThinkingContent('');
            setStreamSegments([]);
            setLastProvenance(prov);
            isStreamActiveRef.current = false;
            streamTransportRef.current = null;
            streamingAssistantIdRef.current = null;
            activeStreamToolCallsRef.current = [];
            currentRunIdRef.current = null;
            directClientRef.current?.setActiveStreamSession(null);
            drainNextQueuedMessage();
          } else if (evt.type === 'error') {
            clearStreamWatchdog();
            if (compactionTimerRef.current) {
              clearTimeout(compactionTimerRef.current);
              compactionTimerRef.current = null;
            }
            setMessages(prev => prev.map(m =>
              m.id === assistantId ? { ...m, content: '⚠️ ' + normalizeAgentError(evt.content, 'Error') } : m
            ));
            setStatusText(null);
            setStreamingPhase('idle');
            setActiveToolName(null);
            activeStreamToolCallsRef.current = [];
            compactionPhaseRef.current = 'idle';
            setCompactionPhase('idle');
            setIsRunning(false);
            currentRunIdRef.current = null;
            isStreamActiveRef.current = false;
            streamTransportRef.current = null;
            streamingAssistantIdRef.current = null;
            directClientRef.current?.setActiveStreamSession(null);
          } else if (evt.type === 'exec_approval') {
            if (evt.approval?.id) {
              setPendingApprovals((prev) => upsertExecApproval(prev, evt.approval));
              setStatusText('\u23f3 Waiting for command approval\u2026');
            }
          }
        } catch { /* ignore parse errors */ }
      }
      if (done) break;
    }
  }, [appendThinkingChunk, applyCompactionState, applyOpenClawActiveStreamSnapshot, buildGraduatedSegments, clearStreamWatchdog, getRunningToolName, graduateLiveTextSegment, graduateLiveThinkingSegment, normalizeAgentError, resetStreamWatchdog, resolveOpenClawSessionKey, setLiveRunPhase]);

  // Drain queued FYI messages after the current stream ends.
  // isRunning is in the dep array so this re-evaluates when the stream completes
  // (isStreamActiveRef is a ref — mutations don't trigger effects on their own).
  useEffect(() => {
    if (isRunning) return;
    drainNextQueuedMessage();
  }, [drainNextQueuedMessage, isRunning, messageQueue]);


  const injectNote = useCallback(async (text: string, sessionKey?: string) => {
    const note = String(text || '').trim();
    if (!note) return;

    const targetSession = sessionKey || sessionRef.current;
    const directClient = directClientRef.current;
    if (useDirectGateway && providerRef.current === 'OPENCLAW' && directClient?.isConnected) {
      await directClient.injectMessage(targetSession, note);
      return;
    }

    const manager = wsManagerRef.current;
    if (manager && manager.isConnected()) {
      const sent = manager.send({ type: 'inject', session: targetSession, text: note });
      if (sent) return;
    }

    await client.post('/gateway/chat/inject', { session: targetSession, text: note });
  }, [useDirectGateway]);

  // Cancel stream
  const cancelStream = useCallback(async () => {
    try {
      // For OPENCLAW with direct gateway, use the direct client for abort
      const directClient = directClientRef.current;
      if (useDirectGateway && providerRef.current === 'OPENCLAW' && directClient?.isConnected) {
        const currentSession = sessionRef.current;
        const runId = currentRunIdRef.current;
        debugLog('[ChatState] Aborting via direct gateway, session:', currentSession, 'runId:', runId);
        await directClient.abortRun(currentSession, runId || undefined);
      } else {
        const manager = wsManagerRef.current;
        const runId = currentRunIdRef.current;
        if (manager && manager.isConnected()) {
          manager.send({
            type: 'abort',
            session: sessionRef.current,
            provider: providerRef.current,
            ...(runId ? { runId } : {}),
          });
        } else {
          await client.post('/gateway/chat/abort', {
            session: sessionRef.current,
            provider: providerRef.current,
            ...(runId ? { runId } : {}),
          });
        }
      }
    } catch (err) {
      console.error('[ChatState] Failed to cancel stream:', err);
    } finally {
      clearStreamWatchdog();
      isStreamActiveRef.current = false;
      setIsRunning(false);
      setStreamingPhase('idle');
      setStatusText(null);
      compactionPhaseRef.current = 'idle';
      setCompactionPhase('idle');
      currentRunIdRef.current = null;
      if (compactionTimerRef.current) { clearTimeout(compactionTimerRef.current); compactionTimerRef.current = null; }
      const cid = streamingAssistantIdRef.current;
      if (cid) {
        const ft = assembledRef.current;
        if (ft) setMessages(prev => prev.map(m => m.id === cid ? { ...m, content: ft + '\n\n*(cancelled)*' } : m));
        streamingAssistantIdRef.current = null;
      }
    }
  }, [clearStreamWatchdog, useDirectGateway]);

  // Session controls: check if supported (OPENCLAW with concrete session)
  const sessionControlsSupported = provider === 'OPENCLAW' && session.startsWith('agent:');

  const setThinkingLevel = useCallback(async (nextLevel: ThinkingLevel) => {
    if (!sessionControlsSupported) return;
    try {
      await gatewayAPI.patchSession(session, { thinking: nextLevel }, provider);
      setThinkingLevelState(nextLevel);
    } catch (err) {
      console.error('[ChatState] Failed to patch thinking level:', err);
    }
  }, [sessionControlsSupported, session, provider]);

  const reconnectSocket = useCallback(() => {
    if (useDirectGateway && providerRef.current === 'OPENCLAW' && directClientRef.current) {
      directClientRef.current.disconnect();
      directClientRef.current.connect();
      return;
    }
    wsManagerRef.current?.reconnect();
  }, [useDirectGateway]);

  const toggleFastMode = useCallback(async () => {
    if (!sessionControlsSupported) return;
    try {
      await gatewayAPI.patchSession(session, { fastMode: !fastModeEnabled }, provider);
      setFastModeEnabled((prev) => !prev);
    } catch (err) {
      console.error('[ChatState] Failed to toggle fast mode:', err);
    }
  }, [sessionControlsSupported, fastModeEnabled, session, provider]);

  const setCompactionModelOverride = useCallback(async (model: string) => {
    setCompactionModelLoading(true);
    try {
      const normalized = String(model || '').trim();
      const patchResult = await gatewayAPI.patchConfigPath('agents.defaults.compaction.model', normalized || null);
      const patchedValue = typeof patchResult?.value === 'string' ? patchResult.value.trim() : '';
      setCompactionModelOverrideState(patchedValue);
      const fresh = await gatewayAPI.getConfigPath('agents.defaults.compaction.model');
      const freshValue = typeof fresh?.value === 'string' ? fresh.value.trim() : '';
      setCompactionModelOverrideState(freshValue);
    } catch (err) {
      console.error('[ChatState] Failed to patch compaction model override:', err);
    } finally {
      setCompactionModelLoading(false);
    }
  }, []);


  // Build context value
  const contextValue: ChatStateContextValue = {
    messages,
    messageQueue,
    queueCount: messageQueue.length,
    setMessages,
    clearMessages,
    clearQueue,
    removeQueuedMessage,
    isRunning,
    isLoadingHistory,
    isSwitchingSession,
    streamingPhase,
    activeToolName,
    statusText,
    lastProvenance,
    thinkingContent,
    streamSegments,
    compactionPhase,
    wsConnected,
    pendingApproval: pendingApprovals[0] || null,
    pendingApprovals,
    pendingApprovalCount: pendingApprovals.length,
    resolveApproval,
    dismissApproval,
    provider,
    setProvider,
    session,
    setSession,
    agentId,
    setAgentId,
    selectedModel,
    setSelectedModel,
    switchModel,
    sendMessage,
    injectNote,
    cancelStream,
    loadHistory,
    selectSession,
    refreshChat,
    wsManager: wsManagerRef.current,
    reconnectSocket,
    // Session controls
    thinkingLevel,
    setThinkingLevel,
    fastModeEnabled,
    toggleFastMode,
    compactionModelOverride,
    setCompactionModelOverride,
    compactionModelLoading,
    compactionModelError,
    sessionControlsSupported,
    ensureSessionControlsMetadataLoaded,
    sessionTelemetry,
    sessionAvailability,
  };

  return (
    <ChatStateContext.Provider value={contextValue}>
      {children}
    </ChatStateContext.Provider>
  );
}
