'use client';

import { memo, useCallback, useState, useRef, useEffect, useMemo, type ReactNode, type Ref } from 'react';
import { Stack, Text, Title, Box, Group, Menu, ActionIcon, Tooltip, Badge, Button } from '@tidbcloud/uikit';
import { CloseOutlined, CodeOutlined, LoadingOutlined, SendOutlined, StopOutlined, UploadOutlined } from '@ant-design/icons';
import dayjs from 'dayjs';
import utc from 'dayjs/plugin/utc';
import { Message, MessagePart, TextPart, ToolPart, ReasoningPart, ImagePart, SSEEvent, ImageAttachment, type ToolMeta } from './types';
import { ToolGroupView } from './MessagePartView';
import { MarkdownContent } from './MarkdownContent';
import { IssueInfoSystemMessage } from './IssueInfoSystemMessage';
import { formatAlertRulePath, summarizeAlertExpr } from './alertRuleFormat';
import { ArtifactType, useWorkspace } from '@/lib/workspace';
import FileViewer from '@/components/StudioPanel/FileViewer';
import {
  AGENT_RUNTIME_CHANGED_EVENT,
  authHeaders,
  fetchWithAuth,
  clearAuth,
  fetchMe,
  getToken,
  setAgentRuntimePreference,
  type AgentRuntime,
} from '@/lib/auth';
import { useIsMobile } from '@/lib/useIsMobile';
import { useTranslation, type TranslationKey } from '@/lib/i18n';
import FeedbackBanner from './FeedbackBanner';
import { CodeDiagnosisModal } from './CodeDiagnosisModal';
import {
  DiagnosisCheckpoint,
  type ConfirmedTimeRange,
  type DiagnosisCheckpointState,
  type DiagnosisRoutingCheckpoint,
  type DiagnosisTimeCheckpoint,
  isStandardDiagnosisRoute,
} from './DiagnosisCheckpoint';

dayjs.extend(utc);

const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL ?? '';
// Set NEXT_PUBLIC_CHAT_SSE_DEBUG=1 to log per-event SSE timing in the browser console.
const SSE_DEBUG = process.env.NEXT_PUBLIC_CHAT_SSE_DEBUG === '1';
const ARTIFACT_TYPES = new Set<ArtifactType>(['report', 'chart', 'summary', 'timeline', 'note']);
type ActiveCodeContext = { repositoryId: string; repo: string; label: string; versionLabel?: string; commitSha: string; contextId: string };
type JiraCommentDraft = {
  id: string;
  issueKey: string;
  body: string;
};
type DiagnosisInvocationRequest = {
  diagnosis_mode: 'copilot';
  trigger_type: 'human_message';
  supports_followup: true;
  interaction_action?: 'start' | 'confirm_time' | 'revise_time' | 'select_routes' | 'route_feedback';
  selected_routes?: string[];
  confirmed_time_range?: ConfirmedTimeRange;
};
const DEFAULT_AGENT_RUNTIME: AgentRuntime =
  process.env.NEXT_PUBLIC_DEFAULT_AGENT_RUNTIME === 'pi' ? 'pi' : 'opencode';

function parseAgentRuntime(value: unknown): AgentRuntime | null {
  return value === 'opencode' || value === 'pi' ? value : null;
}

function sameToolMeta(left?: ToolMeta, right?: ToolMeta): boolean {
  const leftSubagent = left?.['opencode/subagent'];
  const rightSubagent = right?.['opencode/subagent'];
  if (leftSubagent || rightSubagent) {
    return leftSubagent?.parentSessionId === rightSubagent?.parentSessionId
      && leftSubagent?.parentTaskCallId === rightSubagent?.parentTaskCallId
      && leftSubagent?.subagentSessionId === rightSubagent?.subagentSessionId
      && leftSubagent?.subagentName === rightSubagent?.subagentName;
  }
  return JSON.stringify(left ?? null) === JSON.stringify(right ?? null);
}

function hasSubagentParent(meta?: ToolMeta): boolean {
  const parentTaskCallId = meta?.['opencode/subagent']?.parentTaskCallId;
  return typeof parentTaskCallId === 'string' && parentTaskCallId.trim().length > 0;
}

function getCodeDiagnosisPhase(data: Extract<SSEEvent, { type: 'tool' }>): NonNullable<Message['codeDiagnosisProgress']>['phase'] | null {
  const subagentName = data.meta?.['opencode/subagent']?.subagentName;
  if (subagentName === 'code-investigator') return 'analyzing';
  if (data.input?.subagent_type === 'code-investigator') return 'starting_agent';
  return null;
}

function CodeDiagnosisProgressView({ progress }: { progress: NonNullable<Message['codeDiagnosisProgress']> }) {
  const { t } = useTranslation();
  const [now, setNow] = useState(() => Date.now());

  useEffect(() => {
    const interval = window.setInterval(() => setNow(Date.now()), 1_000);
    return () => window.clearInterval(interval);
  }, []);

  const phaseKey = progress.phase === 'preparing'
    ? 'chat.codeDiagnosis.progressPreparing'
    : progress.phase === 'starting_agent'
      ? 'chat.codeDiagnosis.progressStartingAgent'
      : 'chat.codeDiagnosis.progressAnalyzing';
  const elapsedSeconds = Math.max(0, Math.floor((now - progress.startedAt) / 1_000));

  return (
    <Box
      p="sm"
      style={{
        display: 'flex',
        alignItems: 'center',
        gap: '8px',
        backgroundColor: '#eff6ff',
        border: '1px solid #bfdbfe',
        borderRadius: '6px',
      }}
    >
      <LoadingOutlined style={{ color: '#2563eb' }} spin />
      <Text size="sm" fw={600} c="blue.8" style={{ flex: 1 }}>{t(phaseKey)}</Text>
      <Text size="xs" c="carbon.6" style={{ fontFamily: 'monospace', fontVariantNumeric: 'tabular-nums' }}>
        {t('chat.codeDiagnosis.progressElapsed', { seconds: elapsedSeconds })}
      </Text>
    </Box>
  );
}

function isSubagentTextPart(part: TextPart): boolean {
  return hasSubagentParent(part.meta);
}

function MessageTimingBadge({ meta }: { meta: { ttff_ms?: number; total_ms?: number; waiting_ms?: number } }) {
  const { t } = useTranslation();
  if (!meta.total_ms && !meta.ttff_ms && !meta.waiting_ms) return null;

  const items: { label: string; value: string; color: string }[] = [];
  if (meta.total_ms != null)
    items.push({ label: t('chat.timingTotal'), value: `${(meta.total_ms / 1000).toFixed(1)}s`, color: '#6b7280' });
  if (meta.waiting_ms != null && meta.waiting_ms > 200)
    items.push({ label: t('chat.timingWait'), value: `${(meta.waiting_ms / 1000).toFixed(1)}s`, color: '#f59e0b' });
  if (meta.ttff_ms != null)
    items.push({ label: t('chat.timingTtff'), value: `${(meta.ttff_ms / 1000).toFixed(1)}s`, color: '#3b82f6' });

  return (
    <div style={{ display: 'flex', alignItems: 'center', gap: '8px', marginTop: '6px', flexWrap: 'wrap' }}>
      {items.map((item, i) => (
        <div key={i} style={{ display: 'flex', alignItems: 'center', gap: '3px' }}>
          <Text size="xs" style={{ color: '#9ca3af', fontSize: '10px' }}>{item.label}</Text>
          <Text size="xs" style={{ color: item.color, fontFamily: 'monospace', fontSize: '11px', fontWeight: 600 }}>
            {item.value}
          </Text>
        </div>
      ))}
    </div>
  );
}

function stripMarkdown(md: string): string {
  return md
    .replace(/^#{1,6}\s+/gm, '')           // headings
    .replace(/\*\*(.+?)\*\*/g, '$1')        // bold **
    .replace(/__(.+?)__/g, '$1')            // bold __
    .replace(/\*(.+?)\*/g, '$1')            // italic *
    .replace(/_(.+?)_/g, '$1')             // italic _
    .replace(/~~(.+?)~~/g, '$1')            // strikethrough
    .replace(/`{3}[\s\S]*?`{3}/g, (m) =>   // fenced code blocks → content only
      m.replace(/`{3}[^\n]*\n?/, '').replace(/\n?`{3}/, '')
    )
    .replace(/`([^`]+)`/g, '$1')            // inline code
    .replace(/\[([^\]]+)\]\([^)]+\)/g, '$1') // links
    .replace(/!\[([^\]]*)\]\([^)]+\)/g, '$1') // images
    .replace(/^[-*+]\s+/gm, '')             // unordered list bullets
    .replace(/^\d+\.\s+/gm, '')             // ordered list numbers
    .replace(/^>\s+/gm, '')                 // blockquotes
    .replace(/\|/g, ' ')                    // table pipes
    .replace(/^[-=]{3,}$/gm, '')            // hr / setext heading underlines
    .replace(/\n{3,}/g, '\n\n')             // collapse excess blank lines
    .trim();
}

function CopyMessageButtons({ content }: { content: string }) {
  const { t } = useTranslation();
  const [copiedMd, setCopiedMd] = useState(false);
  const [copiedTxt, setCopiedTxt] = useState(false);

  const copy = async (text: string, setFlag: (v: boolean) => void) => {
    try {
      await navigator.clipboard.writeText(text);
      setFlag(true);
      setTimeout(() => setFlag(false), 1500);
    } catch { /* ignore */ }
  };

  const btnStyle = (active: boolean) => ({
    border: 0,
    background: 'transparent',
    cursor: 'pointer',
    padding: '2px 6px',
    borderRadius: '4px',
    fontSize: '11px',
    color: active ? '#10b981' : '#9ca3af',
    fontFamily: 'inherit',
    lineHeight: '18px',
  } as const);

  return (
    <div style={{ display: 'flex', gap: '2px', marginTop: '6px' }}>
      <button type="button" style={btnStyle(copiedMd)} onClick={() => copy(content, setCopiedMd)}>
        {copiedMd ? `✓ ${t('chat.copied')}` : t('chat.copyMarkdown')}
      </button>
      <button type="button" style={btnStyle(copiedTxt)} onClick={() => copy(stripMarkdown(content), setCopiedTxt)}>
        {copiedTxt ? `✓ ${t('chat.copied')}` : t('chat.copyPlain')}
      </button>
    </div>
  );
}

function MessageStatusBadge({ status }: { status?: Message['status'] }) {
  const { t } = useTranslation();
  if (!status || status === 'completed') return null;

  const color = status === 'streaming' ? 'blue' : status === 'cancelled' ? 'yellow' : 'red';
  const label = status === 'streaming'
    ? t('chat.statusStreaming')
    : status === 'cancelled'
      ? t('chat.statusCancelled')
      : t('chat.statusFailed');
  const text = status === 'streaming'
    ? t('chat.statusStreamingText')
    : status === 'cancelled'
      ? t('chat.statusCancelledText')
      : t('chat.statusFailedText');

  return (
    <div style={{ marginTop: '6px', display: 'flex', alignItems: 'center', gap: '8px', flexWrap: 'wrap' }}>
      <Badge size="xs" color={color as any} variant="light">
        {label}
      </Badge>
      <Text size="xs" style={{ color: '#6b7280' }}>
        {text}
      </Text>
    </div>
  );
}

// Module-level cache: workspaceId|path → blob URL (or pending fetch promise).
// Lives for the page session so re-renders / scroll-back don't re-fetch.
const workspaceImageCache = new Map<string, string>();
const workspaceImagePending = new Map<string, Promise<string>>();

async function loadWorkspaceImage(workspaceId: string, path: string): Promise<string> {
  const key = `${workspaceId}|${path}`;
  const cached = workspaceImageCache.get(key);
  if (cached) return cached;
  const pending = workspaceImagePending.get(key);
  if (pending) return pending;
  const promise = (async () => {
    const url = `${API_BASE_URL}/api/workspace/${encodeURIComponent(workspaceId)}/file?path=${encodeURIComponent(path)}`;
    const res = await fetchWithAuth(url);
    if (!res.ok) throw new Error(`HTTP ${res.status}`);
    const blob = await res.blob();
    const blobUrl = URL.createObjectURL(blob);
    workspaceImageCache.set(key, blobUrl);
    workspaceImagePending.delete(key);
    return blobUrl;
  })();
  workspaceImagePending.set(key, promise);
  promise.catch(() => workspaceImagePending.delete(key));
  return promise;
}

function WorkspaceImageThumb({
  workspaceId,
  path,
  alt,
  onClick,
}: {
  workspaceId: string | null | undefined;
  path: string;
  alt: string;
  onClick?: () => void;
}) {
  const [src, setSrc] = useState<string | null>(() => {
    if (!workspaceId) return null;
    return workspaceImageCache.get(`${workspaceId}|${path}`) ?? null;
  });
  const [failed, setFailed] = useState(false);

  useEffect(() => {
    if (!workspaceId || !path || src) return;
    let alive = true;
    loadWorkspaceImage(workspaceId, path)
      .then(url => { if (alive) setSrc(url); })
      .catch(() => { if (alive) setFailed(true); });
    return () => { alive = false; };
  }, [workspaceId, path, src]);

  if (failed) {
    return (
      <div style={{
        width: 64, height: 64, borderRadius: 6,
        background: '#fef2f2', color: '#b91c1c',
        display: 'flex', alignItems: 'center', justifyContent: 'center',
        fontSize: 10, flexShrink: 0,
      }}>
        load failed
      </div>
    );
  }
  if (!src) {
    return (
      <div style={{
        width: 64, height: 64, borderRadius: 6,
        background: '#f3f4f6',
        display: 'flex', alignItems: 'center', justifyContent: 'center',
        flexShrink: 0,
      }}>
        <LoadingOutlined style={{ fontSize: 16, color: '#9ca3af' }} />
      </div>
    );
  }
  return (
    <img
      src={src}
      alt={alt}
      onClick={onClick}
      style={{
        width: 64, height: 64, objectFit: 'cover',
        borderRadius: 6, flexShrink: 0,
        cursor: onClick ? 'zoom-in' : 'default',
        display: 'block',
      }}
    />
  );
}

function ImageAttachmentList({
  attachments,
  isUser,
  workspaceId,
  onOpen,
  onRemove,
}: {
  attachments?: ImageAttachment[];
  isUser: boolean;
  workspaceId: string | null | undefined;
  onOpen?: (path: string) => void;
  onRemove?: (id: string) => void;
}) {
  if (!attachments || attachments.length === 0) return null;
  return (
    <div style={{ display: 'flex', flexWrap: 'wrap', gap: '8px', marginBottom: '8px' }}>
      {attachments.map(item => {
        const isProcessing = item.status === 'processing';
        const isReady = item.status === 'ready' || item.status === 'uploaded';
        const isFailed = item.status === 'failed';
        const statusColor = isReady ? '#10b981' : isFailed ? '#ef4444' : '#f59e0b';
        const showThumb = isReady && !!item.preview_path && !!workspaceId;
        return (
          <div
            key={item.id}
            style={{
              display: 'flex',
              alignItems: 'center',
              gap: '8px',
              maxWidth: '320px',
              padding: '6px 8px',
              borderRadius: '8px',
              border: `1px solid ${isUser ? 'rgba(255,255,255,0.35)' : '#d1d5db'}`,
              backgroundColor: isUser ? 'rgba(255,255,255,0.12)' : '#fff',
              color: isUser ? '#fff' : '#111827',
            }}
          >
            {showThumb ? (
              <WorkspaceImageThumb
                workspaceId={workspaceId}
                path={item.preview_path}
                alt={item.filename}
                onClick={onOpen ? () => onOpen(item.preview_path) : undefined}
              />
            ) : isProcessing ? (
              <LoadingOutlined style={{ fontSize: 13, color: statusColor }} />
            ) : (
              <span style={{ width: 8, height: 8, borderRadius: 999, backgroundColor: statusColor, flexShrink: 0 }} />
            )}
            <div style={{ display: 'flex', flexDirection: 'column', gap: 2, minWidth: 0 }}>
              <button
                type="button"
                onClick={() => item.preview_path && onOpen?.(item.preview_path)}
                disabled={!item.preview_path || !onOpen}
                style={{
                  border: 0,
                  padding: 0,
                  background: 'transparent',
                  color: 'inherit',
                  cursor: item.preview_path && onOpen ? 'pointer' : 'default',
                  overflow: 'hidden',
                  textOverflow: 'ellipsis',
                  whiteSpace: 'nowrap',
                  fontSize: 12,
                  textAlign: 'left',
                }}
                title={item.filename}
              >
                {item.filename}
              </button>
              {item.kind && <span style={{ fontSize: 11, opacity: 0.75 }}>{item.kind}</span>}
            </div>
            {onRemove && (
              <button
                type="button"
                onClick={() => onRemove(item.id)}
                style={{
                  border: 0,
                  background: 'transparent',
                  color: 'inherit',
                  cursor: 'pointer',
                  padding: 0,
                  lineHeight: 1,
                  marginLeft: 'auto',
                }}
                aria-label="Remove image"
              >
                <CloseOutlined style={{ fontSize: 11 }} />
              </button>
            )}
          </div>
        );
      })}
    </div>
  );
}

function ThinkingIndicator({ startTime, thinkingText, runningTool, thoughtCount }: {
  startTime: number;
  thinkingText: string;
  runningTool: string | null;
  thoughtCount: number;
}) {
  const { t } = useTranslation();
  const [elapsed, setElapsed] = useState(0);

  useEffect(() => {
    const id = setInterval(() => {
      setElapsed(Math.floor((Date.now() - startTime) / 1000));
    }, 1000);
    return () => clearInterval(id);
  }, [startTime]);

  const isTool = !!runningTool;
  // Models like GPT-5 emit metadata-only reasoning chunks (no text) before any
  // visible output, so flip out of the Waiting state as soon as any thought
  // event arrives, even without accumulated text.
  const isReasoning = !isTool && thoughtCount > 0;
  const isWaiting = !isTool && !isReasoning && !thinkingText;

  const label = isTool ? runningTool! : isWaiting ? t('chat.waiting') : t('chat.thinking');

  return (
    <div style={{ display: 'inline-flex', flexDirection: 'column', maxWidth: '100%' }}>
      {/* Single-line indicator */}
      <div
        style={{ display: 'flex', alignItems: 'center', gap: '6px', padding: '2px 0' }}
      >
        {/* Pulsing dot */}
        <div style={{
          width: 6, height: 6, borderRadius: '50%',
          backgroundColor: isTool ? '#60a5fa' : isWaiting ? '#fbbf24' : '#a78bfa',
          flexShrink: 0,
          animation: 'pulse 1.4s ease-in-out infinite',
        }} />
        <Text size="xs" style={{ color: '#6b7280', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', flex: 1, minWidth: 0 }}>
          {label}
        </Text>
        <Text size="xs" style={{ color: '#9ca3af', fontVariantNumeric: 'tabular-nums', flexShrink: 0 }}>
          {elapsed}s
        </Text>
      </div>
    </div>
  );
}

type RawMessage = Omit<Message, 'timestamp'> & { timestamp: string | Date };

interface IssueReadyDetail {
  issueId: string;
  issueMeta: {
    summary?: string;
    status?: string;
    priority?: string;
    component?: string;
    alert_name?: string;
    rule?: string | null;
    rule_expr?: string | null;
    rule_for?: string | null;
    created?: string;
    cluster_id?: string;
    tenant_id?: string;
  };
  clusterMeta?: {
    cluster_id?: string;
    clinic_org_id?: string;
    cluster_type?: string;
    cluster_type_v2?: string;
    cluster_name?: string;
    tidb_version?: string;
    cloud_provider?: string;
    region?: string;
    tenant_id?: string;
  } | null;
}

interface ChatPanelProps {
  sessionId: string;
  /** When true, disables message input (shared workspace viewed by non-owner) */
  readOnly?: boolean;
}

interface WorkspaceUsageStatus {
  contextUsed?: number;
  contextSize?: number;
  costAmount?: number;
  costCurrency?: string;
  costLimitUsd?: number;
  compactedAt?: number;
  compactedAuto?: boolean;
  compactedOverflow?: boolean;
}

interface ContextDebugExample {
  path: string;
  label: string;
  detail?: string;
  source_ref?: string;
  kind?: string;
  bytes: number;
  estimated_tokens: number;
  scaled_context_tokens: number | null;
  percent_of_used_context: number | null;
  percent_of_context_window: number | null;
}

interface ContextDebugCategory {
  key: string;
  label: string;
  scope: string;
  included_in_context_estimate: boolean;
  description: string;
  bytes: number;
  estimated_tokens: number;
  item_count: number;
  scaled_context_tokens: number | null;
  percent_of_used_context: number | null;
  percent_of_context_window: number | null;
  examples: ContextDebugExample[];
}

interface ContextDebugReport {
  workspace_id: string;
  generated_at: string;
  actual_context: {
    context_used: number;
    context_size: number;
    percent: number | null;
    session_id: string;
    created_at: string;
    input_tokens: number;
    output_tokens: number;
    reasoning_tokens: number;
    cache_read_tokens: number;
    cache_write_tokens: number;
  };
  estimate_model: {
    token_estimator: string;
    included_estimated_tokens: number;
    scaled_to_actual_context: boolean;
  };
  categories: ContextDebugCategory[];
  notes: string[];
}

// ---------------------------------------------------------------------------
// API-backed message persistence (keyed by workspace ID, not session ID,
// so history survives session loss/recreation after pod restarts)
// ---------------------------------------------------------------------------

type LoadMessagesOptions = {
  includeParts?: boolean;
  includeActiveParts?: boolean;
  fallbackToWelcome?: boolean;
};

async function loadMessagesFromAPI(
  workspaceId: string,
  welcome: Message,
  {
    includeParts = true,
    includeActiveParts = false,
    fallbackToWelcome = true,
  }: LoadMessagesOptions = {},
): Promise<Message[]> {
  try {
    const params = new URLSearchParams();
    if (!includeParts) params.set('include_parts', 'false');
    if (includeActiveParts) params.set('include_active_parts', 'true');
    const qs = params.toString();
    const res = await fetchWithAuth(
      `${API_BASE_URL}/api/workspace/${encodeURIComponent(workspaceId)}/messages${qs ? `?${qs}` : ''}`,
    );
    if (res.ok) {
      const json = await res.json();
      const msgs: Message[] = ((json.messages ?? []) as RawMessage[]).map((m) => ({
        ...m,
        timestamp: new Date(m.timestamp),
        status: m.status ?? 'completed',
      }));
      if (msgs.length > 0) return msgs;
    }
  } catch (e) {
    console.warn('Failed to load chat history:', e);
  }
  return fallbackToWelcome ? [welcome] : [];
}

async function fetchWorkspaceJson(workspaceId: string, path: string): Promise<Record<string, unknown> | null> {
  try {
    const params = new URLSearchParams({ path, _: String(Date.now()) });
    const res = await fetchWithAuth(
      `${API_BASE_URL}/api/workspace/${encodeURIComponent(workspaceId)}/file?${params.toString()}`,
      { cache: 'no-store' },
    );
    if (!res.ok) return null;
    const value = await res.json();
    return value && typeof value === 'object' && !Array.isArray(value)
      ? value as Record<string, unknown>
      : null;
  } catch {
    return null;
  }
}

function hasExactlyKeys(value: Record<string, unknown>, expected: string[]): boolean {
  const actual = Object.keys(value).sort();
  const wanted = [...expected].sort();
  return actual.length === wanted.length && actual.every((key, index) => key === wanted[index]);
}

function isStringArray(value: unknown): value is string[] {
  return Array.isArray(value) && value.every(item => typeof item === 'string');
}

function isValidRoutingScope(value: unknown): boolean {
  if (!value || typeof value !== 'object' || Array.isArray(value)) return false;
  const scope = value as Record<string, unknown>;
  if (typeof scope.cluster_id !== 'string' || scope.cluster_id.trim().length === 0) return false;
  if (!scope.time_range || typeof scope.time_range !== 'object' || Array.isArray(scope.time_range)) return false;
  const timeRange = scope.time_range as Record<string, unknown>;
  return typeof timeRange.start === 'string'
    && timeRange.start.trim().length > 0
    && typeof timeRange.end === 'string'
    && timeRange.end.trim().length > 0;
}

async function loadDiagnosisCheckpoint(workspaceId: string): Promise<DiagnosisCheckpointState | null> {
  const [timeValue, routeValue] = await Promise.all([
    fetchWorkspaceJson(workspaceId, 'evidences/agent-runs/diagnosis-time-confirmation.json'),
    fetchWorkspaceJson(workspaceId, 'evidences/agent-runs/diagnosis-routing.json'),
  ]);

  if (timeValue?.status === 'awaiting_user') {
    const range = timeValue.normalized_range;
    const normalizedRange = range && typeof range === 'object' && !Array.isArray(range)
      ? range as Record<string, unknown>
      : null;
    const checkpoint: DiagnosisTimeCheckpoint = {
      kind: 'time',
      status: 'awaiting_user',
      source_expression: String(timeValue.source_expression || ''),
      normalized_range: normalizedRange
        && typeof normalizedRange.start === 'string'
        && typeof normalizedRange.end === 'string'
        && typeof normalizedRange.timezone === 'string'
        ? {
            start: normalizedRange.start,
            end: normalizedRange.end,
            timezone: normalizedRange.timezone,
          }
        : null,
      clarification_needed: typeof timeValue.clarification_needed === 'string'
        ? timeValue.clarification_needed
        : null,
    };
    return checkpoint;
  }

  if (
    routeValue?.schema_version === '2.0'
    && routeValue.status === 'awaiting_user'
    && routeValue.selection === null
    && isValidRoutingScope(routeValue.scope)
    && hasExactlyKeys(routeValue, [
      'schema_version',
      'status',
      'scope',
      'candidates',
      'recommended_route',
      'selection',
    ])
    && Array.isArray(routeValue.candidates)
    && routeValue.candidates.length >= 1
    && routeValue.candidates.length <= 3
  ) {
    const candidates = routeValue.candidates.flatMap(candidate => {
      if (!candidate || typeof candidate !== 'object' || Array.isArray(candidate)) return [];
      const value = candidate as Record<string, unknown>;
      if (
        !hasExactlyKeys(value, [
          'route',
          'label',
          'routing_confidence',
          'evidence_for',
          'evidence_against',
        ])
        || !isStandardDiagnosisRoute(value.route)
        || typeof value.label !== 'string'
        || value.label.trim().length === 0
        || typeof value.routing_confidence !== 'number'
        || value.routing_confidence < 0
        || value.routing_confidence > 1
        || !isStringArray(value.evidence_for)
        || !isStringArray(value.evidence_against)
      ) return [];
      return [{
        route: value.route,
        label: value.label,
        routing_confidence: value.routing_confidence,
        evidence_for: value.evidence_for,
        evidence_against: value.evidence_against,
      }];
    });
    const recommendedRoute = routeValue.recommended_route;
    const uniqueRoutes = new Set(candidates.map(candidate => candidate.route));
    if (
      candidates.length === routeValue.candidates.length
      && uniqueRoutes.size === candidates.length
      && isStandardDiagnosisRoute(recommendedRoute)
      && candidates.some(candidate => candidate.route === recommendedRoute)
    ) {
      const checkpoint: DiagnosisRoutingCheckpoint = {
        kind: 'routing',
        status: 'awaiting_user',
        candidates,
        recommended_route: recommendedRoute,
      };
      return checkpoint;
    }
  }

  return null;
}

async function fetchMessagesStatus(workspaceId: string): Promise<{ updated_at: string; count: number } | null> {
  try {
    const res = await fetchWithAuth(`${API_BASE_URL}/api/workspace/${encodeURIComponent(workspaceId)}/messages/status`);
    if (res.ok) return await res.json();
  } catch (e) {
    console.warn('Failed to fetch messages status:', e);
  }
  return null;
}

async function appendMessagesToAPI(workspaceId: string, messages: Message[]): Promise<void> {
  const MAX_RETRIES = 3;
  for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
    try {
      const res = await fetchWithAuth(`${API_BASE_URL}/api/workspace/${encodeURIComponent(workspaceId)}/messages`, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ messages }),
      });
      if (res.ok) return;
      if (res.status >= 400 && res.status < 500) {
        console.warn(`Failed to append chat messages: HTTP ${res.status}`);
        return;
      }
    } catch (e) {
      if (attempt === MAX_RETRIES) {
        console.warn('Failed to append chat messages after retries:', e);
        return;
      }
    }
    await new Promise(r => setTimeout(r, 1000 * Math.pow(2, attempt)));
  }
}

async function uploadImageAttachment(workspaceId: string, file: File): Promise<ImageAttachment> {
  const form = new FormData();
  form.append('file', file);
  const res = await fetchWithAuth(
    `${API_BASE_URL}/api/workspace/${encodeURIComponent(workspaceId)}/image-attachments`,
    { method: 'POST', body: form }
  );
  const json = await res.json().catch(() => ({}));
  if (!res.ok) {
    throw new Error(typeof json.detail === 'string' ? json.detail : `HTTP ${res.status}`);
  }
  return json.attachment as ImageAttachment;
}

async function fetchImageAttachments(workspaceId: string, ids: string[]): Promise<ImageAttachment[]> {
  if (ids.length === 0) return [];
  const qs = new URLSearchParams({ ids: ids.join(',') });
  const res = await fetchWithAuth(
    `${API_BASE_URL}/api/workspace/${encodeURIComponent(workspaceId)}/image-attachments?${qs.toString()}`
  );
  if (!res.ok) return [];
  const json = await res.json();
  return (json.attachments ?? []) as ImageAttachment[];
}

async function clearMessagesAPI(workspaceId: string): Promise<void> {
  try {
    await fetchWithAuth(`${API_BASE_URL}/api/workspace/${encodeURIComponent(workspaceId)}/messages`, {
      method: 'DELETE',
    });
  } catch (e) {
    console.warn('Failed to clear chat messages:', e);
  }
}

async function fetchWorkspaceUsageStatus(workspaceId: string, sessionId?: string): Promise<WorkspaceUsageStatus | null> {
  try {
    const qs = new URLSearchParams({ workspace_id: workspaceId });
    if (sessionId) qs.set('session_id', sessionId);
    const res = await fetchWithAuth(`${API_BASE_URL}/api/chat/commands/stats?${qs.toString()}`);
    if (!res.ok) return null;
    const d = await res.json();
    return {
      contextUsed: Number(d.context_used ?? 0),
      contextSize: Number(d.context_size ?? 0),
      costAmount: Number(d.total_cost ?? 0),
      costCurrency: String(d.cost_currency || 'USD'),
      costLimitUsd: Number(d.workspace_cost_limit_usd ?? 0),
    };
  } catch (e) {
    console.warn('Failed to fetch workspace usage status:', e);
    return null;
  }
}

async function fetchWorkspaceContextDebug(workspaceId: string): Promise<ContextDebugReport> {
  const res = await fetchWithAuth(`${API_BASE_URL}/api/workspace/${encodeURIComponent(workspaceId)}/debug/context`);
  const body = await res.json().catch(() => ({}));
  if (!res.ok) {
    const detail = typeof body?.detail === 'string' ? body.detail : `HTTP ${res.status}`;
    throw new Error(detail);
  }
  return body as ContextDebugReport;
}

// Returns true when both arrays have the exact same message IDs in the same order.
// Used by owner-mode polling to skip setMessages (and the save effect) when the
// detected change was caused by our own last save, not by an external source.
function sameMessageIds(a: Message[], b: Message[]): boolean {
  if (a.length !== b.length) return false;
  return a.every((m, i) => m.id === b[i].id);
}

function mergeFetchedMessagesWithLocalState(prev: Message[], fetched: Message[]): Message[] {
  if (sameMessageIds(prev, fetched)) return prev;
  return fetched;
}

function hydrateMessagesWithParts(prev: Message[], hydrated: Message[]): Message[] {
  if (hydrated.length === 0) return prev;

  const hydratedById = new Map(hydrated.map(msg => [msg.id, msg]));
  let changed = false;
  const next = prev.map(msg => {
    const full = hydratedById.get(msg.id);
    if (!full || msg.status === 'streaming') return msg;
    if (!full.parts || full.parts.length === 0) return msg;
    if (
      msg.parts
      && msg.parts.length === full.parts.length
      && JSON.stringify(msg.parts) === JSON.stringify(full.parts)
    ) return msg;
    changed = true;
    return { ...msg, parts: full.parts };
  });

  return changed ? next : prev;
}

function buildWelcomeMessage(t: (key: any) => string): Message {
  return {
    id: 'welcome',
    role: 'assistant',
    content: `${t('chat.welcomeTitle')}\n\n${t('chat.welcomeBody')}`,
    timestamp: new Date(),
  };
}

function createLocalCommandUserMessage(command: string, startedAt = Date.now()): Message {
  return {
    id: `user-command-${startedAt}`,
    role: 'user',
    content: command,
    timestamp: new Date(startedAt),
  };
}

function createLocalCommandSystemMessage(content: string, startedAt: number): Message {
  return {
    id: `system-command-${startedAt}`,
    role: 'system',
    content,
    timestamp: new Date(startedAt + 1),
  };
}

function formatCompactNumber(value: number | undefined): string {
  const n = Number(value ?? 0);
  if (!Number.isFinite(n)) return '0';
  if (Math.abs(n) >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
  if (Math.abs(n) >= 1_000) return `${(n / 1_000).toFixed(1)}k`;
  return n.toLocaleString();
}

function formatCost(value: number | undefined, currency = 'USD'): string {
  const amount = Number(value ?? 0);
  if (!Number.isFinite(amount) || amount <= 0) return '$0.00';
  return `${currency === 'USD' ? '$' : ''}${amount.toFixed(2)}${currency !== 'USD' ? ` ${currency}` : ''}`;
}

function formatDebugNumber(value: number | null | undefined): string {
  if (value == null || !Number.isFinite(Number(value))) return '-';
  return Math.round(Number(value)).toLocaleString();
}

function formatDebugPercent(value: number | null | undefined): string {
  if (value == null || !Number.isFinite(Number(value))) return '-';
  return `${Number(value).toFixed(2)}%`;
}

function formatDebugPath(path: string): string {
  return path.length <= 72 ? path : `...${path.slice(-69)}`;
}

type Translate = (key: TranslationKey, vars?: Record<string, string | number>) => string;

function formatContextDebugMessage(report: ContextDebugReport, t: Translate): string {
  const actual = report.actual_context;
  const includedRows = report.categories.filter(row => row.included_in_context_estimate);
  const inventoryRows = report.categories.filter(row => !row.included_in_context_estimate);

  const lines: string[] = [
    '**Context Debug — local only**',
    '',
    `Workspace: \`${report.workspace_id}\``,
    `Context window: **${formatDebugNumber(actual.context_used)} / ${formatDebugNumber(actual.context_size)}** (${formatDebugPercent(actual.percent)})`,
    `Session: \`${actual.session_id || 'n/a'}\``,
    `${t('chat.cmd.statsReasoningTokens')}: ${formatDebugNumber(actual.reasoning_tokens)}`,
    `Cache read/write: ${formatDebugNumber(actual.cache_read_tokens)} / ${formatDebugNumber(actual.cache_write_tokens)}`,
    '',
    '### Actually in context',
    '',
    '| Source | Scope | Items | Scaled tokens | Used % | Est tokens |',
    '|--------|-------|------:|--------------:|-------:|-----------:|',
  ];

  if (includedRows.length === 0) {
    lines.push('| _none_ | - | - | - | - | - |');
  } else {
    includedRows.forEach(row => {
      lines.push([
        `| ${row.label}`,
        row.scope,
        formatDebugNumber(row.item_count),
        formatDebugNumber(row.scaled_context_tokens),
        formatDebugPercent(row.percent_of_used_context),
        `${formatDebugNumber(row.estimated_tokens)} |`,
      ].join(' | '));
    });
  }

  const topExamples = includedRows
    .flatMap(row => row.examples.slice(0, 3).map(example => ({ row, example })))
    .sort((a, b) => b.example.estimated_tokens - a.example.estimated_tokens)
    .slice(0, 8);

  if (topExamples.length > 0) {
    lines.push('', '### Largest examples');
    lines.push('');
    lines.push('| Source | Item | Scaled tokens | Used % |');
    lines.push('|--------|------|--------------:|-------:|');
    topExamples.forEach(({ row, example }) => {
      const item = example.label || formatDebugPath(example.path);
      lines.push(
        `| ${row.label} | ${item} | ${formatDebugNumber(example.scaled_context_tokens)} | ${formatDebugPercent(example.percent_of_used_context)} |`
      );
    });
  }

  if (inventoryRows.length > 0) {
    lines.push('', '### Available on disk');
    lines.push('');
    lines.push('| Source | Items | Est tokens | Bytes |');
    lines.push('|--------|------:|-----------:|------:|');
    inventoryRows.forEach(row => {
      lines.push(`| ${row.label} | ${formatDebugNumber(row.item_count)} | ${formatDebugNumber(row.estimated_tokens)} | ${formatDebugNumber(row.bytes)} |`);
    });
  }

  lines.push(
    '',
    '_This message is not persisted. Refreshing the workspace removes it._',
    '_OpenCode gives exact total context usage, not exact per-source allocation; rows are semantic-source estimates scaled to the latest actual context_used._',
  );
  return lines.join('\n');
}

function WorkspaceUsageBar({ status, isMobile }: { status: WorkspaceUsageStatus | null; isMobile: boolean }) {
  if (!status) return null;
  const contextSize = Number(status.contextSize ?? 0);
  const contextUsed = Number(status.contextUsed ?? 0);
  const pct = contextSize > 0 ? Math.min(100, Math.max(0, Math.round((contextUsed / contextSize) * 100))) : 0;
  const cost = Number(status.costAmount ?? 0);
  const limit = Number(status.costLimitUsd ?? 0);
  const contextTone = pct >= 90 ? '#dc2626' : pct >= 75 ? '#d97706' : '#2563eb';
  const costTone = limit > 0 && cost >= limit ? '#dc2626' : limit > 0 && cost >= limit * 0.8 ? '#d97706' : '#475569';
  const hasCompactionSignal = status.compactedAt != null;
  const compactLabel = status.compactedOverflow ? 'Context compacted after overflow' : 'Context compacted';

  return (
    <div
      style={{
        display: 'flex',
        alignItems: 'center',
        gap: '10px',
        flexWrap: 'wrap',
        padding: '7px 10px',
        marginBottom: '8px',
        border: '1px solid #e5e7eb',
        borderRadius: '8px',
        background: '#fff',
      }}
    >
      <div style={{ display: 'flex', alignItems: 'center', gap: '7px', minWidth: isMobile ? '100%' : 180 }}>
        <Text size="xs" fw={600} style={{ color: contextTone, whiteSpace: 'nowrap' }}>
          Context {pct}%
        </Text>
        <div style={{ width: isMobile ? '100%' : 92, height: 5, borderRadius: 999, background: '#e5e7eb', overflow: 'hidden' }}>
          <div
            style={{
              width: `${pct}%`,
              height: '100%',
              background: contextTone,
              transition: 'width 160ms ease',
            }}
          />
        </div>
        <Text size="xs" style={{ color: '#64748b', fontFamily: 'monospace', whiteSpace: 'nowrap' }}>
          {`${formatCompactNumber(contextUsed)} / ${formatCompactNumber(contextSize)}`}
        </Text>
      </div>
      <Text size="xs" fw={600} style={{ color: costTone, whiteSpace: 'nowrap' }}>
        Cost {formatCost(cost, status.costCurrency || 'USD')}
        {limit > 0 ? ` / $${limit.toFixed(2)}` : ''}
      </Text>
      {hasCompactionSignal && (
        <Badge size="xs" color={status.compactedOverflow ? 'red' : 'blue'} variant="light">
          {compactLabel}
        </Badge>
      )}
    </div>
  );
}

type MessageSegment =
  | { kind: 'text'; part: TextPart }
  | { kind: 'tools'; parts: MessagePart[]; label?: string };

function buildMessageSegments(parts: MessagePart[]): MessageSegment[] {
  const segments: MessageSegment[] = [];
  let pendingNonText: MessagePart[] = [];

  const flushNonText = (label?: string) => {
    if (pendingNonText.length > 0) {
      segments.push({ kind: 'tools', parts: [...pendingNonText], label });
      pendingNonText = [];
    }
  };

  for (const part of parts) {
    if (part.type === 'text' && !isSubagentTextPart(part)) {
      flushNonText();
      segments.push({ kind: 'text', part });
    } else {
      pendingNonText.push(part);
    }
  }
  flushNonText();

  const merged: MessageSegment[] = [];
  for (let i = 0; i < segments.length; i++) {
    const seg = segments[i];
    if (seg.kind === 'text') {
      const text = seg.part.text.trim();
      const isShort = text.length < 15 && !text.includes('\n');
      const next = segments[i + 1];
      if (isShort && next?.kind === 'tools') {
        merged.push({ ...next, label: text.replace(/[:：\s]+$/, '') });
        i += 1;
        continue;
      }
    }
    merged.push(seg);
  }
  return merged;
}

// Ordinary code-diagnosis authorization notices should read like a normal
// assistant reply, rather than exposing the internal reasoning timeline.
function isSimpleAssistantReply(parts: MessagePart[]): boolean {
  if (parts.length === 0 || parts.some(part => part.type === 'tool')) return false;
  if (parts.some(part => part.type === 'text' && isSubagentTextPart(part))) return false;
  const text = parts.filter((part): part is TextPart => part.type === 'text')
    .map(part => part.text)
    .join('\n');
  return parts.every(part => part.type === 'reasoning' || part.type === 'text')
    && text.includes('INVESTIBOOK_CODE_DIAGNOSIS_UI_V1');
}

const ChatMessageBubble = memo(function ChatMessageBubble({
  message,
  isMobile,
  workspaceId,
  isTyping,
  isLastMessage,
  onOpenFile,
  onForceContinue,
}: {
  message: Message;
  isMobile: boolean;
  workspaceId: string | null | undefined;
  isTyping: boolean;
  isLastMessage: boolean;
  onOpenFile: (path: string) => void;
  onForceContinue?: () => void;
}) {
  const { t } = useTranslation();
  const segments = useMemo(
    () => (message.role === 'assistant' && message.parts && message.parts.length > 0 ? buildMessageSegments(message.parts) : []),
    [message.parts, message.role],
  );
  const simpleAssistantReply = message.role === 'assistant'
    && message.parts
    && isSimpleAssistantReply(message.parts);

  const isAssistantStreaming = isTyping && isLastMessage;

  return (
    <div
      style={{
        display: 'flex',
        justifyContent: message.role === 'user' ? 'flex-end' : 'flex-start'
      }}
    >
      <Box
        p="md"
        style={{
          maxWidth: message.issueInfo ? (isMobile ? '96%' : '760px') : (isMobile ? '92%' : '80%'),
          width: message.issueInfo ? (isMobile ? '96%' : '66%') : undefined,
          borderRadius: message.issueInfo ? '8px' : '12px',
          backgroundColor: message.role === 'user' ? '#3b82f6' : message.issueInfo ? '#ffffff' : message.role === 'system' ? '#eff6ff' : '#f3f4f6',
          border: message.role === 'system' ? '1px solid #bfdbfe' : undefined,
        }}
      >
        <ImageAttachmentList
          attachments={message.attachments}
          isUser={message.role === 'user'}
          workspaceId={workspaceId}
          onOpen={onOpenFile}
        />
        {message.role === 'assistant' && message.status === 'streaming' && message.codeDiagnosisProgress && (
          <CodeDiagnosisProgressView progress={message.codeDiagnosisProgress} />
        )}
        {message.role === 'system' && message.issueInfo ? (
          <IssueInfoSystemMessage issueInfo={message.issueInfo} isMobile={isMobile} />
        ) : simpleAssistantReply ? (
          <MarkdownContent content={message.content} isUser={false} />
        ) : segments.length > 0 ? (
          <Stack gap="xs">
            {segments.map(seg => {
              if (seg.kind === 'text') {
                return (
                  <MarkdownContent
                    key={seg.part.id}
                    content={seg.part.text || ''}
                    isUser={false}
                  />
                );
              }
              const key = seg.parts[0].id;
              return (
                <ToolGroupView
                  key={key}
                  parts={seg.parts}
                  label={seg.label}
                  isStreaming={isAssistantStreaming}
                  workspaceId={workspaceId ?? undefined}
                />
              );
            })}
          </Stack>
        ) : (
          <MarkdownContent
            content={message.content}
            isUser={message.role === 'user'}
          />
        )}

        {message.role === 'assistant' && !isTyping && message.status !== 'streaming' && message.content && (
          <CopyMessageButtons content={message.content} />
        )}

        {message.role === 'assistant' && message.timingMeta && !isTyping && (
          <MessageTimingBadge meta={message.timingMeta} />
        )}

        {message.role === 'assistant' && !isTyping && (
          <MessageStatusBadge status={message.status} />
        )}

        {message.isCostLimitBlocked && onForceContinue && (
          <Box mt="sm">
            <Button size="xs" variant="filled" color="blue" onClick={onForceContinue}>
              {t('chat.continueDiagnosis')}
            </Button>
          </Box>
        )}

        {message.role === 'user' && (
          <Text
            size="xs"
            mt={6}
            style={{
              color: '#fff',
              opacity: 0.7
            }}
          >
            {dayjs(message.timestamp).format('HH:mm:ss')}
          </Text>
        )}
      </Box>
    </div>
  );
});

const ChatMessageList = memo(function ChatMessageList({
  scrollContainerRef,
  messagesEndRef,
  messages,
  isHistoryLoading,
  isTyping,
  thinkingText,
  runningToolTitle,
  thoughtCount,
  typingStartTime,
  isMobile,
  workspaceId,
  onOpenFile,
  onForceContinue,
  checkpointContent,
}: {
  scrollContainerRef: Ref<HTMLDivElement>;
  messagesEndRef: Ref<HTMLDivElement>;
  messages: Message[];
  isHistoryLoading: boolean;
  isTyping: boolean;
  thinkingText: string;
  runningToolTitle: string | null;
  thoughtCount: number;
  typingStartTime: number;
  isMobile: boolean;
  workspaceId: string | null | undefined;
  onOpenFile: (path: string) => void;
  onForceContinue?: () => void;
  checkpointContent?: ReactNode;
}) {
  const { t } = useTranslation();
  const lastMessageId = messages[messages.length - 1]?.id;
  const hasStreamingCodeDiagnosis = messages.some(message =>
    message.role === 'assistant'
    && message.status === 'streaming'
    && message.codeDiagnosisProgress,
  );

  return (
    <Box
      ref={scrollContainerRef}
      style={{
        flex: 1,
        overflowY: 'auto',
        padding: isMobile ? '12px' : '24px',
        display: 'flex',
        flexDirection: 'column',
        gap: isMobile ? '12px' : '16px',
        minHeight: 0,
      }}
    >
      {isHistoryLoading && messages.length === 0 && (
        <Box p="md">
          <Text size="sm" c="carbon.5">{t('chat.loadingHistory')}</Text>
        </Box>
      )}

      {messages.map((message) => (
        <ChatMessageBubble
          key={message.id}
          message={message}
          isMobile={isMobile}
          workspaceId={workspaceId}
          isTyping={isTyping}
          isLastMessage={message.id === lastMessageId}
          onOpenFile={onOpenFile}
          onForceContinue={onForceContinue}
        />
      ))}

      {isTyping && !hasStreamingCodeDiagnosis && (
        <div style={{ display: 'flex', justifyContent: 'flex-start' }}>
          <ThinkingIndicator startTime={typingStartTime} thinkingText={thinkingText} runningTool={runningToolTitle} thoughtCount={thoughtCount} />
        </div>
      )}

      {checkpointContent}

      <div ref={messagesEndRef} />
    </Box>
  );
});

export default function ChatPanel({ sessionId, readOnly = false }: ChatPanelProps) {
  const { t } = useTranslation();
  const [messages, setMessages] = useState<Message[]>([]);
  const [isHistoryLoading, setIsHistoryLoading] = useState(false);
  const [input, setInput] = useState('');
  const [codeDiagnosisOpen, setCodeDiagnosisOpen] = useState(false);
  const [isTyping, setIsTyping] = useState(false);
  const [thinkingText, setThinkingText] = useState('');
  const [thoughtCount, setThoughtCount] = useState(0);
  const [typingStartTime, setTypingStartTime] = useState(0);
  const typingStartTimeRef = useRef(0);
  const [runningToolTitle, setRunningToolTitle] = useState<string | null>(null);
  const [hasStreamedText, setHasStreamedText] = useState(false);
  const firstEventTimeRef = useRef<number | null>(null);
  const [feedbackDismissed, setFeedbackDismissed] = useState(false);
  const [feedbackRefreshToken, setFeedbackRefreshToken] = useState(0);
  const [diagnosisCheckpoint, setDiagnosisCheckpoint] = useState<DiagnosisCheckpointState | null>(null);
  const [modelTier, setModelTier] = useState<'standard' | 'pro'>('standard');
  const [agentRuntime, setAgentRuntime] = useState<AgentRuntime>(DEFAULT_AGENT_RUNTIME);
  const agentRuntimePreferenceVersionRef = useRef(0);
  const [showCommands, setShowCommands] = useState(false);
  const [commandFilter, setCommandFilter] = useState('');
  const [selectedCommandIdx, setSelectedCommandIdx] = useState(0);
  const [pendingAttachments, setPendingAttachments] = useState<ImageAttachment[]>([]);
  const [attachmentError, setAttachmentError] = useState<string | null>(null);
  const [usageStatus, setUsageStatus] = useState<WorkspaceUsageStatus | null>(null);
  const [jiraCommentDraft, setJiraCommentDraft] = useState<JiraCommentDraft | null>(null);
  const [isJiraCommentPublishing, setIsJiraCommentPublishing] = useState(false);
  const [jiraCommentEditorHeight, setJiraCommentEditorHeight] = useState(420);
  const [isJiraCommentEditorResizing, setIsJiraCommentEditorResizing] = useState(false);
  const jiraCommentEditorResizeRef = useRef<{ startY: number; startHeight: number } | null>(null);
  const messagesEndRef = useRef<HTMLDivElement>(null);
  const scrollContainerRef = useRef<HTMLDivElement>(null);
  const eventSourceRef = useRef<EventSource | null>(null);
  const activeRunIdRef = useRef<string | null>(null);
  const imageInputRef = useRef<HTMLInputElement>(null);
  const textareaRef = useRef<HTMLTextAreaElement>(null);
  // True while handleSend is actively streaming a response. Prevents
  // a slow loadMessagesFromAPI promise from overwriting the live state.
  const isStreamingRef = useRef(false);
  // Incremented whenever a newer load/stream cycle starts. Late hydration
  // responses from older cycles are ignored instead of patching stale parts.
  const hydrationEpochRef = useRef(0);
  // Last known updated_at from the server, used by the read-only poll to
  // avoid fetching full messages when nothing has changed.
  const lastStatusRef = useRef<string>('');
  // True when the user is scrolled to (or near) the bottom of the message list.
  // Auto-scroll only fires when this is true, so manually scrolling up is preserved.
  const isAtBottomRef = useRef(true);
  // Refs for stop functionality — allow handleStop to access streaming state
  // held inside handleSendInternal's closure.
  const streamingAssistantIdRef = useRef<string | null>(null);
  const streamingPartsOrderRef = useRef<string[]>([]);
  const streamingPartsMapRef = useRef<Map<string, MessagePart>>(new Map());
  const streamingContentRef = useRef('');
  const streamStopRequestedRef = useRef(false);
  /** True while CJK IME is composing — ignore Enter so confirm-key does not send the message. */
  const imeComposingRef = useRef(false);

  const { openFilePath, setOpenFilePath, workspaceId, onWorkspaceNameChange, addArtifact, triggerFilesRefresh, sources, refreshSources } = useWorkspace();
  const [selectedCodeContext, setSelectedCodeContext] = useState<ActiveCodeContext | null>(null);

  const refreshDiagnosisCheckpoint = useCallback(async () => {
    if (!workspaceId) {
      setDiagnosisCheckpoint(null);
      return;
    }
    setDiagnosisCheckpoint(await loadDiagnosisCheckpoint(workspaceId));
  }, [workspaceId]);

  useEffect(() => {
    if (isTyping) return;
    void refreshDiagnosisCheckpoint();
  }, [isTyping, refreshDiagnosisCheckpoint]);

  const loadedCodeContexts = useMemo(() => sources.flatMap((source) => {
    if (source.type !== 'git_repo' || source.status !== 'ready' || !source.meta?.repository_id) return [];
    const meta = source.meta as Record<string, unknown>;
    if (typeof meta.repository_id !== 'string' || typeof meta.repo !== 'string' || typeof meta.label !== 'string' || typeof meta.commit_sha !== 'string' || typeof meta.context_id !== 'string') return [];
    return [{ repositoryId: meta.repository_id, repo: meta.repo, label: meta.label, versionLabel: typeof meta.version_label === 'string' ? meta.version_label : undefined, commitSha: meta.commit_sha, contextId: meta.context_id }];
  }), [sources]);

  useEffect(() => {
    if (selectedCodeContext && !loadedCodeContexts.some(context => context.contextId === selectedCodeContext.contextId)) {
      setSelectedCodeContext(null);
    }
  }, [loadedCodeContexts, selectedCodeContext]);

  useEffect(() => {
    if (!getToken()) return;
    const preferenceVersion = agentRuntimePreferenceVersionRef.current;
    const handleRuntimeChange = (event: Event) => {
      const runtime = parseAgentRuntime(
        (event as CustomEvent<{ agentRuntime?: unknown }>).detail?.agentRuntime,
      );
      if (runtime) {
        agentRuntimePreferenceVersionRef.current += 1;
        setAgentRuntime(runtime);
      }
    };
    window.addEventListener(AGENT_RUNTIME_CHANGED_EVENT, handleRuntimeChange);
    void fetchMe()
      .then((me) => {
        if (agentRuntimePreferenceVersionRef.current !== preferenceVersion) return;
        setAgentRuntime(parseAgentRuntime(me.agent_runtime) ?? DEFAULT_AGENT_RUNTIME);
      })
      .catch(() => {
        // Authentication failures redirect in fetchWithAuth. Keep the configured
        // default for transient network failures.
      });
    return () => window.removeEventListener(AGENT_RUNTIME_CHANGED_EVENT, handleRuntimeChange);
  }, []);

  useEffect(() => {
    if (!workspaceId || readOnly) return;

    const releaseSession = () => {
      if (isStreamingRef.current) return;
      fetch(`${API_BASE_URL}/api/chat/session?workspace_id=${encodeURIComponent(workspaceId)}`, {
        method: 'DELETE',
        headers: authHeaders(),
        keepalive: true,
      }).catch(() => { /* best-effort session release */ });
    };

    const handleBeforeUnload = () => releaseSession();

    window.addEventListener('beforeunload', handleBeforeUnload);

    return () => {
      window.removeEventListener('beforeunload', handleBeforeUnload);
    };
  }, [readOnly, workspaceId]);

  // Recover observer controls after a refresh. The durable worker keeps the
  // turn alive; this poll only restores busy/Stop state while canonical message
  // SSE continues to hydrate content and tool snapshots.
  useEffect(() => {
    if (!workspaceId || !sessionId || readOnly || !getToken()) return;
    let active = true;
    let pollHandle: ReturnType<typeof setTimeout> | null = null;
    let recoveredRunId: string | null = null;

    const pollActiveRun = async () => {
      try {
        const response = await fetchWithAuth(
          `${API_BASE_URL}/api/sessions/${encodeURIComponent(sessionId)}?history_limit=1`
        );
        if (!response.ok || !active) return;
        const payload = await response.json() as {
          active_turn: { id: string; assistant_message_id: string } | null;
        };
        if (payload.active_turn) {
          recoveredRunId = payload.active_turn.id;
          activeRunIdRef.current = payload.active_turn.id;
          streamingAssistantIdRef.current = payload.active_turn.assistant_message_id;
          setIsTyping(true);
          if (!typingStartTimeRef.current) {
            const now = Date.now();
            typingStartTimeRef.current = now;
            setTypingStartTime(now);
          }
          pollHandle = setTimeout(pollActiveRun, 2_000);
        } else if (recoveredRunId && activeRunIdRef.current === recoveredRunId) {
          activeRunIdRef.current = null;
          recoveredRunId = null;
          setIsTyping(false);
          setRunningToolTitle(null);
        }
      } catch {
        if (active) pollHandle = setTimeout(pollActiveRun, 5_000);
      }
    };

    void pollActiveRun();
    return () => {
      active = false;
      if (pollHandle) clearTimeout(pollHandle);
    };
  }, [readOnly, sessionId, workspaceId]);

  // Load chat history from backend on mount / workspaceId change.
  // History is keyed by workspaceId (not sessionId) so it survives
  // session loss after a pod restart.
  useEffect(() => {
    if (!workspaceId) return;

    let active = true;
    const epoch = ++hydrationEpochRef.current;
    setUsageStatus(null);
    setIsHistoryLoading(true);
    const welcome = buildWelcomeMessage(t);

    loadMessagesFromAPI(workspaceId, welcome, { includeParts: false, includeActiveParts: true })
      .then(async msgs => {
        // Don't overwrite messages that are currently being streamed in.
        if (!active || hydrationEpochRef.current !== epoch || isStreamingRef.current) return;
        setMessages(msgs);

        const hydrated = await loadMessagesFromAPI(workspaceId, welcome, {
          fallbackToWelcome: false,
        });
        if (!active || hydrationEpochRef.current !== epoch || isStreamingRef.current) return;
        setMessages(prev => hydrateMessagesWithParts(prev, hydrated));
      })
      .finally(() => {
        if (active) setIsHistoryLoading(false);
      });

    // Seed the poll reference so the first poll cycle doesn't immediately
    // reload data that was just fetched above.
    fetchMessagesStatus(workspaceId).then(s => {
      if (active && s) lastStatusRef.current = s.updated_at || String(s.count);
    });
    fetchWorkspaceUsageStatus(workspaceId, sessionId).then(status => {
      if (active) setUsageStatus(status);
    });

    return () => { active = false; };
  }, [readOnly, sessionId, workspaceId]);

  // Subscribe to server-sent events for message changes.
  // The backend pushes {type:"updated", updated_at, count} whenever any client
  // (UI or CLI) calls PUT /messages, so updates appear instantly instead of
  // waiting for a polling interval.
  // Falls back gracefully: if the EventSource errors out we do a one-off
  // status poll so nothing is permanently lost.
  useEffect(() => {
    if (!workspaceId) return;
    const token = getToken();
    if (!token) return;

    const applyUpdate = async (key: string) => {
      if (isStreamingRef.current) return;
      if (key === lastStatusRef.current) return;
      lastStatusRef.current = key;
      const epoch = ++hydrationEpochRef.current;
      const welcome = buildWelcomeMessage(t);
      const fetched = await loadMessagesFromAPI(workspaceId, welcome, {
        includeParts: false,
        includeActiveParts: true,
      });
      if (hydrationEpochRef.current !== epoch || isStreamingRef.current) return;
      if (readOnly) {
        setMessages(fetched);
      } else {
        // Owner mode keeps optimistic local state only until the canonical
        // workspace history changes. Durable messages must already be in
        // messages_v2 by the time the reload wins.
        setMessages(prev => mergeFetchedMessagesWithLocalState(prev, fetched));
      }
      const hydrated = await loadMessagesFromAPI(workspaceId, welcome, {
        fallbackToWelcome: false,
      });
      if (hydrationEpochRef.current === epoch && !isStreamingRef.current) {
        setMessages(prev => hydrateMessagesWithParts(prev, hydrated));
      }
    };

    const url = `${API_BASE_URL}/api/workspace/${encodeURIComponent(workspaceId)}/messages/stream?token=${encodeURIComponent(token)}`;
    const es = new EventSource(url);

    // After the SSE connection is established, do a one-off status check to
    // catch any writes that arrived between the initial load and this subscribe.
    es.onopen = () => {
      fetchMessagesStatus(workspaceId).then(status => {
        if (!status) return;
        const key = status.updated_at || String(status.count);
        applyUpdate(key);
      });
    };

    es.onmessage = (event) => {
      try {
        const data = JSON.parse(event.data);
        if (data.type === 'updated') {
          const key = data.updated_at || String(data.count);
          applyUpdate(key);
        }
      } catch { /* ignore parse errors */ }
    };

    es.onerror = () => {
      // Connection dropped — do a one-off poll to catch any missed update,
      // then let the browser reconnect automatically.
      if (!isStreamingRef.current) {
        fetchMessagesStatus(workspaceId).then(status => {
          if (!status) return;
          const key = status.updated_at || String(status.count);
          applyUpdate(key);
        });
      }
    };

    return () => es.close();
  }, [readOnly, workspaceId]);

  const scrollToBottom = (force = false) => {
    if (force || isAtBottomRef.current) {
      messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
    }
  };

  // Keep isAtBottomRef in sync as the user scrolls.
  useEffect(() => {
    const container = scrollContainerRef.current;
    if (!container) return;
    const handleScroll = () => {
      const threshold = 80; // px from bottom considered "at bottom"
      isAtBottomRef.current =
        container.scrollHeight - container.scrollTop - container.clientHeight <= threshold;
    };
    container.addEventListener('scroll', handleScroll, { passive: true });
    return () => container.removeEventListener('scroll', handleScroll);
  }, []);

  useEffect(() => {
    scrollToBottom();
  }, [messages]);

  useEffect(() => {
    if (!workspaceId || pendingAttachments.length === 0) return;
    const active = pendingAttachments.some(a => a.status === 'processing');
    if (!active) return;
    const timer = window.setInterval(() => {
      const ids = pendingAttachments.map(a => a.id);
      fetchImageAttachments(workspaceId, ids).then(next => {
        if (next.length === 0) return;
        const byId = new Map(next.map(item => [item.id, item]));
        setPendingAttachments(prev => prev.map(item => byId.get(item.id) ?? item));
      }).catch(() => { /* keep current optimistic state */ });
    }, 1500);
    return () => window.clearInterval(timer);
  }, [workspaceId, pendingAttachments]);

  useEffect(() => {
    const textarea = textareaRef.current;
    if (!textarea) return;
    textarea.style.height = 'auto';
    const maxHeight = 120;
    const nextHeight = Math.min(textarea.scrollHeight, maxHeight);
    textarea.style.height = `${Math.max(nextHeight, openFilePath ? 34 : 38)}px`;
    textarea.style.overflowY = textarea.scrollHeight > maxHeight ? 'auto' : 'hidden';
  }, [input, openFilePath]);

  // Listen for issue-ready events from useIssuePreparation
  useEffect(() => {
    const handleIssueReady = (event: Event) => {
      const customEvent = event as CustomEvent<IssueReadyDetail>;
      const { issueId, issueMeta, clusterMeta } = customEvent.detail;

      void (async () => {
        const clusterId = clusterMeta?.cluster_id || issueMeta.cluster_id;
        const orgId = clusterMeta?.clinic_org_id;
        const clinicUrl = orgId && clusterId ? `https://clinic.pingcap.com/portal/#/orgs/${orgId}/clusters/${clusterId}` : undefined;
        const clinicLink = clinicUrl ? ` [\\[Clinic ↗\\]](${clinicUrl})` : '';

        // System notification: structured metadata from Jira + Clinic, not AI-generated.
        const clusterType = clusterMeta?.cluster_type_v2 && clusterMeta.cluster_type_v2 !== clusterMeta.cluster_type
          ? `${clusterMeta.cluster_type} (${clusterMeta.cluster_type_v2})`
          : (clusterMeta?.cluster_type || 'N/A');

        const created = issueMeta.created ? new Date(issueMeta.created).toLocaleString() : 'N/A';
        const ruleExpr = issueMeta.rule_expr || 'N/A';
        const ruleFor = issueMeta.rule_for || 'N/A';
        const rulePath = issueMeta.rule || 'N/A';
        const ruleFile = formatAlertRulePath(rulePath);
        const exprSummary = summarizeAlertExpr(ruleExpr);
        let jiraStatusSummary = '';

        let issueInfoMessage = `**${issueId}** ${issueMeta.summary || ''}
- **Status** ${issueMeta.status || 'Unknown'} · **Priority** ${issueMeta.priority || 'Unknown'} · **Component** ${issueMeta.component || 'N/A'}
- **Alert** ${issueMeta.alert_name || 'N/A'} · **Created** ${created}

**Cluster** \`${clusterMeta?.cluster_name || 'N/A'}\`${clinicLink}
- **ID** \`${clusterId || 'N/A'}\` · **Version** ${clusterMeta?.tidb_version || 'N/A'} · **Type** ${clusterType}
- **Region** ${clusterMeta?.cloud_provider || ''} ${clusterMeta?.region || 'N/A'}
- **Tenant** \`${clusterMeta?.tenant_id || issueMeta.tenant_id || 'N/A'}\` · **Org** \`${orgId || 'N/A'}\`

**Alert Rule**
- **Rule** ${ruleFile}
- **For** ${ruleFor}
- **Expr** ${exprSummary}`;

        // Optionally append a backend-generated Jira status summary if available.
        if (workspaceId) {
          try {
            const summaryPath = `issues/${issueId}/comments_summary_ai.md`;
            const res = await fetchWithAuth(
              `${API_BASE_URL}/api/workspace/${encodeURIComponent(
                workspaceId
              )}/file?path=${encodeURIComponent(summaryPath)}`
            );
            if (res.ok) {
              const summaryText = (await res.text()).trim();
              if (summaryText) {
                jiraStatusSummary = summaryText;
                issueInfoMessage += `\n\n**Jira status summary**:\n${summaryText}`;
              }
            }
          } catch (err) {
            console.warn('Failed to load comments_summary_ai.md for issue', issueId, err);
          }
        }

        const sysMsg: Message = {
          id: `issue-info-${issueId}`,
          role: 'system',
          content: issueInfoMessage,
          timestamp: new Date(),
          issueInfo: {
            issueId,
            summary: issueMeta.summary || '',
            status: issueMeta.status || 'Unknown',
            priority: issueMeta.priority || 'Unknown',
            component: issueMeta.component || 'N/A',
            alertName: issueMeta.alert_name || 'N/A',
            rule: rulePath,
            ruleExpr,
            ruleFor,
            created,
            clusterName: clusterMeta?.cluster_name || 'N/A',
            clinicUrl,
            clusterId: clusterId || 'N/A',
            version: clusterMeta?.tidb_version || 'N/A',
            clusterType,
            region: `${clusterMeta?.cloud_provider || ''} ${clusterMeta?.region || 'N/A'}`.trim(),
            tenantId: clusterMeta?.tenant_id || issueMeta.tenant_id || 'N/A',
            orgId: orgId || 'N/A',
            jiraStatusSummary: jiraStatusSummary || undefined,
          }
        };

        setMessages(prev => {
          const hasIssueInfo = prev.some(
            msg => msg.id === sysMsg.id || msg.issueInfo?.issueId === issueId
          );
          return hasIssueInfo ? prev : [...prev, sysMsg];
        });
        if (workspaceId && !readOnly) {
          appendMessagesToAPI(workspaceId, [sysMsg]);
        }
      })();
    };

    window.addEventListener('issue-ready', handleIssueReady);
    return () => {
      window.removeEventListener('issue-ready', handleIssueReady);
    };
  }, [readOnly, workspaceId]);

  // Internal send function that can be reused
  const handleSendInternal = async (
    userInput: string,
    userMessageId?: string,
    attachmentIds: string[] = [],
    codeDiagnosis?: { repo: string; commit_sha: string },
    diagnosisInvocation?: DiagnosisInvocationRequest,
  ) => {
    let assistantMessageId = `assistant-${Date.now()}`;
    const codeDiagnosisProgress = codeDiagnosis ? { phase: 'preparing' as const, startedAt: Date.now() } : undefined;
    streamingAssistantIdRef.current = assistantMessageId;
    streamingPartsOrderRef.current = [];
    streamingPartsMapRef.current = new Map();
    streamingContentRef.current = '';
    streamStopRequestedRef.current = false;
    setMessages(prev => [...prev, {
      id: assistantMessageId,
      role: 'assistant',
      content: '',
      timestamp: new Date(),
      status: 'streaming',
      parts: [],
      codeDiagnosisProgress,
    }]);

    try {
      if (!workspaceId || !sessionId || !userMessageId) throw new Error('workspace_id, session_id, and user_message_id are required');
      const submitResponse = await fetchWithAuth(
        `${API_BASE_URL}/api/sessions/${encodeURIComponent(sessionId)}/turns?wait=false`, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({
          input: userInput,
          client_turn_id: userMessageId,
          attachment_ids: attachmentIds,
          model_tier: modelTier,
          agent_type: agentRuntime,
          code_diagnosis: codeDiagnosis,
          diagnosis_invocation: diagnosisInvocation ?? {
            diagnosis_mode: 'copilot',
            trigger_type: 'human_message',
            supports_followup: true,
          },
        }),
      });
      if (!submitResponse.ok) {
        const detail = await submitResponse.json().catch(() => null);
        const message = detail?.detail?.message || detail?.detail || `HTTP ${submitResponse.status}`;
        throw new Error(typeof message === 'string' ? message : JSON.stringify(message));
      }
      const submitted = await submitResponse.json() as { id: string; assistant_message_id: string };
      const runId = submitted.id;
      activeRunIdRef.current = runId;
      if (submitted.assistant_message_id !== assistantMessageId) {
        const optimisticAssistantId = assistantMessageId;
        assistantMessageId = submitted.assistant_message_id;
        streamingAssistantIdRef.current = assistantMessageId;
        setMessages(prev => prev.map(message =>
          message.id === optimisticAssistantId
            ? { ...message, id: assistantMessageId }
            : message
        ));
      }

      // EventSource cannot send custom headers; only the observer receives the
      // token in its URL. The submitted turn itself is already durable.
      const token = getToken();
      const sseT0 = performance.now();
      if (SSE_DEBUG) console.log('[sse] open', { t0_ms: 0 });
      const eventSource = new EventSource(
        `${API_BASE_URL}/api/sessions/${encodeURIComponent(sessionId)}/transcript/events?token=${encodeURIComponent(token || '')}`
      );
      eventSourceRef.current = eventSource;
      if (SSE_DEBUG) {
        eventSource.onopen = () => {
          console.log('[sse] onopen', { since_t0_ms: Math.round(performance.now() - sseT0) });
        };
      }

      // Guard: prevent onerror from treating a clean server-side close
      // (which always happens after 'complete') as an error. Without this,
      // EventSource auto-retries, sending the same message to Claude again.
      let streamCompleted = false;
      // Track parts for interleaved rendering (use refs so handleStop can access them)
      const partsOrder = streamingPartsOrderRef.current;
      const partsMap = streamingPartsMapRef.current;
      let currentTextPartId: string | null = null;
      let completedContent = '';


      const updateMessage = (contentDelta?: string) => {
        setMessages(prev => prev.map(msg =>
          msg.id === assistantMessageId
            ? {
              ...msg,
              content: contentDelta ? msg.content + contentDelta : msg.content,
              parts: partsOrder.map(id => partsMap.get(id)!).filter(Boolean),
            }
            : msg
        ));
      };

      const sealOpenReasoningPart = () => {
        const now = Date.now();
        let sealed = false;
        for (const pid of partsOrder) {
          const p = partsMap.get(pid);
          if (p?.type === 'reasoning' && !p.doneAt) {
            partsMap.set(pid, { ...p, doneAt: now });
            sealed = true;
          }
        }
        if (sealed) updateMessage();
      };

      const handleAgentEvent = (event: MessageEvent<string>) => {
        try {
          const data = JSON.parse(event.data) as SSEEvent;
          if (SSE_DEBUG) {
            console.log('[sse] event', {
              since_t0_ms: Math.round(performance.now() - sseT0),
              type: (data as { type?: string }).type,
              len: (event.data ?? '').length,
            });
          }
          if (data.type === 'session_created') {
            // Runtime session IDs are internal transcript metadata. The public
            // Public session ID remains stable for the workspace.
          } else if (data.type === 'message_id') {
            // Backend assigned the canonical assistant message ID — update local state
            const backendId = data.id;
            if (backendId && backendId !== assistantMessageId) {
              const oldId = assistantMessageId;
              assistantMessageId = backendId;
              streamingAssistantIdRef.current = backendId;
              setMessages(prev => prev.map(msg =>
                msg.id === oldId ? { ...msg, id: backendId } : msg
              ));
            }
          } else if (data.type === 'text') {
            sealOpenReasoningPart();
            // First-event timer: any model output counts (text/thought/tool)
            // so the Wait metric matches the backend TTFF.
            if (firstEventTimeRef.current === null) firstEventTimeRef.current = Date.now();
            const isSubagentText = hasSubagentParent(data.meta);
            const existingTextPart = currentTextPartId ? partsMap.get(currentTextPartId) : undefined;
            const canAppendToCurrentText =
              existingTextPart?.type === 'text' && sameToolMeta(existingTextPart.meta, data.meta);

            // Append to current text part, or start a new one. Subagent text is
            // still a text part, but it stays out of the top-level assistant
            // content so it can render inside the parent Task details.
            if (currentTextPartId && canAppendToCurrentText) {
              const existing = existingTextPart as TextPart;
              partsMap.set(currentTextPartId, { ...existing, text: existing.text + data.text });
            } else {
              currentTextPartId = `text-${Date.now()}-${Math.random().toString(36).slice(2, 5)}`;
              partsMap.set(currentTextPartId, {
                type: 'text',
                id: currentTextPartId,
                text: data.text,
                meta: data.meta,
              });
              partsOrder.push(currentTextPartId);
            }
            if (isSubagentText) {
              updateMessage();
            } else {
              completedContent += data.text;
              streamingContentRef.current = completedContent;
              setHasStreamedText(true);
              updateMessage(data.text);
            }

          } else if (data.type === 'thought') {
            currentTextPartId = null;
            if (firstEventTimeRef.current === null) firstEventTimeRef.current = Date.now();
            setThoughtCount(prev => prev + 1);
            setThinkingText(prev => prev + data.text);
            // Merge consecutive thoughts into a single reasoning part
            const lastPartId = partsOrder[partsOrder.length - 1];
            const lastPart = lastPartId ? partsMap.get(lastPartId) : undefined;
            if (
              lastPart
              && lastPart.type === 'reasoning'
              && !lastPart.doneAt
              && sameToolMeta(lastPart.meta, data.meta)
            ) {
              partsMap.set(lastPartId, {
                ...lastPart,
                text: lastPart.text + data.text,
                meta: data.meta ?? lastPart.meta,
              });
            } else {
              const id = `thought-${Date.now()}-${Math.random().toString(36).slice(2, 5)}`;
              partsMap.set(id, { type: 'reasoning', id, text: data.text, startedAt: Date.now(), meta: data.meta });
              partsOrder.push(id);
            }
            updateMessage();

          } else if (data.type === 'tool') {
            currentTextPartId = null;
            if (firstEventTimeRef.current === null) firstEventTimeRef.current = Date.now();
            sealOpenReasoningPart();
            // Update existing tool part in-place if same ID
            const existingId = partsOrder.find(pid => {
              const p = partsMap.get(pid);
              if (p?.type !== 'tool') return false;
              const tp = p as ToolPart;
              // Match by callID if available, otherwise match by title for tools with empty ID
              return data.id ? tp.callID === data.id : tp.tool === data.title && tp.state.status === 'running';
            });
            const existingPart = existingId ? partsMap.get(existingId) as ToolPart | undefined : undefined;
            const toolStatus = data.status === 'failed'
              ? 'failed'
              : data.status === 'completed'
                ? 'completed'
                : data.status === 'cancelled'
                  ? 'cancelled'
                  : 'running';
            const toolStartedAt = existingPart?.startedAt ?? Date.now();
            const isDone = toolStatus !== 'running';
            const frontendDurationMs = isDone && existingPart?.startedAt
              ? Date.now() - existingPart.startedAt
              : null;
            const toolPart: ToolPart = {
              type: 'tool',
              id: existingId || `tool-${data.id}`,
              tool: data.title,
              callID: data.id,
              startedAt: toolStartedAt,
              state: {
                status: toolStatus,
                title: data.title,
                input: data.input ?? existingPart?.state.input,
                output: data.output ?? existingPart?.state.output,
                duration_ms: data.duration_ms ?? frontendDurationMs ?? undefined,
                meta: data.meta ?? existingPart?.state.meta,
              },
            };
            if (existingId) {
              partsMap.set(existingId, toolPart);
            } else {
              partsMap.set(toolPart.id, toolPart);
              partsOrder.push(toolPart.id);
            }
            if (toolStatus === 'running') {
              setRunningToolTitle(data.title);
            } else {
              setRunningToolTitle(null);
            }
            updateMessage();
            const codeDiagnosisPhase = codeDiagnosis && getCodeDiagnosisPhase(data);
            if (codeDiagnosisPhase) {
              setMessages(prev => prev.map(msg => {
                if (msg.id !== assistantMessageId || !msg.codeDiagnosisProgress) return msg;
                const currentPhase = msg.codeDiagnosisProgress.phase;
                const phaseRank = { preparing: 0, starting_agent: 1, analyzing: 2 };
                return phaseRank[codeDiagnosisPhase] > phaseRank[currentPhase]
                  ? { ...msg, codeDiagnosisProgress: { ...msg.codeDiagnosisProgress, phase: codeDiagnosisPhase } }
                  : msg;
              }));
            }
            // Trigger file tree refresh when a tool completes (agent may have written evidence files)
            if (data.status === 'completed') triggerFilesRefresh();

          } else if (data.type === 'usage') {
            setUsageStatus(prev => ({
              ...(prev ?? {}),
              contextUsed: Number(data.context_used ?? 0),
              contextSize: Number(data.context_size ?? 0),
              costAmount: data.cost_amount != null ? Number(data.cost_amount) : prev?.costAmount,
              costCurrency: data.cost_currency || prev?.costCurrency || 'USD',
            }));

          } else if (data.type === 'session_compacted') {
            setUsageStatus(prev => ({
              ...(prev ?? {}),
              compactedAt: Date.now(),
              compactedAuto: data.auto,
              compactedOverflow: data.overflow,
            }));

          } else if (data.type === 'image') {
            currentTextPartId = null;
            const imagePart: ImagePart = {
              type: 'image',
              id: `image-${Date.now()}-${Math.random().toString(36).slice(2, 5)}`,
              url: data.url,
              alt: data.alt,
            };
            partsMap.set(imagePart.id, imagePart);
            partsOrder.push(imagePart.id);
            updateMessage();

          } else if (data.type === 'artifact') {
            // Backend signals that an artifact should be created in the right panel
            // (e.g., when evidence collection writes a workspace_evidence_report.json)
            if (addArtifact && data.title) {
              const artifactType = ARTIFACT_TYPES.has(data.artifact_type as ArtifactType)
                ? data.artifact_type as ArtifactType
                : 'report';
              addArtifact({
                type: artifactType,
                title: data.title,
                description: data.description,
                data: data.data,
              });
            }

          } else if (data.type === 'complete') {
            sealOpenReasoningPart();
            streamCompleted = true;
            {
              const now = Date.now();
              const totalMs = data.timing?.total_ms ?? (now - typingStartTimeRef.current);
              const waitingMs = firstEventTimeRef.current != null
                ? firstEventTimeRef.current - typingStartTimeRef.current
                : null;
              const timingMeta = {
                total_ms: totalMs,
                ttff_ms: data.timing?.ttff_ms ?? undefined,
                waiting_ms: waitingMs ?? undefined,
              };
              const finalStatus = data.status === 'cancelled' || streamStopRequestedRef.current
                ? 'cancelled'
                : data.status === 'failed' || data.status === 'interrupted'
                  ? 'failed'
                  : 'completed';
              setMessages(prev => prev.map(msg =>
                msg.id === assistantMessageId ? { ...msg, timingMeta, status: finalStatus } : msg
              ));
            }
            setIsTyping(false);
            eventSource.close();
            activeRunIdRef.current = null;
            // Trigger file tree refresh after chat completes (agent may have written files)
            triggerFilesRefresh();
            void refreshDiagnosisCheckpoint();
            // Evidence postpass runs after the assistant completes, so the
            // authored key evidence can land after the immediate refresh.
            window.setTimeout(triggerFilesRefresh, 5_000);
            window.setTimeout(triggerFilesRefresh, 20_000);
            if (workspaceId) {
              fetchWorkspaceUsageStatus(workspaceId, sessionId).then(status => {
                if (status) setUsageStatus(prev => ({ ...(prev ?? {}), ...status }));
              });
            }
            // Delay clearing isStreamingRef so the server-side flush
            // completes before applyUpdate is re-enabled, preventing stale
            // server snapshot from overwriting local state (truncation bug).
            setTimeout(() => { isStreamingRef.current = false; }, 2000);
            // Assistant message is persisted server-side; no frontend append needed.
            // Auto-rename workspace if backend generated a title from the first message
            if (data.workspace_name && onWorkspaceNameChange) {
              onWorkspaceNameChange(data.workspace_name);
            }

          } else if (data.type === 'error') {
            console.error('SSE Error:', data.error);
            // If the backend signals an auth error, clear credentials and go to login.
            if (typeof data.error === 'string' && /unauthorized|unauthenticated/i.test(data.error)) {
              clearAuth();
              const current = window.location.pathname + window.location.search;
              window.location.replace(`/login?redirect=${encodeURIComponent(current)}`);
              return;
            }
            const errText =
              typeof data.error === 'string'
                ? data.error
                : String(data.error ?? 'Unknown error');
            const isCostLimit = data.code === 'workspace_cost_limit';
            const isSessionReset = data.code === 'acp_session_reset';
            const errBlock = isCostLimit
              ? errText
              : isSessionReset
                ? t('chat.errorSessionReset')
                : t('chat.errorParam', { error: errText });
            const errContent = completedContent
              ? `${completedContent}\n\n${errBlock}`
              : errBlock;
            setMessages(prev => prev.map(msg =>
              msg.id === assistantMessageId
                ? { ...msg, content: errContent, status: 'failed', isCostLimitBlocked: isCostLimit || undefined }
                : msg
            ));
            isStreamingRef.current = false;
            setIsTyping(false);
            eventSource.close();
            activeRunIdRef.current = null;
            // Assistant message is persisted server-side; no frontend append needed.
          }
        } catch (error) {
          console.error('Error parsing SSE data:', error);
        }
      };

      type TranscriptRecord = {
        turn_id: string;
        data: SSEEvent;
      };
      const replayTranscriptRecord = (record: TranscriptRecord) => {
        if (record.turn_id !== runId) return;
        handleAgentEvent(new MessageEvent('message', {
          data: JSON.stringify(record.data),
        }));
      };
      eventSource.addEventListener('record', (event) => {
        const envelope = JSON.parse((event as MessageEvent<string>).data) as {
          record: TranscriptRecord;
        };
        replayTranscriptRecord(envelope.record);
      });
      const replayTranscript = (event: Event) => {
        const envelope = JSON.parse((event as MessageEvent<string>).data) as {
          transcript: { records: TranscriptRecord[] };
        };
        for (const record of envelope.transcript.records) {
          replayTranscriptRecord(record);
        }
      };
      eventSource.addEventListener('sync', replayTranscript);
      eventSource.addEventListener('reset', replayTranscript);

      eventSource.onerror = () => {
        // EventSource reconnects to the same observer endpoint with Last-Event-ID.
        // A transport failure must not change or cancel the durable turn.
        if (streamCompleted) return;
        if (streamStopRequestedRef.current) {
          setIsTyping(false);
          eventSource.close();
          return;
        }

        // Probe for 401. fetchWithAuth clears auth and redirects to /login.
        // Otherwise leave EventSource open so its built-in reconnect can resume.
        fetchWithAuth(`${API_BASE_URL}/api/workspaces`).catch(() => { /* redirect handled */ });
      };

    } catch (error) {
      console.error('Error sending message:', error);
      isStreamingRef.current = false;
      activeRunIdRef.current = null;
      setMessages(prev => prev.map(message =>
        message.id === assistantMessageId
          ? { ...message, content: t('chat.errorRetry'), status: 'failed' }
          : message
      ));
      setIsTyping(false);
    }
  };

  const publishJiraCommentDraft = async () => {
    if (!workspaceId || !jiraCommentDraft) return;
    setIsJiraCommentPublishing(true);
    try {
      const qs = new URLSearchParams({ workspace_id: workspaceId });
      const res = await fetchWithAuth(`${API_BASE_URL}/api/chat/commands/comment/publish?${qs.toString()}`, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ draft_id: jiraCommentDraft.id, body: jiraCommentDraft.body }),
      });
      if (!res.ok) {
        const err = await res.json().catch(() => ({ detail: `HTTP ${res.status}` }));
        throw new Error(typeof err.detail === 'string' ? err.detail : `HTTP ${res.status}`);
      }
      const result = await res.json() as { issue_key: string };
      const now = Date.now();
      const sysMsg = createLocalCommandSystemMessage(
        t('chat.cmd.commentPublished', { issue: result.issue_key }), now,
      );
      setMessages(prev => [...prev, sysMsg]);
      appendMessagesToAPI(workspaceId, [sysMsg]);
      setJiraCommentDraft(null);
    } catch (error) {
      const now = Date.now();
      const sysMsg = createLocalCommandSystemMessage(
        t('chat.cmd.commentPublishFailed', { error: error instanceof Error ? error.message : String(error) }), now,
      );
      setMessages(prev => [...prev, sysMsg]);
      appendMessagesToAPI(workspaceId, [sysMsg]);
    } finally {
      setIsJiraCommentPublishing(false);
    }
  };

  const cancelJiraCommentDraft = async () => {
    if (!workspaceId || !jiraCommentDraft) return;
    try {
      const qs = new URLSearchParams({ workspace_id: workspaceId });
      const res = await fetchWithAuth(`${API_BASE_URL}/api/chat/commands/comment/cancel?${qs.toString()}`, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ draft_id: jiraCommentDraft.id }),
      });
      if (!res.ok) {
        const err = await res.json().catch(() => ({ detail: `HTTP ${res.status}` }));
        throw new Error(typeof err.detail === 'string' ? err.detail : `HTTP ${res.status}`);
      }
      setJiraCommentDraft(null);
    } catch (error) {
      const now = Date.now();
      const sysMsg = createLocalCommandSystemMessage(
        t('chat.cmd.commentCancelFailed', { error: error instanceof Error ? error.message : String(error) }), now,
      );
      setMessages(prev => [...prev, sysMsg]);
      appendMessagesToAPI(workspaceId, [sysMsg]);
    }
  };

  const handleStop = () => {
    const targetAssistantId = streamingAssistantIdRef.current;
    streamStopRequestedRef.current = true;
    // Explicit Stop is the only browser action that cancels the durable turn.
    const activeRunId = activeRunIdRef.current;
    if (activeRunId) {
      fetchWithAuth(`${API_BASE_URL}/api/sessions/${encodeURIComponent(sessionId)}/turns/stop`, {
        method: 'POST',
      }).catch(() => { /* best-effort */ });
    } else if (workspaceId) {
      fetchWithAuth(`${API_BASE_URL}/api/chat/cancel?workspace_id=${encodeURIComponent(workspaceId)}`, {
        method: 'POST',
      }).catch(() => { /* compatibility fallback */ });
    }
    // Close the SSE connection
    if (eventSourceRef.current) {
      eventSourceRef.current.close();
      eventSourceRef.current = null;
    }
    // Reset streaming state
    setIsTyping(false);
    isStreamingRef.current = false;
    setRunningToolTitle(null);
    streamingPartsMapRef.current = new Map(
      Array.from(streamingPartsMapRef.current.entries()).map(([id, part]) => {
        if (part.type !== 'tool' || part.state.status !== 'running') return [id, part];
        return [
          id,
          {
            ...part,
            state: {
              ...part.state,
              status: 'cancelled' as const,
            },
          },
        ] as const;
      })
    );
    setMessages(prev => prev.map(msg => {
      if (!targetAssistantId || msg.id !== targetAssistantId) return msg;
      const nextParts = (msg.parts ?? []).map(part => {
        if (part.type !== 'tool' || part.state.status !== 'running') return part;
        return {
          ...part,
          state: {
            ...part.state,
            status: 'cancelled' as const,
          },
        };
      });
      return { ...msg, status: 'cancelled', parts: nextParts };
    }));
    // Assistant message is persisted server-side via cancel_prompt flush.
    streamingAssistantIdRef.current = null;
  };

  const removePendingAttachment = (id: string) => {
    setPendingAttachments(prev => prev.filter(item => item.id !== id));
  };

  const handleImageUploadClick = () => {
    imageInputRef.current?.click();
  };

  const uploadImageFiles = async (files: File[]) => {
    if (!workspaceId || isTyping || files.length === 0) return;
    setAttachmentError(null);
    const pairs = files.map(file => {
      const localId = `local-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
      const placeholder: ImageAttachment = {
        id: localId,
        filename: file.name || 'pasted-image.png',
        mime_type: file.type || 'image/png',
        preview_path: '',
        status: 'processing',
      };
      return { file, placeholder };
    });
    setPendingAttachments(prev => [...prev, ...pairs.map(p => p.placeholder)]);
    await Promise.all(pairs.map(async ({ file, placeholder }) => {
      try {
        const attachment = await uploadImageAttachment(workspaceId, file);
        setPendingAttachments(prev =>
          prev.map(item => (item.id === placeholder.id ? attachment : item))
        );
      } catch (err) {
        setPendingAttachments(prev => prev.filter(item => item.id !== placeholder.id));
        setAttachmentError(err instanceof Error ? err.message : String(err));
      }
    }));
  };

  const handleImageFileChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
    const files = Array.from(event.target.files ?? []);
    event.target.value = '';
    await uploadImageFiles(files);
  };

  const handlePaste = (event: React.ClipboardEvent<HTMLTextAreaElement>) => {
    const files = Array.from(event.clipboardData.items)
      .filter(item => item.kind === 'file' && item.type.startsWith('image/'))
      .map(item => item.getAsFile())
      .filter((file): file is File => !!file);
    if (files.length === 0) return;
    void uploadImageFiles(files);
  };

  const handleSend = async () => {
    if (readOnly || isTyping || isStreamingRef.current) return;
    if (!input.trim() && pendingAttachments.length === 0) return;
    const failedAttachment = pendingAttachments.find(a => a.status === 'failed');
    if (failedAttachment) {
      setAttachmentError(
        `Image parse failed: ${failedAttachment.error || failedAttachment.filename}`
      );
      return;
    }
    // 'uploaded' and 'processing' are fine to send.  In passthrough mode the
    // upload endpoint already flips to 'ready' inline; in vision-parse mode
    // build_image_summary_prompt_block waits on the lazy parse before
    // building the prompt.  Only 'failed' is a hard stop here.

    const trimmed = input.trim();

    // Handle slash commands locally — do not send to the AI backend.
    if (trimmed.startsWith('/')) {
      const [rawCmd, ...commandArgs] = trimmed.split(/\s+/);
      const cmd = rawCmd.toLowerCase();
      if (cmd === '/agent') {
        setInput('');
        const commandStartedAt = Date.now();
        const userMessage = createLocalCommandUserMessage(trimmed, commandStartedAt);
        setMessages(prev => [...prev, userMessage]);

        const requestedRuntime = parseAgentRuntime(commandArgs[0]?.toLowerCase());
        const currentLabel = agentRuntime === 'opencode'
          ? t('chatPanel.runtimeOpencode')
          : t('chatPanel.runtimePi');
        let statusText = '';
        if (!workspaceId) {
          statusText = t('chat.cmd.noWorkspace');
        } else if (!requestedRuntime) {
          statusText = t('chat.cmd.agentUsage', { runtime: currentLabel });
        } else {
          const requestedLabel = requestedRuntime === 'opencode'
            ? t('chatPanel.runtimeOpencode')
            : t('chatPanel.runtimePi');
          if (requestedRuntime === agentRuntime) {
            agentRuntimePreferenceVersionRef.current += 1;
            try {
              await setAgentRuntimePreference(requestedRuntime);
              statusText = t('chat.cmd.agentAlreadySelected', { runtime: requestedLabel });
            } catch {
              statusText = t('chat.cmd.agentSwitchFailed');
            }
          } else {
            agentRuntimePreferenceVersionRef.current += 1;
            try {
              await setAgentRuntimePreference(requestedRuntime);
              setAgentRuntime(requestedRuntime);
              statusText = t('chat.cmd.agentSwitched', { runtime: requestedLabel });
            } catch {
              statusText = t('chat.cmd.agentSwitchFailed');
            }
          }
        }

        const sysMsg = createLocalCommandSystemMessage(statusText, commandStartedAt);
        setMessages(prev => [...prev, sysMsg]);
        if (workspaceId && !readOnly) {
          appendMessagesToAPI(workspaceId, [userMessage, sysMsg]);
        }
        return;
      }
      if (cmd === '/debug') {
        setInput('');
        const commandStartedAt = Date.now();
        const userMessage = createLocalCommandUserMessage(trimmed, commandStartedAt);
        setMessages(prev => [...prev, userMessage]);

        let statusText = '';
        if (!workspaceId) {
          statusText = 'No active workspace.';
        } else {
          try {
            const report = await fetchWorkspaceContextDebug(workspaceId);
            statusText = formatContextDebugMessage(report, t);
          } catch (e) {
            statusText = `Failed to fetch context debug report: ${e instanceof Error ? e.message : String(e)}`;
          }
        }

        const sysMsg = createLocalCommandSystemMessage(statusText, commandStartedAt);
        setMessages(prev => [...prev, sysMsg]);
        return;
      }
      if (cmd === '/stats') {
        setInput('');
        const commandStartedAt = Date.now();
        const userMessage = createLocalCommandUserMessage(trimmed, commandStartedAt);
        setMessages(prev => [...prev, userMessage]);

        let statusText = '';
        if (!workspaceId) {
          statusText = t('chat.cmd.noWorkspace');
        } else {
          try {
            const qs = new URLSearchParams({ workspace_id: workspaceId });
            if (sessionId) qs.set('session_id', sessionId);
            const res = await fetchWithAuth(`${API_BASE_URL}/api/chat/commands/stats?${qs.toString()}`);
            if (!res.ok) throw new Error(`HTTP ${res.status}`);
            const d = await res.json();
            const fmt = (n: number) => n.toLocaleString();
            const cost = d.total_cost > 0 ? `$${d.total_cost.toFixed(2)} ${d.cost_currency}` : 'N/A';
            const limitUsd = Number(d.workspace_cost_limit_usd ?? 0);
            const limitRow =
              limitUsd > 0
                ? `| ${t('chat.cmd.statsCostCap')} | $${limitUsd.toFixed(2)} |`
                : `| ${t('chat.cmd.statsCostCap')} | ${t('chat.cmd.statsCostCapOff')} |`;
            const ctx = `${fmt(Number(d.context_used ?? 0))} / ${fmt(Number(d.context_size ?? 0))}`;
            statusText = [
              t('chat.cmd.statsTitle'),
              ``,
              `| ${t('chat.cmd.statsMetric')} | ${t('chat.cmd.statsValue')} |`,
              `|--------|-------|`,
              `| ${t('chat.cmd.statsInteractions')} | ${fmt(d.interactions)} |`,
              `| ${t('chat.cmd.statsInputTokens')} | ${fmt(d.total_input_tokens)} |`,
              `| ${t('chat.cmd.statsOutputTokens')} | ${fmt(d.total_output_tokens)} |`,
              `| ${t('chat.cmd.statsReasoningTokens')} | ${fmt(Number(d.total_reasoning_tokens ?? 0))} |`,
              `| ${t('chat.cmd.statsCacheRead')} | ${fmt(d.total_cache_read_tokens)} |`,
              `| ${t('chat.cmd.statsCacheWrite')} | ${fmt(d.total_cache_write_tokens)} |`,
              `| ${t('chat.cmd.statsContextWindow')} | ${ctx} |`,
              `| ${t('chat.cmd.statsCost')} | ${cost} |`,
              limitRow,
            ].join('\n');
          } catch (e) {
            statusText = t('chat.cmd.statsFetchFailed', { error: String(e) });
          }
        }

        const sysMsg = createLocalCommandSystemMessage(statusText, commandStartedAt);
        setMessages(prev => [...prev, sysMsg]);
        if (workspaceId && !readOnly) {
          appendMessagesToAPI(workspaceId, [userMessage, sysMsg]);
        }
        return;
      }
      if (cmd === '/timing') {
        setInput('');
        const commandStartedAt = Date.now();
        const userMessage = createLocalCommandUserMessage(trimmed, commandStartedAt);
        setMessages(prev => [...prev, userMessage]);

        const ms = (n: number | null | undefined) => (n == null ? '—' : `${Math.round(n)} ms`);
        const sec = (n: number | null | undefined) => (n == null ? '—' : `${(n / 1000).toFixed(2)} s`);

        // Pick the FIRST assistant message of this session — its timingMeta
        // and parts carry everything we need (TTFF, total, per-tool durations,
        // reasoning gaps).
        const firstAssistant = messages.find(m => m.role === 'assistant' && m.timingMeta);

        // Cold-start init phases live on the AgentSession on the backend; fetch
        // them as a side panel. Tolerate failure — the message-derived data
        // is already useful on its own.
        let backendInit: Record<string, number | string | undefined> | null = null;
        let backendRuntimeSessionId: string | null = null;
        if (workspaceId) {
          try {
            const qs = new URLSearchParams({ workspace_id: workspaceId });
            const res = await fetchWithAuth(`${API_BASE_URL}/api/chat/commands/timing?${qs.toString()}`);
            if (res.ok) {
              const d = await res.json();
              if (d.session_active) {
                backendInit = (d.init || {}) as Record<string, number | string | undefined>;
                backendRuntimeSessionId =
                  (d.runtime_session_id as string | undefined)
                  ?? (d.acp_session_id as string | undefined)
                  ?? null;
              }
            }
          } catch {
            // ignore — surface message-side breakdown anyway
          }
        }

        const lines: string[] = [
          t('chat.cmd.timingTitle'),
          ``,
        ];
        if (backendRuntimeSessionId) {
          lines.push(t('chat.cmd.runtimeSession', { id: backendRuntimeSessionId }));
          lines.push('');
        }
        if (backendInit) {
          const configuredModel = (backendInit.configured_model as string | undefined) || '';
          const modelTier = (backendInit.model_tier as string | undefined) || '';
          const tierLabel = modelTier || t('chat.cmd.modelTierNa');
          const modelLabel = configuredModel
            ? t('chat.cmd.modelLabelConfigured', { model: configuredModel, tier: tierLabel })
            : t('chat.cmd.modelLabelDefault', { tier: tierLabel });
          lines.push(modelLabel);
          lines.push('');
        }

        // ---- Wait breakdown (from the first assistant's client-measured timingMeta + cold-start)
        const initTotal = backendInit
          ? (backendInit.init_total_ms as number | undefined)
          : undefined;
        const ttffMs = firstAssistant?.timingMeta?.ttff_ms;
        const waitingMs = firstAssistant?.timingMeta?.waiting_ms;
        const totalMs = firstAssistant?.timingMeta?.total_ms;

        lines.push(t('chat.cmd.waitBreakdownTitle'));
        lines.push('');
        lines.push(`| ${t('chat.cmd.waitStage')} | ${t('chat.cmd.waitSinceT0')} |`);
        lines.push(`|-------|----------|`);
        if (initTotal != null) {
          lines.push(`| ${t('chat.cmd.waitColdStart')} | ${sec(initTotal)} |`);
        }
        if (ttffMs != null && initTotal != null) {
          lines.push(`| ${t('chat.cmd.waitTtffBackend')} | ${sec(initTotal + ttffMs)} |`);
        } else if (ttffMs != null) {
          lines.push(`| ${t('chat.cmd.waitTtffRelative', { time: sec(ttffMs) })} |`);
        }
        if (waitingMs != null) {
          lines.push(`| ${t('chat.cmd.waitFirstOutput')} | ${sec(waitingMs)} |`);
        }
        if (totalMs != null) {
          lines.push(`| ${t('chat.cmd.waitTotal')} | **${sec(totalMs)}** |`);
        }
        if (initTotal == null && ttffMs == null && totalMs == null) {
          lines.push(`| ${t('chat.cmd.waitNoData')} | — |`);
        }
        lines.push('');

        // ---- Cold-start phases (backend init breakdown)
        if (backendInit) {
          lines.push(t('chat.cmd.coldStartTitle'));
          lines.push('');
          lines.push(`| ${t('chat.cmd.coldStartPhase')} | ${t('chat.cmd.coldStartTime')} |`);
          lines.push(`|-------|------|`);
          lines.push(`| ${t('chat.cmd.coldStartSpawn')} | ${ms(backendInit.spawn_ms as number | undefined)} |`);
          lines.push(`| ${t('chat.cmd.coldStartInit')} | ${ms(backendInit.acp_initialize_ms as number | undefined)} |`);
          lines.push(`| ${t('chat.cmd.coldStartNewSession')} | ${ms(backendInit.new_session_ms as number | undefined)} |`);
          lines.push(`| ${t('chat.cmd.coldStartSetModel')} | ${ms(backendInit.set_model_ms as number | undefined)} |`);
          lines.push(`| ${t('chat.cmd.coldStartSubtotal')} | **${ms(initTotal)}** |`);
          lines.push('');
        }

        // ---- Tool execution breakdown (from the first assistant's parts[])
        if (firstAssistant?.parts && firstAssistant.parts.length > 0) {
          const toolParts: ToolPart[] = firstAssistant.parts.filter(
            (p): p is ToolPart => p.type === 'tool',
          );
          if (toolParts.length > 0) {
            // Total over ALL tool calls in the turn — not just the top-N shown.
            const totalToolMs = toolParts.reduce(
              (acc, tp) => acc + (tp.state.duration_ms ?? 0),
              0,
            );
            const topTools = [...toolParts]
              .sort((a, b) => (b.state.duration_ms ?? 0) - (a.state.duration_ms ?? 0))
              .slice(0, 10);

            lines.push(t('chat.cmd.toolBreakdownTitle', { top: topTools.length, total: toolParts.length }));
            lines.push('');
            lines.push(`| ${t('chat.cmd.toolColIdx')} | ${t('chat.cmd.toolColTool')} | ${t('chat.cmd.toolColDuration')} | ${t('chat.cmd.toolColStatus')} |`);
            lines.push(`|---|------|----------|--------|`);
            topTools.forEach((tp, i) => {
              const d = tp.state.duration_ms ?? null;
              const name = tp.tool || tp.state.title || '?';
              lines.push(`| ${i + 1} | ${name} | ${ms(d)} | ${tp.state.status} |`);
            });
            lines.push(`| | ${t('chat.cmd.toolTotal', { count: toolParts.length })} | **${ms(totalToolMs)}** | |`);
            lines.push('');
          }

          // ---- Reasoning gaps (LLM thinking time between tool calls)
          const reasoningParts = firstAssistant.parts.filter(
            (p): p is ReasoningPart => p.type === 'reasoning' && p.startedAt != null && p.doneAt != null,
          );
          if (reasoningParts.length > 0) {
            const totalReasoningMs = reasoningParts.reduce(
              (acc, rp) => acc + ((rp.doneAt as number) - (rp.startedAt as number)),
              0,
            );
            lines.push(t('chat.cmd.reasoningTitle'));
            lines.push('');
            lines.push(`| ${t('chat.cmd.reasoningSegments')} | ${reasoningParts.length} |`);
            lines.push(t('chat.cmd.reasoningSeparator'));
            lines.push(`| ${t('chat.cmd.reasoningTotal')} | **${ms(totalReasoningMs)}** |`);
            lines.push('');
          }
        }

        lines.push(t('chat.cmd.timingFootnote'));

        const sysMsg = createLocalCommandSystemMessage(lines.join('\n'), commandStartedAt);
        setMessages(prev => [...prev, sysMsg]);
        if (workspaceId && !readOnly) {
          appendMessagesToAPI(workspaceId, [userMessage, sysMsg]);
        }
        return;
      }
      if (cmd === '/force-continue') {
        setInput('');
        const commandStartedAt = Date.now();
        const userMessage = createLocalCommandUserMessage(trimmed, commandStartedAt);
        setMessages(prev => [...prev, userMessage]);

        let statusText = '';
        if (!workspaceId) {
          statusText = t('chat.cmd.noWorkspace');
        } else {
          try {
            const res = await fetchWithAuth(
              `${API_BASE_URL}/api/chat/commands/force-continue?${new URLSearchParams({ workspace_id: workspaceId }).toString()}`
            );
            if (!res.ok) {
              const err = await res.json().catch(() => ({ detail: `HTTP ${res.status}` }));
              throw new Error(typeof err.detail === 'string' ? err.detail : `HTTP ${res.status}`);
            }
            const d = await res.json() as { ok?: boolean; message?: string };
            statusText = d.message
              ? t('chat.cmd.forceContinueTitleMsg', { message: d.message })
              : t('chat.cmd.forceContinueDefault');
          } catch (e: unknown) {
            statusText = t('chat.cmd.forceContinueFailed', { error: e instanceof Error ? e.message : String(e) });
          }
        }

        const sysMsg = createLocalCommandSystemMessage(statusText, commandStartedAt);
        setMessages(prev => [...prev, sysMsg]);
        if (workspaceId && !readOnly) {
          appendMessagesToAPI(workspaceId, [userMessage, sysMsg]);
        }
        return;
      }
      if (cmd === '/issue') {
        setInput('');
        const commandStartedAt = Date.now();
        const userMessage = createLocalCommandUserMessage(trimmed, commandStartedAt);
        setMessages(prev => [...prev, userMessage]);

        let statusText = '';
        if (!workspaceId) {
          statusText = t('chat.cmd.noWorkspace');
        } else {
          try {
            const issueText = trimmed.replace(/^\/issue\s*/, '');
            const qs = new URLSearchParams({ workspace_id: workspaceId });
            if (issueText) qs.set('text', issueText);
            const res = await fetchWithAuth(`${API_BASE_URL}/api/chat/commands/issue?${qs.toString()}`);
            if (!res.ok) {
              const err = await res.json().catch(() => ({ detail: `HTTP ${res.status}` }));
              throw new Error(err.detail || `HTTP ${res.status}`);
            }
            const d = await res.json();
            statusText = [
              t('chat.cmd.issueCreated'),
              ``,
              `| ${t('chat.cmd.issueField')} | ${t('chat.cmd.issueValue')} |`,
              `|-------|-------|`,
              `| ${t('chat.cmd.issueLabel')} | ${t('chat.cmd.issueLink', { number: d.issue_number, url: d.issue_url })} |`,
              `| ${t('chat.cmd.issueTitle')} | ${d.title} |`,
              `| ${t('chat.cmd.issueUrl')} | ${d.issue_url} |`,
            ].join('\n');
          } catch (e: unknown) {
            statusText = t('chat.cmd.issueFailed', { error: e instanceof Error ? e.message : String(e) });
          }
        }

        const sysMsg = createLocalCommandSystemMessage(statusText, commandStartedAt);
        setMessages(prev => [...prev, sysMsg]);
        if (workspaceId && !readOnly) {
          appendMessagesToAPI(workspaceId, [userMessage, sysMsg]);
        }
        return;
      }
      if (cmd === '/comment') {
        setInput('');
        const commandStartedAt = Date.now();
        const userMessage = createLocalCommandUserMessage(trimmed, commandStartedAt);
        setMessages(prev => [...prev, userMessage]);
        let statusText = '';
        if (!workspaceId) {
          statusText = t('chat.cmd.noWorkspace');
        } else {
          try {
            const qs = new URLSearchParams({ workspace_id: workspaceId });
            const res = await fetchWithAuth(`${API_BASE_URL}/api/chat/commands/comment?${qs.toString()}`);
            if (!res.ok) {
              const err = await res.json().catch(() => ({ detail: `HTTP ${res.status}` }));
              throw new Error(err.detail || `HTTP ${res.status}`);
            }
            const d = await res.json() as { draft_id: string; issue_key: string; body: string };
            statusText = t('chat.cmd.commentDraft', { issue: d.issue_key, body: d.body });
            setJiraCommentDraft({ id: d.draft_id, issueKey: d.issue_key, body: d.body });
          } catch (e: unknown) {
            statusText = t('chat.cmd.commentFailed', { error: e instanceof Error ? e.message : String(e) });
          }
        }
        const sysMsg = createLocalCommandSystemMessage(statusText, commandStartedAt);
        setMessages(prev => [...prev, sysMsg]);
        if (workspaceId && !readOnly) appendMessagesToAPI(workspaceId, [userMessage, sysMsg]);
        return;
      }
      // Unknown command — fall through and send as normal message.
    }

    const userMessage: Message = {
      id: `user-${Date.now()}`,
      role: 'user',
      content: input || 'Uploaded image',
      timestamp: new Date(),
      attachments: pendingAttachments,
    };

    setMessages(prev => [...prev, userMessage]);
    // User message persistence is handled by /api/chat/stream so attachment
    // metadata and the backend-assembled prompt stay in sync.
    const userInput = input;
    const sendAttachments = pendingAttachments;
    setInput('');
    setPendingAttachments([]);
    setIsTyping(true);
    setThinkingText('');
    setThoughtCount(0);
    setHasStreamedText(false);
    const now = Date.now();
    setTypingStartTime(now);
    typingStartTimeRef.current = now;
    setRunningToolTitle(null);
    firstEventTimeRef.current = null;
    isStreamingRef.current = true;
    hydrationEpochRef.current += 1;
    // User actively sent a message — always scroll to bottom.
    isAtBottomRef.current = true;

    const codeDiagnosis = agentRuntime !== 'pi' && selectedCodeContext
      ? { repo: selectedCodeContext.repo, commit_sha: selectedCodeContext.commitSha }
      : undefined;
    let diagnosisInvocation: DiagnosisInvocationRequest | undefined;
    if (diagnosisCheckpoint?.kind === 'routing') {
      diagnosisInvocation = {
        diagnosis_mode: 'copilot',
        trigger_type: 'human_message',
        supports_followup: true,
        interaction_action: 'route_feedback',
      };
      if (workspaceId && userInput.trim()) {
        await fetchWithAuth(`${API_BASE_URL}/api/workspace/${encodeURIComponent(workspaceId)}/feedback`, {
          method: 'PUT',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify({ rating: 'failure', failure_reason: userInput }),
        }).catch(() => {});
        setFeedbackRefreshToken(value => value + 1);
      }
    } else if (diagnosisCheckpoint?.kind === 'time') {
      diagnosisInvocation = {
        diagnosis_mode: 'copilot',
        trigger_type: 'human_message',
        supports_followup: true,
        interaction_action: 'revise_time',
      };
    }
    setDiagnosisCheckpoint(null);
    await handleSendInternal(
      userInput,
      String(userMessage.id),
      sendAttachments.map(item => item.id),
      codeDiagnosis,
      diagnosisInvocation,
    );
  };

  const registerCodeContext = async (prepared: { repository_id: string; repo: string; label: string; version_label?: string | null; commit_sha: string; context_id: string }) => {
    const context = { repositoryId: prepared.repository_id, repo: prepared.repo, label: prepared.label, versionLabel: prepared.version_label ?? undefined, commitSha: prepared.commit_sha, contextId: prepared.context_id };
    // The successful prepare response has already replaced the persisted
    // workspace source atomically. Refresh instead of making a second client
    // write that could briefly revive the previous version.
    await refreshSources();
    // Loading a repository and SHA is an explicit user choice, so make it the
    // active conversation context once its source is ready.
    setSelectedCodeContext(context);
  };

  const prepareCodeContext = async ({ repo, commitSha, replaceExisting = true }: { repo: string; commitSha: string; replaceExisting?: boolean }): Promise<string | null> => {
    if (!workspaceId) return t('chat.codeDiagnosis.prepareFailed');
    try {
      const response = await fetchWithAuth(`${API_BASE_URL}/api/code-context/prepare`, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ workspace_id: workspaceId, repo, commit_sha: commitSha, replace_existing: replaceExisting }),
      });
      const body = await response.json().catch(() => ({}));
      if (!response.ok) throw new Error(typeof body.detail === 'string' ? body.detail : t('chat.codeDiagnosis.prepareFailed'));
      await registerCodeContext(body);
      return null;
    } catch (cause) {
      const error = cause instanceof Error ? cause.message : t('chat.codeDiagnosis.prepareFailed');
      return error;
    }
  };

  const COMMANDS = [
    { name: '/agent opencode', description: t('chatPanel.cmdAgentOpencodeDesc') },
    { name: '/agent pi', description: t('chatPanel.cmdAgentPiDesc') },
    { name: '/debug', description: t('chatPanel.cmdDebugDesc') },
    { name: '/stats', description: t('chatPanel.cmdStatsDesc') },
    { name: '/timing', description: t('chatPanel.cmdTimingDesc') },
    { name: '/force-continue', description: t('chatPanel.cmdForceContinueDesc') },
    { name: '/issue', description: t('chatPanel.cmdIssueDesc') },
    { name: '/comment', description: t('chatPanel.cmdCommentDesc') },
  ];

  const filteredCommands = COMMANDS.filter(c =>
    c.name.startsWith(commandFilter || '/')
  );

  const handleInputChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
    const val = e.target.value;
    setInput(val);
    // Show command palette when input starts with /
    if (val.startsWith('/') && !val.includes(' ')) {
      setCommandFilter(val);
      setShowCommands(true);
      setSelectedCommandIdx(0);
    } else {
      setShowCommands(false);
    }
  };

  const applyCommand = (cmd: string) => {
    setInput(cmd + ' ');
    setShowCommands(false);
    requestAnimationFrame(() => textareaRef.current?.focus());
  };

  const openCommandPalette = () => {
    setInput('/');
    setCommandFilter('/');
    setShowCommands(true);
    setSelectedCommandIdx(0);
    requestAnimationFrame(() => textareaRef.current?.focus());
  };

  const handleKeyPress = (e: React.KeyboardEvent) => {
    const ne = e.nativeEvent as KeyboardEvent;
    const imeActive =
      imeComposingRef.current
      || ne.isComposing
      || e.key === 'Process'
      || (e.key === 'Enter' && ne.keyCode === 229);

    if (showCommands && filteredCommands.length > 0) {
      if (imeActive) return;
      if (e.key === 'ArrowDown') {
        e.preventDefault();
        setSelectedCommandIdx(i => Math.min(i + 1, filteredCommands.length - 1));
        return;
      }
      if (e.key === 'ArrowUp') {
        e.preventDefault();
        setSelectedCommandIdx(i => Math.max(i - 1, 0));
        return;
      }
      if (e.key === 'Tab' || (e.key === 'Enter' && showCommands)) {
        e.preventDefault();
        applyCommand(filteredCommands[selectedCommandIdx].name);
        return;
      }
      if (e.key === 'Escape') {
        setShowCommands(false);
        return;
      }
    }
    if (e.key === 'Enter' && !e.shiftKey) {
      if (imeActive) return;
      e.preventDefault();
      if (isTyping || isStreamingRef.current) return;
      handleSend();
    }
  };

  // Cleanup on unmount
  useEffect(() => {
    return () => {
      if (eventSourceRef.current) {
        eventSourceRef.current.close();
      }
    };
  }, []);

  const clearChat = () => {
    const welcome = buildWelcomeMessage(t);
    setMessages([welcome]);
    if (workspaceId) {
      clearMessagesAPI(workspaceId).then(() => appendMessagesToAPI(workspaceId, [welcome]));
    }
  };
  const hasOpenFile = !!openFilePath;
  const isMobile = useIsMobile();

  const userMessageCount = messages.filter(m => m.role === 'user').length;
  const showFeedback = !isTyping
    && !diagnosisCheckpoint
    && userMessageCount >= 1
    && !!workspaceId
    && !feedbackDismissed;
  const hasFailedAttachment = pendingAttachments.some(a => a.status === 'failed');
  const canSend = !!workspaceId
    && !isTyping
    && !hasFailedAttachment
    && (!!input.trim() || pendingAttachments.length > 0);
  const codeContextLabel = selectedCodeContext
    ? `${selectedCodeContext.label} ${selectedCodeContext.versionLabel ?? `@ ${selectedCodeContext.commitSha.slice(0, 8)}`}`
    : t('chat.codeContext.general');
  const inputPlaceholder = selectedCodeContext
    ? t('chat.codeContext.placeholder', { label: `${selectedCodeContext.label} ${selectedCodeContext.versionLabel ?? ''}`.trim(), sha: selectedCodeContext.commitSha.slice(0, 8) })
    : diagnosisCheckpoint?.kind === 'routing'
      ? t('chat.copilot.routePromptPlaceholder')
      : diagnosisCheckpoint?.kind === 'time'
        ? t('chat.copilot.timePromptPlaceholder')
        : t('chat.placeholder');

  // Re-render the welcome message in the active locale every time it switches.
  // The stored message has a stable id='welcome'; the body is recomputed from t()
  // so a language switch updates it immediately, even though the message itself
  // was loaded once on mount.
  const displayMessages = useMemo(
    () =>
      messages.map(m =>
        m.id === 'welcome'
          ? { ...m, content: `${t('chat.welcomeTitle')}\n\n${t('chat.welcomeBody')}` }
          : m,
      ),
    [messages, t],
  );
  const handleOpenFile = useCallback((path: string) => setOpenFilePath(path), [setOpenFilePath]);

  const handleForceContinue = useCallback(async () => {
    const commandStartedAt = Date.now();
    const userMessage = createLocalCommandUserMessage('/force-continue', commandStartedAt);
    setMessages(prev => [
      ...prev.map(msg => msg.isCostLimitBlocked ? { ...msg, isCostLimitBlocked: false } : msg),
      userMessage,
    ]);

    let statusText = '';
    if (!workspaceId) {
      statusText = t('chat.cmd.noWorkspace');
    } else {
      try {
        const res = await fetchWithAuth(
          `${API_BASE_URL}/api/chat/commands/force-continue?${new URLSearchParams({ workspace_id: workspaceId }).toString()}`
        );
        if (!res.ok) {
          const err = await res.json().catch(() => ({ detail: `HTTP ${res.status}` }));
          throw new Error(typeof err.detail === 'string' ? err.detail : `HTTP ${res.status}`);
        }
        const d = await res.json() as { ok?: boolean; message?: string };
        statusText = d.message
          ? t('chat.cmd.forceContinueTitleMsg', { message: d.message })
          : t('chat.cmd.forceContinueDefault');
      } catch (e: unknown) {
        statusText = t('chat.cmd.forceContinueFailed', { error: e instanceof Error ? e.message : String(e) });
      }
    }

    const sysMsg = createLocalCommandSystemMessage(statusText, commandStartedAt);
    setMessages(prev => [...prev, sysMsg]);
    if (workspaceId && !readOnly) {
      appendMessagesToAPI(workspaceId, [userMessage, sysMsg]);
    }
  }, [workspaceId, readOnly, t]);

  const handleFailureFeedback = async (reason: string) => {
    const feedbackContent = t('chat.feedback.prefix', { reason });
    const userMessage: Message = {
      id: `feedback-${Date.now()}`,
      role: 'user',
      content: feedbackContent,
      timestamp: new Date(),
    };
    setMessages(prev => [...prev, userMessage]);
    // The turn API persists the canonical user message. Writing it through the
    // legacy messages endpoint first races with atomic turn creation.
    // Auto-create GitHub issue from failed feedback (fire-and-forget)
    if (workspaceId) {
      const qs = new URLSearchParams({ workspace_id: workspaceId, text: reason });
      fetchWithAuth(`${API_BASE_URL}/api/chat/commands/issue?${qs.toString()}`)
        .catch(() => {}); // best-effort, don't block the feedback flow
    }
    setIsTyping(true);
    setThinkingText('');
    setThoughtCount(0);
    setHasStreamedText(false);
    const now = Date.now();
    setTypingStartTime(now);
    typingStartTimeRef.current = now;
    setRunningToolTitle(null);
    firstEventTimeRef.current = null;
    isStreamingRef.current = true;
    hydrationEpochRef.current += 1;
    isAtBottomRef.current = true;
    await handleSendInternal(feedbackContent, String(userMessage.id));
  };

  const sendCheckpointTurn = async (
    content: string,
    diagnosisInvocation: DiagnosisInvocationRequest,
  ) => {
    const userMessage: Message = {
      id: `copilot-${Date.now()}`,
      role: 'user',
      content,
      timestamp: new Date(),
    };
    setMessages(prev => [...prev, userMessage]);
    setDiagnosisCheckpoint(null);
    setIsTyping(true);
    setThinkingText('');
    setThoughtCount(0);
    setHasStreamedText(false);
    const now = Date.now();
    setTypingStartTime(now);
    typingStartTimeRef.current = now;
    setRunningToolTitle(null);
    firstEventTimeRef.current = null;
    isStreamingRef.current = true;
    hydrationEpochRef.current += 1;
    isAtBottomRef.current = true;
    await handleSendInternal(content, String(userMessage.id), [], undefined, diagnosisInvocation);
  };

  const handleConfirmDiagnosisTime = (range: ConfirmedTimeRange) => {
    void sendCheckpointTurn(
      t('chat.copilot.confirmTimeMessage', range),
      {
        diagnosis_mode: 'copilot',
        trigger_type: 'human_message',
        supports_followup: true,
        interaction_action: 'confirm_time',
        confirmed_time_range: range,
      },
    );
  };

  const handleSelectDiagnosisRoutes = (routes: string[]) => {
    const labels = routes.map(route =>
      diagnosisCheckpoint?.kind === 'routing'
        ? diagnosisCheckpoint.candidates.find(candidate => candidate.route === route)?.label || route
        : route
    );
    void sendCheckpointTurn(
      t('chat.copilot.selectRoutesMessage', { routes: labels.join(', ') }),
      {
        diagnosis_mode: 'copilot',
        trigger_type: 'human_message',
        supports_followup: true,
        interaction_action: 'select_routes',
        selected_routes: routes,
      },
    );
  };

  const focusCheckpointInput = () => {
    requestAnimationFrame(() => textareaRef.current?.focus());
  };

  return (
    <Stack
      gap={0}
      style={{
        height: '100%',
        backgroundColor: '#fff'
      }}
    >
      {/* Chat section — shrinks to top half when file is open */}
      <div
        style={{
          flex: hasOpenFile ? undefined : 1,
          height: hasOpenFile ? '50%' : undefined,
          display: 'flex',
          flexDirection: 'column',
          overflow: 'hidden',
          minHeight: 0,
          transition: 'height 0.2s ease',
        }}
      >
        {/* Header */}
        <Box
          p="lg"
          style={{
            borderBottom: '1px solid #e5e7eb',
            backgroundColor: '#f9fafb',
            flexShrink: 0,
          }}
        >
          <Group justify="space-between" align="flex-start">
            <div>
              <Title order={4}>{t('chat.title')}</Title>
              <Text size="xs" c="carbon.6" mt={4}>
                {t('chat.subtitle')}
              </Text>
            </div>
            <Menu shadow="md" width={220} position="bottom-end">
              <Menu.Target>
                <ActionIcon variant="subtle" color="gray" size="sm">
                  <span style={{ fontSize: '16px', lineHeight: 1 }}>⋮</span>
                </ActionIcon>
              </Menu.Target>
              <Menu.Dropdown>
                <Menu.Item
                  color="red"
                  onClick={clearChat}
                  leftSection={<span style={{ fontSize: '14px' }}>🗑️</span>}
                >
                  {t('chat.clearHistory')}
                </Menu.Item>
              </Menu.Dropdown>
            </Menu>
          </Group>
        </Box>

        <ChatMessageList
          scrollContainerRef={scrollContainerRef}
          messagesEndRef={messagesEndRef}
          messages={displayMessages}
          isHistoryLoading={isHistoryLoading}
          isTyping={isTyping}
          thinkingText={thinkingText}
          runningToolTitle={runningToolTitle}
          thoughtCount={thoughtCount}
          typingStartTime={typingStartTime}
          isMobile={isMobile}
          workspaceId={workspaceId}
          onOpenFile={handleOpenFile}
          onForceContinue={readOnly ? undefined : handleForceContinue}
          checkpointContent={diagnosisCheckpoint && !readOnly ? (
            <DiagnosisCheckpoint
              key={diagnosisCheckpoint.kind === 'routing'
                ? `routing-${diagnosisCheckpoint.recommended_route}`
                : `time-${diagnosisCheckpoint.source_expression}`}
              checkpoint={diagnosisCheckpoint}
              disabled={isTyping}
              onConfirmTime={handleConfirmDiagnosisTime}
              onReviseTime={focusCheckpointInput}
              onSelectRoutes={handleSelectDiagnosisRoutes}
              onRejectRoutes={focusCheckpointInput}
            />
          ) : undefined}
        />

        {/* Feedback */}
        {showFeedback && workspaceId && (
          <FeedbackBanner
            workspaceId={workspaceId}
            readOnly={!!readOnly}
            refreshToken={feedbackRefreshToken}
            onFailureFeedback={handleFailureFeedback}
            onDismiss={() => setFeedbackDismissed(true)}
          />
        )}

        {jiraCommentDraft && !readOnly && (
          <>
            <div
              style={{
                height: '1px', flexShrink: 0, position: 'relative',
                backgroundColor: isJiraCommentEditorResizing ? '#60a5fa' : '#bfdbfe',
              }}
            >
              <div
                role="separator"
                aria-label={t('chat.cmd.commentEditorResize')}
                aria-orientation="horizontal"
                onPointerDown={(event) => {
                  event.preventDefault();
                  event.currentTarget.setPointerCapture(event.pointerId);
                  jiraCommentEditorResizeRef.current = { startY: event.clientY, startHeight: jiraCommentEditorHeight };
                  setIsJiraCommentEditorResizing(true);
                }}
                onPointerMove={(event) => {
                  const resize = jiraCommentEditorResizeRef.current;
                  if (!resize) return;
                  setJiraCommentEditorHeight(Math.min(720, Math.max(120, resize.startHeight + resize.startY - event.clientY)));
                }}
                onPointerUp={() => {
                  jiraCommentEditorResizeRef.current = null;
                  setIsJiraCommentEditorResizing(false);
                }}
                onPointerCancel={() => {
                  jiraCommentEditorResizeRef.current = null;
                  setIsJiraCommentEditorResizing(false);
                }}
                style={{
                  position: 'absolute', inset: '-8px 0', zIndex: 1,
                  cursor: 'ns-resize', touchAction: 'none',
                }}
              />
            </div>
            <Box
              p="md"
              style={{
                height: `${jiraCommentEditorHeight}px`, minHeight: '120px', maxHeight: '720px',
                borderBottom: '1px solid #dbeafe', backgroundColor: '#f8fbff', flexShrink: 0,
                display: 'flex', flexDirection: 'column',
              }}
            >
            <Stack gap="xs" style={{ height: '100%', minHeight: 0 }}>
              <Text size="sm" fw={600}>{t('chat.cmd.commentEditorTitle', { issue: jiraCommentDraft.issueKey })}</Text>
              <textarea
                aria-label={t('chat.cmd.commentEditorLabel')}
                value={jiraCommentDraft.body}
                maxLength={3000}
                onChange={(event) => setJiraCommentDraft(draft =>
                  draft ? { ...draft, body: event.target.value } : draft,
                )}
                style={{
                  width: '100%', flex: 1, minHeight: 0, resize: 'none', padding: '10px',
                  border: '1px solid #bfdbfe', borderRadius: '6px', fontFamily: 'inherit', fontSize: '13px', lineHeight: 1.5,
                }}
              />
              <Group justify="space-between">
                <Text size="xs" c="carbon.6">{t('chat.cmd.commentEditorCount', { count: jiraCommentDraft.body.length })}</Text>
                <Group gap="xs">
                  <Button variant="outline" color="gray" size="sm" onClick={cancelJiraCommentDraft} disabled={isJiraCommentPublishing}>
                    {t('chat.cmd.commentCancel')}
                  </Button>
                  <Button size="sm" onClick={publishJiraCommentDraft} loading={isJiraCommentPublishing} disabled={!jiraCommentDraft.body.trim()}>
                    {t('chat.cmd.commentPublish')}
                  </Button>
                </Group>
              </Group>
            </Stack>
          </Box>
          </>
        )}

        {/* Input */}
        <Box
          p="lg"
          style={{
            borderTop: '1px solid #e5e7eb',
            backgroundColor: '#f9fafb',
            flexShrink: 0,
          }}
        >
          <WorkspaceUsageBar status={usageStatus} isMobile={isMobile} />
          {readOnly ? (
            <Box
              p="sm"
              style={{
                borderRadius: '8px',
                border: '1px solid #d1fae5',
                backgroundColor: '#ecfdf5',
                textAlign: 'center',
              }}
            >
              <Text size="xs" c="teal.7">
                {t('chat.readOnly')}
              </Text>
            </Box>
          ) : (
            <div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
              <input
                ref={imageInputRef}
                type="file"
                accept="image/png,image/jpeg,image/webp,image/gif"
                multiple
                onChange={handleImageFileChange}
                style={{ display: 'none' }}
              />
              {attachmentError && (
                <Text size="xs" style={{ color: '#b91c1c' }}>
                  {attachmentError}
                </Text>
              )}
              <div
                style={{
                  border: '1px solid #d1d5db',
                  borderRadius: '12px',
                  backgroundColor: '#fff',
                  boxShadow: '0 1px 2px rgba(15, 23, 42, 0.05)',
                  overflow: 'visible',
                }}
              >
                {pendingAttachments.length > 0 && (
                  <div style={{ padding: '8px 10px 0' }}>
                    <ImageAttachmentList
                      attachments={pendingAttachments}
                      isUser={false}
                      workspaceId={workspaceId}
                      onOpen={(path) => setOpenFilePath(path)}
                      onRemove={removePendingAttachment}
                    />
                  </div>
                )}
                <div style={{ position: 'relative' }}>
                  {showCommands && filteredCommands.length > 0 && (
                    <div style={{
                      position: 'absolute',
                      bottom: '100%',
                      left: '10px',
                      right: '10px',
                      marginBottom: '8px',
                      backgroundColor: '#fff',
                      border: '1px solid #e5e7eb',
                      borderRadius: '8px',
                      boxShadow: '0 4px 12px rgba(0,0,0,0.1)',
                      overflow: 'hidden',
                      zIndex: 100,
                    }}>
                      {filteredCommands.map((cmd, i) => (
                        <div
                          key={cmd.name}
                          onMouseDown={(e) => { e.preventDefault(); applyCommand(cmd.name); }}
                          style={{
                            padding: '8px 12px',
                            cursor: 'pointer',
                            backgroundColor: i === selectedCommandIdx ? '#eff6ff' : 'transparent',
                            display: 'flex',
                            alignItems: 'baseline',
                            gap: '10px',
                          }}
                        >
                          <span style={{ fontFamily: 'monospace', fontSize: '13px', fontWeight: 600, color: '#1d4ed8' }}>{cmd.name}</span>
                          <span style={{ fontSize: '12px', color: '#6b7280' }}>{cmd.description}</span>
                        </div>
                      ))}
                    </div>
                  )}
                  <textarea
                    ref={textareaRef}
                    value={input}
                    onChange={handleInputChange}
                    onCompositionStart={() => { imeComposingRef.current = true; }}
                    onCompositionEnd={() => { setTimeout(() => { imeComposingRef.current = false; }, 0); }}
                    onKeyDown={handleKeyPress}
                    onPaste={handlePaste}
                    onBlur={() => setShowCommands(false)}
                    placeholder={inputPlaceholder}
                    rows={1}
                    style={{
                      width: '100%',
                      minHeight: hasOpenFile ? '34px' : '38px',
                      maxHeight: '120px',
                      padding: '9px 12px 6px',
                      border: 'none',
                      fontSize: '14px',
                      lineHeight: '20px',
                      resize: 'none',
                      fontFamily: 'inherit',
                      outline: 'none',
                      boxSizing: 'border-box',
                      backgroundColor: 'transparent',
                    }}
                  />
                </div>
                <div
                  style={{
                    display: 'flex',
                    alignItems: 'center',
                    justifyContent: 'space-between',
                    flexWrap: 'wrap',
                    gap: '10px',
                    padding: '0 8px 8px',
                  }}
                >
                  <div style={{ display: 'flex', alignItems: 'center', flexWrap: 'wrap', gap: '6px', minWidth: 0 }}>
                    <Tooltip label={t('chatPanel.uploadImage')} position="top" withArrow>
                      <ActionIcon
                        variant="subtle"
                        color="gray"
                        size="md"
                        onClick={handleImageUploadClick}
                        disabled={!workspaceId || isTyping}
                        aria-label={t('chatPanel.uploadImage')}
                        style={{ flexShrink: 0 }}
                      >
                        <UploadOutlined />
                      </ActionIcon>
                    </Tooltip>
                    {agentRuntime !== 'pi' && <Tooltip label={t('chat.codeDiagnosis.open')} position="top" withArrow>
                      <ActionIcon
                        variant="subtle"
                        color="gray"
                        size="md"
                        onClick={() => setCodeDiagnosisOpen(true)}
                        disabled={!workspaceId || isTyping || readOnly}
                        aria-label={t('chat.codeDiagnosis.open')}
                        style={{ flexShrink: 0 }}
                      >
                        <CodeOutlined />
                      </ActionIcon>
                    </Tooltip>}
                    {agentRuntime !== 'pi' && <Menu shadow="md" width={240} position="top-start">
                      <Menu.Target>
                        <button
                          type="button"
                          disabled={!workspaceId || isTyping}
                          aria-label={t('chat.codeContext.select')}
                          style={{ border: '1px solid #e5e7eb', background: '#fff', borderRadius: 6, color: '#4b5563', cursor: isTyping ? 'not-allowed' : 'pointer', opacity: isTyping ? 0.5 : 1, padding: '3px 8px', fontSize: 12, lineHeight: '18px', maxWidth: 190, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}
                        >
                          {codeContextLabel} <span aria-hidden="true">▾</span>
                        </button>
                      </Menu.Target>
                      <Menu.Dropdown>
                        <Menu.Item onClick={() => setSelectedCodeContext(null)}>{t('chat.codeContext.general')}</Menu.Item>
                        {loadedCodeContexts.map(context => (
                          <Menu.Item key={context.contextId} onClick={() => setSelectedCodeContext(context)}>{`${context.label} ${context.versionLabel ?? `@ ${context.commitSha.slice(0, 8)}`}`}</Menu.Item>
                        ))}
                      </Menu.Dropdown>
                    </Menu>}
                    <Tooltip label={t('chatPanel.commands')} position="top" withArrow>
                      <ActionIcon
                        variant="subtle"
                        color="gray"
                        size="md"
                        onClick={openCommandPalette}
                        disabled={!workspaceId || isTyping}
                        aria-label={t('chatPanel.commands')}
                        style={{ flexShrink: 0, fontFamily: 'monospace', fontWeight: 700 }}
                      >
                        /
                      </ActionIcon>
                    </Tooltip>
                    <Tooltip label={t('chatPanel.tierStandardTooltip')} position="top" withArrow>
                      <button
                        type="button"
                        onClick={() => setModelTier('standard')}
                        disabled={isTyping}
                        style={{
                          padding: '2px 9px',
                          fontSize: '12px',
                          lineHeight: '20px',
                          border: `1px solid ${modelTier === 'standard' ? '#228be6' : '#e5e7eb'}`,
                          borderRadius: '6px',
                          background: modelTier === 'standard' ? '#e7f5ff' : '#fff',
                          color: modelTier === 'standard' ? '#1971c2' : '#6b7280',
                          fontWeight: modelTier === 'standard' ? 600 : 400,
                          cursor: isTyping ? 'not-allowed' : 'pointer',
                          opacity: isTyping ? 0.5 : 1,
                        }}
                      >
                        {t('chat.tierStandard')}
                      </button>
                    </Tooltip>
                    <Tooltip
                      label={t('chatPanel.tierProTooltip')}
                      position="top"
                      withArrow
                      multiline
                      w={220}
                    >
                      <button
                        type="button"
                        onClick={() => setModelTier('pro')}
                        disabled={isTyping}
                        style={{
                          padding: '2px 9px',
                          fontSize: '12px',
                          lineHeight: '20px',
                          border: `1px solid ${modelTier === 'pro' ? '#e03131' : '#e5e7eb'}`,
                          borderRadius: '6px',
                          background: modelTier === 'pro' ? '#fff5f5' : '#fff',
                          color: modelTier === 'pro' ? '#c92a2a' : '#6b7280',
                          fontWeight: modelTier === 'pro' ? 600 : 400,
                          cursor: isTyping ? 'not-allowed' : 'pointer',
                          opacity: isTyping ? 0.5 : 1,
                        }}
                      >
                        {t('chat.tierPro')}
                      </button>
                    </Tooltip>
                    {modelTier === 'pro' && (
                      <Text size="xs" c="red.6" style={{ marginLeft: '2px', whiteSpace: 'nowrap' }}>
                        {t('chatPanel.tierProWarning')}
                      </Text>
                    )}
                  </div>
                  {isTyping ? (
                    <Tooltip label={t('chatPanel.stop')} position="top" withArrow>
                      <ActionIcon
                        onClick={handleStop}
                        color="red"
                        variant="light"
                        size="md"
                        aria-label={t('chatPanel.stop')}
                        style={{ flexShrink: 0 }}
                      >
                        <StopOutlined />
                      </ActionIcon>
                    </Tooltip>
                  ) : (
                    <Tooltip label={hasFailedAttachment ? t('chatPanel.removeFailedBeforeSend') : t('chatPanel.send')} position="top" withArrow>
                      <ActionIcon
                        onClick={handleSend}
                        disabled={!canSend}
                        color="blue"
                        variant={canSend ? 'filled' : 'light'}
                        size="md"
                        aria-label={t('chatPanel.send')}
                        style={{ flexShrink: 0 }}
                      >
                        <SendOutlined />
                      </ActionIcon>
                    </Tooltip>
                  )}
                </div>
              </div>
            </div>
          )}
        </Box>
      </div>

      <CodeDiagnosisModal
        opened={codeDiagnosisOpen}
        workspaceId={workspaceId}
        onClose={() => setCodeDiagnosisOpen(false)}
        onPrepare={prepareCodeContext}
      />

      {/* File Viewer — bottom half when a file is open */}
      {hasOpenFile && (
        <div
          style={{
            height: '50%',
            minHeight: 200,
            display: 'flex',
            flexDirection: 'column',
            overflow: 'hidden',
          }}
        >
          <FileViewer
            filePath={openFilePath!}
            onClose={() => setOpenFilePath(null)}
          />
        </div>
      )}
    </Stack>
  );
}
