import { useState, useRef, useEffect, useCallback } from 'react'
import { useParams, useNavigate } from 'react-router-dom'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import {
  ArrowLeft, Send, Square, ChevronDown, ChevronRight,
  Bot, User, Clock, Brain, Folder, Download, Trash2, X,
  Save, RotateCcw, History, Key, BookMarked, LayoutDashboard,
  MessageSquarePlus,
} from 'lucide-react'
import toast from 'react-hot-toast'
import { agentsApi, type WorkspaceFile, type MemoryEntry, type CoreMemory, type RecallEntry } from '../api/agents'
import { knowledgeApi } from '../api/knowledge'
import { useAppStore } from '../store/app'
import { Spinner } from '../components/ui'
import DirPickerModal from '../components/DirPickerModal'
import { clsx } from '../lib/clsx'
import { useMode } from '../api/mode'

// ── Types ─────────────────────────────────────────────────────────

type Role = 'user' | 'assistant'
type MsgStatus = 'streaming' | 'done' | 'error'

interface ThinkStep {
  type: 'think' | 'tool_call' | 'tool_result' | 'error' | 'think_chunk' | 'think_start'
  content: string
  tool?: string
}

/** One ReAct turn: Thought → Tool → Result, grouped by step index */
interface ReActTurn {
  step: number               // 0-based step index from backend
  thought?: string
  tool?: string
  toolInput?: string
  result?: string
  durationMs?: number        // think latency in ms
  effects: string[]          // inferred effect labels (IO, KB, Exec, Memory…)
  status: 'thinking' | 'calling' | 'done'
}

interface Message {
  id: string
  role: Role
  text: string
  status: MsgStatus
  steps: ThinkStep[]         // legacy — kept for history bubbles
  turns: ReActTurn[]         // Lambda Trace turns (live streaming)
  maxSteps?: number
  costUsd?: number
  runId?: string
  workspacePath?: string
}

// ── Effect inference ──────────────────────────────────────────────
// Maps a tool name to LambdaAgent effect labels.
// These are the labels shown in the ε: badge — they're a UNIQUE feature
// of LambdaAgent that no other framework exposes to end users.
function inferEffects(toolName?: string): string[] {
  if (!toolName) return []
  if (toolName === 'terminate') return ['Memory']
  const e: string[] = []
  if (['ReadFile', 'ListFiles', 'SearchContent'].includes(toolName)) e.push('IO')
  if (['WriteFile', 'EditFile'].includes(toolName)) e.push('IO', 'Write')
  if (['KBSearch', 'KBList'].includes(toolName)) e.push('KB')
  if (toolName === 'Bash') { e.push('IO'); e.push('Exec') }
  return [...new Set(e.length ? e : ['IO'])]
}

// ── Effect Badge ──────────────────────────────────────────────────
function EffectBadge({ effects, costUsd, pending }: {
  effects: string[]
  costUsd?: number
  pending?: boolean
}) {
  const costStr = costUsd !== undefined && costUsd > 0
    ? costUsd < 0.01
      ? `${(costUsd * 100).toFixed(2)}¢`
      : `$${costUsd.toFixed(3)}`
    : null

  return (
    <span className="inline-flex items-center gap-0.5 font-mono text-[10px] text-violet-600 bg-violet-50 border border-violet-200 rounded px-1.5 py-0.5 leading-none whitespace-nowrap">
      <span className="text-violet-400 mr-0.5">ε:</span>
      {effects.length === 0 && <span className="text-violet-300 italic">pure</span>}
      {effects.map((ef, i) => (
        <span key={ef} className="flex items-center">
          {i > 0 && <span className="text-violet-300 mx-0.5">·</span>}
          <span>{ef}</span>
        </span>
      ))}
      {costStr && (
        <>
          <span className="text-violet-300 mx-0.5">·</span>
          <span>Cost[{pending ? '~' : ''}{costStr}]</span>
        </>
      )}
    </span>
  )
}

// ── ReAct Turn Block (Lambda Trace) ───────────────────────────────
// One card per step: header shows step N/maxSteps + ε effect badge.
// Three rows: 💭 Thought · 🔧 Tool · 👁 Result.
// Collapsed = summary rows; expanded = full pre content.

function ReActTurnBlock({ turn, maxSteps, isActive }: {
  turn: ReActTurn
  maxSteps: number
  isActive: boolean
}) {
  const [open, setOpen] = useState(false)

  const TOOL_LABELS: Record<string, string> = {
    ReadFile: '📖 ReadFile', WriteFile: '📝 WriteFile', EditFile: '✏️ EditFile',
    ListFiles: '📁 ListFiles', Bash: '💻 Bash', KBSearch: '🔍 KBSearch',
    KBList: '📚 KBList', terminate: '✅ terminate', SearchContent: '🔎 SearchContent',
  }
  const toolLabel = turn.tool ? (TOOL_LABELS[turn.tool] ?? `🔧 ${turn.tool}`) : null
  const stepLabel = `Step ${turn.step + 1}/${maxSteps}`

  return (
    <div className={clsx(
      'rounded-lg overflow-hidden text-xs transition-all',
      isActive
        ? 'border border-indigo-300 shadow-[0_0_0_1px_rgba(99,102,241,0.15)]'
        : 'border border-gray-100',
    )}>
      {/* ── Header ── */}
      <button
        onClick={() => setOpen(o => !o)}
        className={clsx(
          'flex items-center gap-2 w-full px-3 py-1.5 text-left transition-colors',
          isActive ? 'bg-indigo-50 hover:bg-indigo-100' : 'bg-gray-50 hover:bg-gray-100',
        )}
      >
        {/* Spinner or check */}
        {isActive
          ? <span className="inline-block w-2.5 h-2.5 rounded-full border-2 border-indigo-400 border-t-transparent animate-spin shrink-0" />
          : <span className="text-gray-300 shrink-0">✓</span>
        }
        {/* Step counter */}
        <span className={clsx(
          'font-mono font-semibold shrink-0',
          isActive ? 'text-indigo-600' : 'text-gray-400',
        )}>
          {stepLabel}
        </span>
        {/* Tool name */}
        {toolLabel && (
          <span className="text-gray-500 truncate min-w-0">
            <span className="text-gray-300 mx-1">·</span>{toolLabel}
          </span>
        )}
        {/* Per-step effect badge — THE unique LambdaAgent differentiator */}
        <span className="ml-auto shrink-0">
          <EffectBadge effects={turn.effects} />
        </span>
        {open
          ? <ChevronDown size={10} className="shrink-0 text-gray-400" />
          : <ChevronRight size={10} className="shrink-0 text-gray-400" />
        }
      </button>

      {/* ── Collapsed summary table ── */}
      {!open && (
        <div className="grid grid-cols-[56px_1fr] border-t border-gray-50">
          {turn.thought && (
            <>
              <div className="flex items-center gap-1 px-2 py-1 bg-gray-50 border-r border-b border-gray-100 text-gray-400">
                <span>💭</span><span className="hidden sm:inline">思考</span>
              </div>
              <div className="px-2 py-1 text-gray-600 truncate border-b border-gray-50 leading-tight">
                {turn.thought.slice(0, 120)}
              </div>
            </>
          )}
          {turn.tool && (
            <>
              <div className="flex items-center gap-1 px-2 py-1 bg-gray-50 border-r border-b border-gray-100 text-gray-400">
                <span>🔧</span><span className="hidden sm:inline">工具</span>
              </div>
              <div className="px-2 py-1 text-gray-600 font-mono truncate border-b border-gray-50 leading-tight">
                {turn.tool}
                {turn.toolInput && (
                  <span className="text-gray-400"> ← {turn.toolInput.slice(0, 80)}</span>
                )}
              </div>
            </>
          )}
          {turn.result && (
            <>
              <div className="flex items-center gap-1 px-2 py-1 bg-gray-50 border-r border-gray-100 text-gray-400">
                <span>👁</span><span className="hidden sm:inline">结果</span>
              </div>
              <div className="px-2 py-1 text-gray-600 truncate leading-tight">
                {turn.result.slice(0, 100)}
              </div>
            </>
          )}
          {/* Active "waiting" row */}
          {isActive && !turn.thought && !turn.tool && (
            <div className="col-span-2 px-3 py-1.5 text-indigo-400 italic animate-pulse">
              正在推理…
            </div>
          )}
          {isActive && turn.thought && !turn.tool && (
            <div className="col-span-2 px-3 py-1.5 text-indigo-400 italic animate-pulse">
              🔧 调用工具中…
            </div>
          )}
          {isActive && turn.tool && !turn.result && (
            <div className="col-span-2 px-3 py-1.5 text-indigo-400 italic animate-pulse">
              👁 等待执行结果…
            </div>
          )}
        </div>
      )}

      {/* ── Expanded full content ── */}
      {open && (
        <div className="divide-y divide-gray-50 border-t border-gray-100">
          {turn.thought && (
            <>
              <div className="flex items-center gap-1.5 px-3 py-1 bg-amber-50 text-[10px] font-medium text-amber-700">
                <span>💭</span> 推理过程
                {turn.durationMs && (
                  <span className="ml-auto text-amber-400 font-mono">
                    {(turn.durationMs / 1000).toFixed(1)}s
                  </span>
                )}
              </div>
              <pre className="px-3 py-2 text-[11px] text-gray-700 whitespace-pre-wrap break-words font-mono leading-relaxed bg-white max-h-48 overflow-auto">
                {turn.thought}
              </pre>
            </>
          )}
          {turn.toolInput && (
            <>
              <div className="flex items-center gap-1.5 px-3 py-1 bg-blue-50 text-[10px] font-medium text-blue-700">
                <span>🔧</span> {turn.tool ?? '工具'} 调用
              </div>
              <pre className="px-3 py-2 text-[11px] text-gray-700 whitespace-pre-wrap break-words font-mono leading-relaxed bg-white max-h-32 overflow-auto">
                {turn.toolInput}
              </pre>
            </>
          )}
          {turn.result && (
            <>
              <div className="flex items-center gap-1.5 px-3 py-1 bg-emerald-50 text-[10px] font-medium text-emerald-700">
                <span>👁</span> 执行结果
              </div>
              <pre className="px-3 py-2 text-[11px] text-gray-700 whitespace-pre-wrap break-words font-mono leading-relaxed bg-white max-h-32 overflow-auto">
                {turn.result}
              </pre>
            </>
          )}
        </div>
      )}
    </div>
  )
}

// ── Lambda Trace View ─────────────────────────────────────────────
// Renders all ReAct turns for a message. During streaming the last
// turn is highlighted as active; done turns are collapsed by default.
// Cost estimate: ~2¢/step conservative midpoint from empirical runs.
// Clearly flagged with "est." so users know it's approximate.
//
// UX DETAIL: After the run completes the turn cards are auto-collapsed
// so the long final answer remains visible without scrolling. The summary
// bar (always shown) lets users expand the trace on demand.
const COST_EST_PER_STEP = 0.02

function LambdaTraceView({ turns, maxSteps, costUsd, streaming }: {
  turns: ReActTurn[]
  maxSteps: number
  costUsd?: number
  streaming: boolean
}) {
  // Auto-collapse turn cards when run completes; user can click to re-expand.
  // We use a ref to track whether we've already auto-collapsed (so manual
  // re-expand is not immediately reversed on next render).
  const autoCollapsedRef = useRef(false)
  const [cardsCollapsed, setCardsCollapsed] = useState(false)

  useEffect(() => {
    if (!streaming && turns.length > 0 && !autoCollapsedRef.current) {
      autoCollapsedRef.current = true
      setCardsCollapsed(true)
    }
    // Re-expand if a new streaming run starts on the same message object
    if (streaming) {
      autoCollapsedRef.current = false
      setCardsCollapsed(false)
    }
  }, [streaming, turns.length])

  if (turns.length === 0) return null

  const activeTurnIdx  = streaming ? turns.length - 1 : -1
  // Unique effects seen across all turns (for the summary bar)
  const allEffects     = [...new Set(turns.flatMap(t => t.effects))]
  // Total think latency from turns that have it
  const thinkMs        = turns.reduce((s, t) => s + (t.durationMs ?? 0), 0)

  // Cost value: real (from done event) > 0, else estimate based on step count.
  // Use turns.length (not just doneTurns) so estimate shows even mid-step.
  const realCost       = costUsd != null && costUsd > 0 ? costUsd : null
  const estimatedCost  = !realCost && turns.length > 0
    ? turns.length * COST_EST_PER_STEP
    : null
  const displayCost    = realCost ?? estimatedCost
  const isEstimated    = realCost === null && estimatedCost !== null

  function fmtCost(v: number) {
    return v < 0.01
      ? `${(v * 100).toFixed(2)}¢`
      : `$${v.toFixed(3)}`
  }
  const costStr = displayCost !== null ? fmtCost(displayCost) : null

  return (
    <div className="space-y-1 w-full">
      {/* ── Trace summary bar — always visible, clickable to toggle cards ── */}
      <button
        onClick={() => setCardsCollapsed(c => !c)}
        className={[
          'flex items-center gap-2 w-full px-3 py-1.5 rounded-lg border text-[10px] text-left transition-colors',
          streaming
            ? 'bg-indigo-50/60 border-indigo-200 cursor-default'
            : cardsCollapsed
              ? 'bg-gray-50 border-gray-200 hover:bg-gray-100 cursor-pointer'
              : 'bg-gray-50 border-gray-200 hover:bg-gray-100 cursor-pointer',
        ].join(' ')}
        disabled={streaming}
        title={streaming ? undefined : (cardsCollapsed ? '展开执行步骤' : '收起执行步骤')}
      >
        {/* Spinner / done check */}
        {streaming
          ? <span className="inline-block w-2.5 h-2.5 rounded-full border-2 border-indigo-400 border-t-transparent animate-spin shrink-0" />
          : <span className="text-emerald-500 shrink-0">✓</span>
        }

        {/* Step counter */}
        <span className="font-mono text-gray-500 shrink-0">
          {turns.length}<span className="text-gray-300">/{maxSteps}</span>
        </span>

        {/* Wall-clock think time */}
        {thinkMs > 0 && (
          <span className="font-mono text-gray-400 shrink-0">
            {thinkMs >= 60_000
              ? `${(thinkMs / 60_000).toFixed(1)}m`
              : `${(thinkMs / 1000).toFixed(0)}s`}
          </span>
        )}

        {/* Accumulated effect labels */}
        {allEffects.length > 0 && (
          <span className="font-mono text-violet-500 shrink-0">
            ε: {allEffects.join('·')}
          </span>
        )}

        {/* Cost — always right-aligned, most important field */}
        {costStr && (
          <span className={[
            'font-mono font-semibold shrink-0',
            isEstimated ? 'text-amber-500' : 'text-violet-600',
          ].join(' ')}>
            {isEstimated && (
              <span className="text-amber-400 text-[9px] mr-0.5 font-normal">est.</span>
            )}
            Cost[{isEstimated ? '~' : ''}{costStr}]
          </span>
        )}

        {/* Collapse toggle indicator — only after done */}
        {!streaming && (
          <span className="ml-auto shrink-0 text-gray-300">
            {cardsCollapsed
              ? <ChevronRight size={10} />
              : <ChevronDown size={10} />
            }
          </span>
        )}
      </button>

      {/* ── Turn cards — hidden when collapsed (auto after done, user-toggleable) ── */}
      {!cardsCollapsed && turns.map((turn, i) => (
        <ReActTurnBlock
          key={turn.step}
          turn={turn}
          maxSteps={maxSteps}
          isActive={i === activeTurnIdx}
        />
      ))}
    </div>
  )
}

// ── Legacy Step Block (for history bubbles with no turns) ─────────
function StepBlock({ step }: { step: ThinkStep }) {
  const [open, setOpen] = useState(false)
  const icons: Record<string, string> = {
    think: '💭', tool_call: '🔧', tool_result: '✅', error: '❌', think_chunk: '✨',
  }
  const labels: Record<string, string> = {
    think: '推理', tool_call: '工具调用', tool_result: '结果', error: '错误', think_chunk: '流式',
  }
  const headerLabel = step.tool ? `${labels[step.type]} · ${step.tool}` : labels[step.type]

  return (
    <div className="border border-gray-100 rounded-lg overflow-hidden text-xs">
      <button
        onClick={() => setOpen(o => !o)}
        className="flex items-center gap-2 w-full px-3 py-2 bg-gray-50 hover:bg-gray-100 text-gray-500 transition-colors"
      >
        <span>{icons[step.type] ?? '📌'}</span>
        <span className="font-medium">{headerLabel}</span>
        {open ? <ChevronDown size={12} className="ml-auto" /> : <ChevronRight size={12} className="ml-auto" />}
      </button>
      {open && (
        <pre className="px-3 py-2 text-gray-700 whitespace-pre-wrap break-words font-mono leading-relaxed bg-white max-h-48 overflow-auto">
          {step.content}
        </pre>
      )}
    </div>
  )
}

// ── Workspace panel ───────────────────────────────────────────────

function WorkspacePanel({
  agentId, runId, apiKey, workspacePath,
}: {
  agentId: string
  runId: string
  apiKey: string
  workspacePath?: string
}) {
  const [open, setOpen] = useState(false)
  const navigate = useNavigate()

  // Eagerly fetch file count so the header badge is populated immediately
  const { data, isLoading } = useQuery({
    queryKey: ['workspace', agentId, runId],
    queryFn: () => agentsApi.workspaceFiles(agentId, runId),
    staleTime: 60_000,
  })

  const shortPath = workspacePath
    ? workspacePath.split('/').slice(-2).join('/')   // "workspace/run_YYYYMMDD_HHmmss"
    : null

  async function download(file: WorkspaceFile) {
    const url = agentsApi.workspaceDownloadUrl(agentId, runId, file.path)
    const res = await fetch(url, { headers: { Authorization: `Bearer ${apiKey}` } })
    if (!res.ok) return
    const blob = await res.blob()
    const blobUrl = URL.createObjectURL(blob)
    const a = document.createElement('a')
    a.href = blobUrl
    a.download = file.name
    a.click()
    URL.revokeObjectURL(blobUrl)
  }

  function fmt(bytes: number) {
    if (bytes < 1024) return `${bytes} B`
    if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
    return `${(bytes / 1024 / 1024).toFixed(1)} MB`
  }

  return (
    <div className="mt-2 border border-gray-200 rounded-lg overflow-hidden text-xs">
      {/* Header: path · file count · 查看 · expand */}
      <div className="flex items-center gap-2 px-3 py-2 bg-gray-50 hover:bg-gray-100 transition-colors">
        <Folder size={12} className="text-emerald-500 shrink-0" />

        {/* Path + count — click to expand inline file list */}
        <button
          onClick={() => setOpen(o => !o)}
          className="flex items-center gap-1.5 flex-1 text-left min-w-0"
        >
          {shortPath
            ? <code className="font-mono text-gray-600 truncate" title={workspacePath}>{shortPath}</code>
            : <span className="text-gray-500">工作区文件</span>
          }
          {isLoading
            ? <span className="text-gray-400 shrink-0">…</span>
            : data && <span className="text-gray-400 shrink-0">（{data.files.length} 个）</span>
          }
          {open
            ? <ChevronDown size={11} className="ml-1 text-gray-400 shrink-0" />
            : <ChevronRight size={11} className="ml-1 text-gray-400 shrink-0" />
          }
        </button>

        {/* Single "查看" — opens workspace browser scoped to this run */}
        <button
          onClick={() => navigate(`/agents/${agentId}/workspace?run=${runId}`)}
          className="text-indigo-500 hover:text-indigo-700 shrink-0 font-medium"
          title={`在工作区浏览器查看 ${workspacePath ?? ''}`}
        >
          查看
        </button>
      </div>

      {/* Inline file list */}
      {open && (
        <div className="bg-white divide-y divide-gray-50">
          {isLoading && (
            <div className="flex items-center gap-2 px-3 py-2 text-gray-400">
              <Spinner size={12} /> 加载中…
            </div>
          )}
          {!isLoading && data?.files.length === 0 && (
            <div className="px-3 py-2 text-gray-400">暂无文件</div>
          )}
          {data?.files.map(f => (
            <div key={f.path} className="flex items-center gap-2 px-3 py-1.5 hover:bg-gray-50">
              <span className="flex-1 text-gray-700 font-mono truncate">{f.path}</span>
              <span className="text-gray-400 shrink-0">{fmt(f.size)}</span>
              <button
                onClick={() => download(f)}
                className="text-gray-400 hover:text-indigo-600 transition-colors shrink-0"
                title="下载"
              >
                <Download size={12} />
              </button>
            </div>
          ))}
        </div>
      )}
    </div>
  )
}

// ── Message bubble ────────────────────────────────────────────────

// Polls the run's current DB status so we can show a "Stop" button on any
// assistant bubble whose run is still going in the backend — even after
// the SSE connection has already closed. Without this, the only stop
// affordance lives at the input bar (which only shows during *active*
// streaming), so a long iterate run silently continued in the background
// with no UI for the user to cancel it.
function RunStatusControl({
  agentId, runId, apiKey,
}: {
  agentId: string; runId: string; apiKey: string
}) {
  const qc = useQueryClient()
  const [cancelling, setCancelling] = useState(false)
  const [lastError, setLastError] = useState<string | null>(null)
  const { data, refetch } = useQuery({
    queryKey: ['run-status', agentId, runId],
    queryFn: async () => {
      const r = await fetch(`/api/v1/agents/${agentId}/runs?limit=200`, {
        headers: { Authorization: `Bearer ${apiKey}` },
      })
      if (!r.ok) return null
      const j = await r.json()
      const row = (j.runs || []).find((x: { id: string; status: string }) => x.id === runId)
      return row?.status as string | undefined
    },
    // Poll only while the run is still alive — stop polling on terminal states.
    refetchInterval: (q) => {
      const s = q.state.data
      return s === 'running' || s === undefined ? 5000 : false
    },
    staleTime: 4000,
  })

  if (data !== 'running' && !cancelling && !lastError) return null

  return (
    <div className="flex flex-col gap-1 items-start">
      <button
        disabled={cancelling}
        onClick={async () => {
          setLastError(null)
          setCancelling(true)
          try {
            const r = await fetch(
              `/api/v1/agents/${agentId}/runs/${runId}/cancel`,
              {
                method: 'POST',
                headers: { Authorization: `Bearer ${apiKey}` },
              },
            )
            if (!r.ok) {
              // Surface backend errors (401/404/500) instead of silently
              // pretending the click did something.
              let body = ''
              try { body = await r.text() } catch { /* */ }
              setLastError(`HTTP ${r.status} ${r.statusText}${body ? ` — ${body.slice(0, 200)}` : ''}`)
              // eslint-disable-next-line no-console
              console.error('[cancel] failed', r.status, body)
            } else {
              // Immediately force the UI to act as if status==='cancelled' —
              // don't wait for the 5s poll. We also nuke the cached run-status
              // and the runs list so other panels re-fetch.
              qc.setQueryData(['run-status', agentId, runId], 'cancelled')
              qc.invalidateQueries({ queryKey: ['runs', agentId] })
              // Then verify backend agrees within a few seconds (the run
              // sometimes lingers as 'running' if the worker mid-claude-call
              // hasn't checked the cancel flag yet).
              setTimeout(() => { refetch() }, 800)
              setTimeout(() => { refetch() }, 2500)
              setTimeout(() => { refetch() }, 6000)
            }
          } catch (e) {
            const msg = (e as Error)?.message || String(e)
            setLastError(`network: ${msg}`)
            // eslint-disable-next-line no-console
            console.error('[cancel] threw', e)
          } finally {
            setCancelling(false)
          }
        }}
        className={clsx(
          'flex items-center gap-1.5 text-xs border rounded-lg px-3 py-1.5 transition-colors',
          cancelling
            ? 'bg-amber-50 border-amber-200 text-amber-700 cursor-wait'
            : 'bg-red-50 hover:bg-red-100 border-red-200 text-red-700 hover:text-red-900',
        )}
        title={`此 run 后端仍在运行 — 点击立即终止 (${runId})`}
      >
        <Square size={11} />
        {cancelling ? '取消中…' : '停止此 run'}
        <span className={cancelling ? 'text-amber-400 font-mono' : 'text-red-400 font-mono'}>
          {runId.slice(-8)}
        </span>
        {!cancelling && (
          <span className="ml-1 inline-block w-1.5 h-1.5 rounded-full bg-red-500 animate-pulse" />
        )}
      </button>
      {lastError && (
        <div className="text-[10px] text-red-600 bg-red-50 border border-red-200 rounded px-2 py-0.5 max-w-md">
          取消失败：{lastError}
        </div>
      )}
    </div>
  )
}


function MessageBubble({
  msg, agentId, apiKey, agentMaxSteps,
  onContinue,
}: {
  msg: Message; agentId: string; apiKey: string
  agentMaxSteps: number
  onContinue?: (runId: string, workspacePath: string) => void
}) {
  const isUser = msg.role === 'user'
  const isStreaming = msg.status === 'streaming'

  return (
    <div className={clsx('flex gap-3', isUser ? 'flex-row-reverse' : 'flex-row')}>
      <div className={clsx(
        'w-8 h-8 rounded-full flex items-center justify-center shrink-0 mt-0.5',
        isUser ? 'bg-indigo-100' : 'bg-gray-100',
      )}>
        {isUser ? <User size={15} className="text-indigo-600" /> : <Bot size={15} className="text-gray-600" />}
      </div>

      <div className={clsx('max-w-[80%] space-y-2', isUser ? 'items-end' : 'items-start')}>
        {/* Waiting indicator — shown when run is streaming but no step has arrived yet.
            Qianwen / non-streaming providers take 10-15s before the first step. */}
        {!isUser && isStreaming && msg.turns.length === 0 && (
          <div className="flex items-center gap-2 text-xs text-gray-400 px-1 py-0.5">
            <span className="animate-spin inline-block w-3 h-3 border border-gray-300 border-t-gray-500 rounded-full" />
            <span>模型思考中，请稍候…</span>
          </div>
        )}
        {/* Lambda Trace — live turns (streaming or completed with turns) */}
        {!isUser && msg.turns.length > 0 && (
          <LambdaTraceView
            turns={msg.turns}
            maxSteps={msg.maxSteps ?? agentMaxSteps}
            costUsd={msg.costUsd}
            streaming={isStreaming}
          />
        )}
        {/* Legacy step blocks — history bubbles loaded from DB (no turns) */}
        {!isUser && msg.turns.length === 0 && msg.steps.length > 0 && (
          <div className="space-y-1 w-full">
            {msg.steps.map((s, i) => <StepBlock key={i} step={s} />)}
          </div>
        )}

        {(msg.text || msg.status === 'streaming') && (
          <div className={clsx(
            'px-4 py-3 rounded-2xl text-sm leading-relaxed whitespace-pre-wrap break-words',
            isUser
              ? 'bg-indigo-600 text-white rounded-tr-sm'
              : msg.status === 'error'
                ? 'bg-red-50 text-red-700 border border-red-200 rounded-tl-sm'
                : 'bg-white text-gray-800 border border-gray-200 rounded-tl-sm shadow-sm',
          )}>
            {msg.text}
            {msg.status === 'streaming' && (
              <span className="inline-block w-1.5 h-4 bg-current opacity-70 animate-pulse ml-0.5 align-text-bottom" />
            )}
          </div>
        )}

        {/* Stop button — appears as SOON as the run_id is known (SSE
            'started' event), independent of the message's local 'done'
            state. Without this, a run that's chugging through 30+ steps
            without ever emitting a final 'done' shows no in-bubble stop
            control. The component polls DB status itself and auto-hides
            when the run reaches a terminal state. */}
        {!isUser && msg.runId && (
          <div className="w-full">
            <RunStatusControl agentId={agentId} runId={msg.runId} apiKey={apiKey} />
          </div>
        )}

        {/* Workspace file panel + Continue button — only after run completes */}
        {!isUser && msg.status === 'done' && msg.runId && (
          <div className="space-y-1.5 w-full">
            {/* Single panel: shows path, file count, and "查看" link all in one */}
            <WorkspacePanel
              agentId={agentId}
              runId={msg.runId}
              apiKey={apiKey}
              workspacePath={msg.workspacePath}
            />

            {onContinue && (
              <button
                onClick={() => onContinue(msg.runId!, msg.workspacePath ?? '')}
                className="flex items-center gap-1.5 text-xs text-indigo-600 hover:text-indigo-800 bg-indigo-50 hover:bg-indigo-100 border border-indigo-200 rounded-lg px-3 py-1.5 transition-colors"
                title={`在此 run 的工作区基础上继续提问\n${msg.workspacePath ?? ''}`}
              >
                <RotateCcw size={11} />
                继续此 run
                <span className="text-indigo-400 font-mono">{msg.runId?.slice(-8)}</span>
              </button>
            )}
          </div>
        )}
      </div>
    </div>
  )
}

// ── Memory panel ──────────────────────────────────────────────────

function MemoryPanel({ agentId, onClose }: { agentId: string; onClose: () => void }) {
  const qc = useQueryClient()
  const [tab, setTab] = useState<'core' | 'candidates' | 'recall'>('core')

  // ── Core Memory (Layer 1) ──
  const { data: coreData, isLoading: coreLoading } = useQuery({
    queryKey: ['memory-core', agentId],
    queryFn: () => agentsApi.coreMemory(agentId),
    refetchInterval: tab === 'core' ? 5000 : false,
  })

  // ── 自动提炼候选（L2，待确认）──
  const { data: candData } = useQuery({
    queryKey: ['memory-candidates', agentId],
    queryFn: () => agentsApi.memoryCandidates(agentId),
    refetchInterval: 8000,
  })
  const candidates = candData?.candidates ?? []
  const confirmCand = useMutation({
    mutationFn: (fact: string) => agentsApi.confirmCandidate(agentId, fact),
    onSuccess: () => {
      qc.invalidateQueries({ queryKey: ['memory-candidates', agentId] })
      qc.invalidateQueries({ queryKey: ['memory-core', agentId] })
    },
  })
  const rejectCand = useMutation({
    mutationFn: (fact: string) => agentsApi.rejectCandidate(agentId, fact),
    onSuccess: () => qc.invalidateQueries({ queryKey: ['memory-candidates', agentId] }),
  })

  // Local editing state — a copy of the core dict for editing
  const [editing, setEditing] = useState<CoreMemory | null>(null)
  const [newKey, setNewKey] = useState('')
  const [newVal, setNewVal] = useState('')

  // Sync editing state when remote data arrives (only if not actively editing)
  const prevCoreRef = useRef<CoreMemory | null>(null)
  useEffect(() => {
    if (coreData && !editing) {
      prevCoreRef.current = coreData.core
    }
  }, [coreData, editing])

  const updateCoreMut = useMutation({
    mutationFn: (core: CoreMemory) => agentsApi.updateCoreMemory(agentId, core),
    onSuccess: () => {
      setEditing(null)
      qc.invalidateQueries({ queryKey: ['memory-core', agentId] })
    },
  })

  const clearCoreMut = useMutation({
    mutationFn: () => agentsApi.clearCoreMemory(agentId),
    onSuccess: () => {
      setEditing(null)
      qc.invalidateQueries({ queryKey: ['memory-core', agentId] })
    },
  })

  const liveCore: CoreMemory = editing ?? coreData?.core ?? {}
  const coreEntries = Object.entries(liveCore)

  function startEdit() { setEditing({ ...coreData?.core }) }
  function cancelEdit() { setEditing(null); setNewKey(''); setNewVal('') }

  function addPair() {
    if (!newKey.trim()) return
    setEditing(prev => ({ ...(prev ?? {}), [newKey.trim()]: newVal }))
    setNewKey(''); setNewVal('')
  }

  function removePair(key: string) {
    setEditing(prev => {
      const next = { ...(prev ?? {}) }
      delete next[key]
      return next
    })
  }

  function updateValue(key: string, val: string) {
    setEditing(prev => ({ ...(prev ?? {}), [key]: val }))
  }

  // ── Recall Memory (Layer 2) ──
  const { data: recallData, isLoading: recallLoading } = useQuery({
    queryKey: ['memory-recall', agentId],
    queryFn: () => agentsApi.recallMemory(agentId, 30),
    enabled: tab === 'recall',
    refetchInterval: tab === 'recall' ? 10000 : false,
  })

  const recallEntries = recallData?.entries ?? []

  function fmtTs(ts: string) {
    return new Date(ts).toLocaleString('zh-CN', {
      month: 'numeric', day: 'numeric', hour: '2-digit', minute: '2-digit',
    })
  }

  return (
    <div className="absolute top-0 right-0 h-full w-80 bg-white border-l border-gray-200 flex flex-col z-10 shadow-xl">
      {/* Header */}
      <div className="flex items-center justify-between px-4 py-3 border-b border-gray-200 shrink-0">
        <div className="flex items-center gap-2">
          <Brain size={15} className="text-indigo-500" />
          <span className="text-sm font-semibold text-gray-900">智能体记忆</span>
        </div>
        <button onClick={onClose} className="text-gray-400 hover:text-gray-700 transition-colors">
          <X size={16} />
        </button>
      </div>

      {/* Tabs */}
      <div className="flex border-b border-gray-200 shrink-0">
        <button
          onClick={() => { setTab('core'); cancelEdit() }}
          className={clsx(
            'flex-1 flex items-center justify-center gap-1.5 py-2 text-xs font-medium transition-colors',
            tab === 'core'
              ? 'text-indigo-600 border-b-2 border-indigo-500'
              : 'text-gray-500 hover:text-gray-700'
          )}
        >
          <Key size={12} /> 核心记忆
        </button>
        <button
          onClick={() => { setTab('candidates'); cancelEdit() }}
          className={clsx(
            'flex-1 flex items-center justify-center gap-1.5 py-2 text-xs font-medium transition-colors',
            tab === 'candidates'
              ? 'text-indigo-600 border-b-2 border-indigo-500'
              : 'text-gray-500 hover:text-gray-700'
          )}
        >
          <Brain size={12} /> 待确认
          {candidates.length > 0 && (
            <span className="bg-indigo-100 text-indigo-600 px-1.5 py-0.5 rounded-full text-xs">
              {candidates.length}
            </span>
          )}
        </button>
        <button
          onClick={() => { setTab('recall'); cancelEdit() }}
          className={clsx(
            'flex-1 flex items-center justify-center gap-1.5 py-2 text-xs font-medium transition-colors',
            tab === 'recall'
              ? 'text-indigo-600 border-b-2 border-indigo-500'
              : 'text-gray-500 hover:text-gray-700'
          )}
        >
          <History size={12} /> 近期对话
          {recallData && recallData.total > 0 && (
            <span className="bg-gray-100 text-gray-500 px-1.5 py-0.5 rounded-full text-xs">
              {recallData.total}
            </span>
          )}
        </button>
      </div>

      {/* ── Core Memory Tab ── */}
      {tab === 'core' && (
        <div className="flex-1 flex flex-col min-h-0">
          {/* Toolbar */}
          <div className="flex items-center gap-2 px-3 py-2 border-b border-gray-100 shrink-0">
            {!editing ? (
              <>
                <button
                  onClick={startEdit}
                  className="flex items-center gap-1 text-xs text-indigo-600 hover:text-indigo-800 font-medium"
                >
                  <Key size={11} /> 编辑
                </button>
                <span className="text-gray-200">|</span>
                <button
                  onClick={() => clearCoreMut.mutate()}
                  disabled={clearCoreMut.isPending || coreEntries.length === 0}
                  className="flex items-center gap-1 text-xs text-red-500 hover:text-red-700 disabled:opacity-30"
                >
                  <Trash2 size={11} /> 清空
                </button>
              </>
            ) : (
              <>
                <button
                  onClick={() => updateCoreMut.mutate(liveCore)}
                  disabled={updateCoreMut.isPending}
                  className="flex items-center gap-1 text-xs text-green-600 hover:text-green-800 font-medium"
                >
                  <Save size={11} /> 保存
                </button>
                <span className="text-gray-200">|</span>
                <button
                  onClick={cancelEdit}
                  className="flex items-center gap-1 text-xs text-gray-500 hover:text-gray-700"
                >
                  <RotateCcw size={11} /> 取消
                </button>
              </>
            )}
            <span className="ml-auto text-xs text-gray-400">
              {coreEntries.length} 个字段
            </span>
          </div>

          {/* Key-value list */}
          <div className="flex-1 overflow-y-auto p-3 space-y-2">
            {coreLoading && (
              <div className="flex justify-center py-8"><Spinner size={24} /></div>
            )}
            {!coreLoading && coreEntries.length === 0 && !editing && (
              <div className="text-center py-10 text-sm text-gray-400">
                <Brain size={32} className="mx-auto mb-2 opacity-30" />
                <p>暂无核心记忆</p>
                <p className="text-xs mt-1">Agent 运行后会自动填充</p>
              </div>
            )}
            {coreEntries.map(([key, val]) => (
              <div key={key} className="group rounded-lg border border-gray-100 bg-gray-50 p-2.5">
                <div className="flex items-center justify-between gap-1 mb-1">
                  <span className="text-xs font-mono font-semibold text-indigo-600 truncate">{key}</span>
                  {editing && (
                    <button
                      onClick={() => removePair(key)}
                      className="text-gray-300 hover:text-red-500 opacity-0 group-hover:opacity-100 transition-all shrink-0"
                    >
                      <Trash2 size={11} />
                    </button>
                  )}
                </div>
                {editing ? (
                  <textarea
                    value={typeof val === 'string' ? val : JSON.stringify(val, null, 2)}
                    onChange={e => updateValue(key, e.target.value)}
                    rows={2}
                    className="w-full text-xs text-gray-700 bg-white border border-gray-200 rounded p-1.5 resize-y font-mono focus:outline-none focus:border-indigo-300"
                  />
                ) : (
                  <p className="text-xs text-gray-700 line-clamp-3 leading-relaxed">
                    {typeof val === 'string' ? val : JSON.stringify(val)}
                  </p>
                )}
              </div>
            ))}

            {/* Add new key-value pair (edit mode only) */}
            {editing && (
              <div className="rounded-lg border border-dashed border-indigo-200 bg-indigo-50 p-2.5 space-y-2">
                <p className="text-xs font-medium text-indigo-600">添加字段</p>
                <input
                  value={newKey}
                  onChange={e => setNewKey(e.target.value)}
                  placeholder="字段名"
                  className="w-full text-xs border border-gray-200 rounded px-2 py-1 font-mono focus:outline-none focus:border-indigo-300"
                />
                <textarea
                  value={newVal}
                  onChange={e => setNewVal(e.target.value)}
                  placeholder="字段值"
                  rows={2}
                  className="w-full text-xs border border-gray-200 rounded px-2 py-1 resize-y focus:outline-none focus:border-indigo-300"
                />
                <button
                  onClick={addPair}
                  disabled={!newKey.trim()}
                  className="text-xs text-indigo-600 font-medium hover:text-indigo-800 disabled:opacity-30"
                >
                  + 添加
                </button>
              </div>
            )}
          </div>
        </div>
      )}

      {/* ── 待确认候选 Tab ── */}
      {tab === 'candidates' && (
        <div className="flex-1 overflow-y-auto p-3 space-y-2">
          <p className="text-[11px] text-gray-400 mb-1">
            从对话里自动提炼的、可能值得长期记住的事实，确认后纳入核心记忆。
          </p>
          {candidates.length === 0 ? (
            <p className="text-xs text-gray-400 py-4 text-center">暂无待确认项</p>
          ) : (
            candidates.map((cand, i) => (
              <div key={i} className="bg-amber-50 border border-amber-100 rounded-lg p-2.5">
                <p className="text-xs text-gray-700 break-words">{cand.fact}</p>
                <div className="flex gap-2 mt-2">
                  <button
                    onClick={() => confirmCand.mutate(cand.fact)}
                    disabled={confirmCand.isPending}
                    className="text-[11px] px-2 py-0.5 rounded bg-indigo-600 text-white hover:bg-indigo-700 disabled:opacity-50"
                  >记住</button>
                  <button
                    onClick={() => rejectCand.mutate(cand.fact)}
                    disabled={rejectCand.isPending}
                    className="text-[11px] px-2 py-0.5 rounded bg-gray-100 text-gray-500 hover:bg-gray-200 disabled:opacity-50"
                  >忽略</button>
                </div>
              </div>
            ))
          )}
        </div>
      )}

      {/* ── Recall Memory Tab ── */}
      {tab === 'recall' && (
        <div className="flex-1 overflow-y-auto p-3 space-y-2">
          {recallLoading && (
            <div className="flex justify-center py-8"><Spinner size={24} /></div>
          )}
          {!recallLoading && recallEntries.length === 0 && (
            <div className="text-center py-10 text-sm text-gray-400">
              <History size={32} className="mx-auto mb-2 opacity-30" />
              <p>暂无对话记录</p>
            </div>
          )}
          {recallEntries.map((entry, i) => (
            <div key={entry.run_id + i} className="border border-gray-100 rounded-lg p-2.5 space-y-1.5">
              <div className="flex items-center justify-between gap-2">
                <span className="text-xs font-mono text-gray-400 truncate">{entry.run_id}</span>
                <span className="text-xs text-gray-400 shrink-0">{fmtTs(entry.ts)}</span>
              </div>
              <div className="space-y-1">
                <div className="flex gap-1.5">
                  <span className="text-xs font-medium text-blue-600 shrink-0">Q:</span>
                  <p className="text-xs text-gray-600 line-clamp-2 leading-relaxed">{entry.input}</p>
                </div>
                <div className="flex gap-1.5">
                  <span className="text-xs font-medium text-green-600 shrink-0">A:</span>
                  <p className="text-xs text-gray-600 line-clamp-2 leading-relaxed">{entry.output}</p>
                </div>
              </div>
            </div>
          ))}
        </div>
      )}
    </div>
  )
}

// ── Main page ─────────────────────────────────────────────────────

export default function Chat() {
  const { agentId } = useParams<{ agentId: string }>()
  const navigate = useNavigate()
  const { apiKey } = useAppStore()
  const qc = useQueryClient()
  // CR §10.3: in desktop mode this surface is a "专业任务工作台" for
  // scientists, not a generic chat. We keep the same component but
  // reframe the empty-state + input affordances.
  const { data: modeData } = useMode()
  const isDesktop = (modeData?.mode ?? 'desktop') === 'desktop'

  const [messages, setMessages]       = useState<Message[]>([])
  const [historyLoaded, setHistoryLoaded] = useState(false)
  // 工作台 AI 入口深链：/agents/{id}/chat?q=... 把首页输入的请求带过来预填
  const [input, setInput]             = useState(() => {
    const q = new URLSearchParams(window.location.search).get('q')
    return q ?? ''
  })
  // 工作区对话模式：/chat/{id}?dir=/path → 每次 run 把工作文件夹带进
  // context.work_dir，让助手就地在该文件夹读写改跑（Claude Code 式）。
  const [workDir, setWorkDir] = useState(() => new URLSearchParams(window.location.search).get('dir') ?? '')
  const [dirPickerOpen, setDirPickerOpen] = useState(false)
  const [streaming, setStreaming]     = useState(false)
  const [memoryOpen, setMemoryOpen]   = useState(false)
  const abortRef  = useRef<(() => void) | null>(null)
  const bottomRef = useRef<HTMLDivElement>(null)
  const textareaRef = useRef<HTMLTextAreaElement>(null)

  // audit/AUDIT_2026-06-05.md #15: navigating away from /chat/:agentId
  // while an SSE stream is in flight used to leave the backend run alive
  // — the agent thread kept spawning claude subprocesses (= real money on
  // Anthropic) and the React component kept calling setMessages on an
  // unmounted tree. The Stop-button path already calls abortRef.current
  // (which POSTs /cancel + closes the EventSource), so we just need the
  // same cleanup to fire on unmount. Empty deps array → runs exactly
  // once, and the returned function runs when the page is gone.
  useEffect(() => {
    return () => { abortRef.current?.() }
  }, [])

  const { data: agent } = useQuery({
    queryKey: ['agents', agentId],
    queryFn: () => agentsApi.get(agentId!),
    enabled: !!agentId,
  })

  // 工作区对话型助手(workspace.assistant)必须先绑定工作文件夹。无 ?dir / workDir
  // 时不能对话——否则后端会拒绝(WORKSPACE_DIR_REQUIRED),且 agent 读不到用户文件
  // 还'假装成功'。此处在前端先强制弹文件夹选择,禁用输入,关掉则退回工作台。
  const needsWorkDir = agent?.agent_template === 'workspace.assistant' && !workDir
  useEffect(() => {
    if (needsWorkDir) setDirPickerOpen(true)
  }, [needsWorkDir])

  // Extract maxSteps from agent config for the Lambda Trace step counter
  const agentMaxSteps = (() => {
    const cfg = (agent?.config ?? {}) as Record<string, unknown>
    const react = (cfg.react ?? {}) as Record<string, unknown>
    return (react.maxSteps as number) ?? 40
  })()

  // Load linked KB details for the header badge display
  const { data: agentKBData } = useQuery({
    queryKey: ['agent-knowledge', agentId],
    queryFn: () => agentsApi.getKBs(agentId!),
    enabled: !!agentId && (agent?.kb_ids?.length ?? 0) > 0,
  })

  const { data: runsData } = useQuery({
    queryKey: ['runs', agentId],
    queryFn: () => agentsApi.runs(agentId!),
    enabled: !!agentId,
    retry: false,
    staleTime: Infinity,
  })

  // Restore history from stored runs on first load
  useEffect(() => {
    if (historyLoaded || !runsData?.runs) return
    const historical: Message[] = []
    const sorted = [...runsData.runs].reverse()
    for (const run of sorted) {
      // Need at least an input to show anything meaningful.
      if (!run.input) continue
      historical.push({
        id: `hist-user-${run.id}`,
        role: 'user',
        text: run.input.slice(0, 2000),
        status: 'done',
        steps: [],
        turns: [],
      })
      // Pick text + UI status from the run's terminal state. In-flight
      // (`running`) and `cancelled` / `failed` rows often have empty
      // `output`; we used to skip those entirely, which is why the
      // current iterate run vanished from the UI after a hard refresh.
      // Now we synthesize a placeholder so the RunStatusControl button
      // still has a bubble to attach to.
      let bubbleText: string
      let bubbleStatus: MsgStatus = 'done'
      const out = (run.output || '').slice(0, 4000)
      if (out) {
        bubbleText = out
      } else if (run.status === 'running') {
        bubbleText = '⏳ 这条 run 仍在后端运行（刷新前没产出最终结果）'
      } else if (run.status === 'cancelled') {
        bubbleText = '🛑 这条 run 已被取消，没有最终输出'
      } else if (run.status === 'failed') {
        bubbleText = `❌ 这条 run 失败${run.error ? `：${String(run.error).slice(0, 200)}` : ''}`
        bubbleStatus = 'error'
      } else {
        bubbleText = '（无输出）'
      }
      historical.push({
        id: `hist-asst-${run.id}`,
        role: 'assistant',
        text: bubbleText,
        status: bubbleStatus,
        steps: [],
        turns: [],
        costUsd: run.cost_usd ?? undefined,
        runId: run.id,
        workspacePath: run.workspace_path ?? undefined,
      })
    }
    if (historical.length > 0) {
      setMessages(prev => {
        // Don't replace in-memory messages that have live Lambda Trace turns
        // (populated during an active or just-completed streaming run). The DB
        // version has turns:[] so reloading it would wipe the trace.
        // This race only matters if runsData loads while a run is in flight —
        // normally historyLoaded=true guards it, but a fresh first-load that
        // races a fast run could slip through.
        const hasLiveTurns = prev.some(m => m.role === 'assistant' && m.turns.length > 0)
        return hasLiveTurns ? prev : historical
      })
    }
    setHistoryLoaded(true)
  }, [runsData, historyLoaded])

  useEffect(() => {
    if (!historyLoaded && runsData !== undefined) setHistoryLoaded(true)
  }, [runsData, historyLoaded])

  useEffect(() => {
    bottomRef.current?.scrollIntoView({ behavior: 'smooth' })
  }, [messages])

  useEffect(() => {
    const ta = textareaRef.current
    if (!ta) return
    ta.style.height = 'auto'
    ta.style.height = `${Math.min(ta.scrollHeight, 160)}px`
  }, [input])

  // Track which run to continue from (useRef avoids stale-closure in useCallback)
  const [continueFromRunId, setContinueFromRunId] = useState<string | null>(null)
  const [continueFromPath, setContinueFromPath] = useState<string | null>(null)
  const continueRunIdRef  = useRef<string | null>(null)
  const continuePathRef   = useRef<string | null>(null)
  // 会话记忆：当前对话 thread。每条消息带上，后端据此注入前情。
  // 切换 agent 或点「新建会话」时清空，由本次 run 的 started 事件回填。
  const threadIdRef = useRef<string | null>(null)
  const [historyOpen, setHistoryOpen] = useState(false)

  // 切换 agent → 重置 thread（不同 agent 的会话不混）
  useEffect(() => { threadIdRef.current = null }, [agentId])

  // 历史会话列表（下拉打开时拉取）
  const { data: threadsData, refetch: refetchThreads } = useQuery({
    queryKey: ['threads', agentId],
    queryFn: () => agentsApi.threads(agentId!),
    enabled: !!agentId && historyOpen,
  })

  // 新建会话：清空 thread + 清空消息（产物 continue 上下文也一并清）
  const startNewThread = useCallback(() => {
    threadIdRef.current = null
    continueRunIdRef.current = null
    continuePathRef.current = null
    setContinueFromRunId(null)
    setContinueFromPath(null)
    setMessages([])
    setHistoryOpen(false)
  }, [])

  // 载入一条历史会话：拉 thread 内 runs 渲染成气泡，并接上该 thread
  const loadThread = useCallback(async (threadId: string) => {
    if (!agentId) return
    try {
      const res = await agentsApi.threadRuns(agentId, threadId)
      const msgs: Message[] = []
      for (const r of res.runs) {
        msgs.push({ id: r.id + '_u', role: 'user', text: r.input, status: 'done', steps: [], turns: [] })
        msgs.push({ id: r.id + '_a', role: 'assistant', text: r.output || '（无输出）',
                    status: r.status === 'failed' ? 'error' : 'done',
                    runId: r.id, workspacePath: r.workspace_path || undefined, steps: [], turns: [] })
      }
      setMessages(msgs)
      threadIdRef.current = threadId
      setHistoryOpen(false)
    } catch (e) {
      toast.error('载入会话失败')
    }
  }, [agentId])

  // Mode selector for continue-this-run flows.
  //   iterate (default): full pipeline, new cycle_N+1
  //   edit:              call ONE sub-agent on the same cycle
  //   chat:              read-only Q&A, no sub-agents
  type Mode = 'iterate' | 'edit' | 'chat'
  const [mode, setMode] = useState<Mode>('iterate')
  const [targetSubagent, setTargetSubagent] = useState<string>('')
  const modeRef            = useRef<Mode>('iterate')
  const targetSubagentRef  = useRef<string>('')

  // Available sub-agents read straight from the agent's config — backend
  // accepts both camelCase `subAgents` and snake_case `sub_agents`.
  const subAgents: string[] = (() => {
    const cfg = (agent?.config ?? {}) as Record<string, unknown>
    const dict = (cfg.subAgents ?? cfg.sub_agents ?? {}) as Record<string, { tool?: string; tool_name?: string }>
    const out: string[] = []
    for (const [name, def] of Object.entries(dict)) {
      const toolName = (def?.tool || def?.tool_name) ?? `call_${name.replace('-agent', '')}`
      if (!out.includes(toolName)) out.push(toolName)
    }
    return out
  })()

  const sendMessage = useCallback(async () => {
    if (!input.trim() || streaming || !agentId) return
    // 工作区助手未绑定文件夹:不发,改为强制弹文件夹选择。
    if (needsWorkDir) { setDirPickerOpen(true); return }

    const userText = input.trim()
    // Read from refs — always current, no stale-closure issue
    const ctxRunId = continueRunIdRef.current
    const ctxPath  = continuePathRef.current
    const ctxMode  = modeRef.current
    const ctxTarget = targetSubagentRef.current
    // Clear continue context after capturing
    continueRunIdRef.current  = null
    continuePathRef.current   = null
    modeRef.current           = 'iterate'
    targetSubagentRef.current = ''
    setContinueFromRunId(null)
    setContinueFromPath(null)
    setMode('iterate')
    setTargetSubagent('')

    setInput('')
    setMessages(prev => [
      ...prev,
      { id: crypto.randomUUID(), role: 'user', text: userText, status: 'done', steps: [], turns: [] },
    ])

    const assistantId = crypto.randomUUID()
    setMessages(prev => [
      ...prev,
      { id: assistantId, role: 'assistant', text: '', status: 'streaming', steps: [], turns: [],
        maxSteps: agentMaxSteps },
    ])
    setStreaming(true)

    let cancelled    = false
    let doneReceived = false   // set when we process the SSE 'done' event; used to
                               // exit the outer while loop (plain `break` only exits
                               // the inner `for (const chunk of parts)` loop)
    let finalText = ''
    let idleTimer: ReturnType<typeof setInterval> | null = null
    let currentRunId: string | null = null   // captured from the SSE 'started' event
    const abortCtrl = new AbortController()

    // AUDIT_2026-06-11 ⑥: SSE 静默断开（后端重启/网络中断）时拉一次
    // run 的最终状态 —— 后端启动清扫会把孤儿 run 收尾成 failed/cancelled，
    // 前端据此结束气泡，而不是永远转圈或只显示笼统的网络错误。
    const fetchFinalStatus = async (rid: string): Promise<string | null> => {
      try {
        const r = await fetch(`/api/v1/traces/${rid}`, {
          headers: { Authorization: `Bearer ${apiKey}` },
        })
        if (!r.ok) return null
        const j = await r.json() as { status?: string }
        return j.status ?? null
      } catch { return null }
    }
    const statusLabel = (s: string | null) =>
      s === 'completed' ? '已完成' : s === 'cancelled' ? '已取消'
        : s === 'failed' ? '已失败' : '状态未知 — 请查看运行历史'

    try {
      const res = await fetch(`/api/v1/agents/${agentId}/run/stream`, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${apiKey}` },
        body: JSON.stringify({
          input: userText,
          parameters: {},
          context: {
            // 工作区对话模式（workDir）不挂续跑上下文——干净会话，避免旧 run
            // 工作区劫持 [工作目录]；连续性靠 thread 记忆 + 文件夹里的真实文件。
            ...((ctxRunId && !workDir) ? { run_id: ctxRunId, workspace_path: ctxPath } : {}),
            ...(workDir ? { work_dir: workDir } : {}),
          },
          // Only meaningful when continuing — fresh / workspace runs run as iterate
          mode: (ctxRunId && !workDir) ? ctxMode : 'iterate',
          target_subagent: (ctxRunId && !workDir && ctxMode === 'edit') ? ctxTarget : '',
          // 会话记忆：带上当前 thread（首条消息为空 → 后端新建，started 回填）
          thread_id: threadIdRef.current ?? '',
        }),
        signal: abortCtrl.signal,
      })

      if (!res.ok || !res.body) throw new Error(`HTTP ${res.status}`)

      const reader = res.body.getReader()
      const decoder = new TextDecoder()
      let buffer = ''

      // Stop button: setting `cancelled` alone isn't enough — reader.read()
      // can park forever while the backend is still computing (no token to
      // wake it). We also call reader.cancel() to release the read and
      // abortCtrl.abort() to tear down the underlying connection so the
      // backend sees the client disconnect.
      //
      // For TRUE cancellation we also POST /runs/<id>/cancel — without
      // that the agent's background thread keeps spawning claude
      // subprocesses even after the SSE connection is dead. We fire the
      // POST in the background (no await) so the UI unblocks immediately.
      abortRef.current = () => {
        cancelled = true
        if (currentRunId) {
          fetch(`/api/v1/agents/${agentId}/runs/${currentRunId}/cancel`, {
            method: 'POST',
            headers: { Authorization: `Bearer ${apiKey}` },
          }).catch(() => { /* nop — best-effort */ })
        }
        try { reader.cancel('user stop') } catch { /* nop */ }
        try { abortCtrl.abort() } catch { /* nop */ }
      }

      // Idle-timeout watchdog: if no SSE chunk arrives for this long, assume
      // the upstream connection is dead (e.g. backend restarted mid-stream).
      // Without this the reader.read() can park forever and freeze the input
      // box until the user hard-refreshes the page.
      const IDLE_TIMEOUT_MS = 90_000
      let lastChunkAt = Date.now()
      idleTimer = setInterval(() => {
        if (Date.now() - lastChunkAt > IDLE_TIMEOUT_MS) {
          cancelled = true
          try { reader.cancel('idle timeout') } catch { /* nop */ }
          try { abortCtrl.abort() } catch { /* nop */ }
        }
      }, 5_000)

      while (!cancelled && !doneReceived) {
        const { done, value } = await reader.read()
        if (done) break
        lastChunkAt = Date.now()

        buffer += decoder.decode(value, { stream: true })
        const parts = buffer.split('\n\n')
        buffer = parts.pop() ?? ''

        for (const chunk of parts) {
          if (!chunk.trim()) continue
          const lines = chunk.split('\n')
          let eventType = 'message'
          let dataLine = ''
          for (const line of lines) {
            if (line.startsWith('event: ')) eventType = line.slice(7).trim()
            if (line.startsWith('data: ')) dataLine = line.slice(6)
          }
          if (!dataLine) continue

          let payload: Record<string, unknown>
          try { payload = JSON.parse(dataLine) } catch { continue }

          if (eventType === 'started') {
            // Backend emits this immediately after creating the run row,
            // long before any work happens. Capture run_id so the Stop
            // button can call /cancel even if the user clicks before any
            // other event arrives.
            const rid = payload.run_id as string | undefined
            if (rid) {
              currentRunId = rid
              // Also stamp it on the assistant message immediately so the
              // bubble can render the polling Stop button right away —
              // without this, the button waits for the SSE 'done' event
              // and would never appear during long in-flight runs.
              setMessages(prev => prev.map(m =>
                m.id === assistantId ? { ...m, runId: rid } : m,
              ))
            }
            // 会话记忆：回填后端解析/新建的 thread_id，后续消息复用
            const tid = payload.thread_id as string | undefined
            if (tid) threadIdRef.current = tid

          } else if (eventType === 'answer') {
            // answer event: backend sends {content, step, tool, duration_ms}
            const text = (payload.content ?? payload.output ?? payload.message ?? '') as string
            if (text) finalText = text

          } else if (eventType === 'cancelled') {
            // Backend confirmed the run was cancelled. Mark the bubble.
            setMessages(prev => prev.map(m =>
              m.id === assistantId
                ? { ...m, status: 'done', text: m.text || '（已取消）' }
                : m,
            ))
            qc.invalidateQueries({ queryKey: ['runs', agentId] })

          } else if (eventType === 'done') {
            const text = (payload.output ?? payload.message ?? '') as string
            if (text) finalText = text
            const runId         = payload.run_id as string | undefined
            const workspacePath = (payload.workspace_path as string) || undefined
            // FIX: `(0.0 as number) || undefined` evaluates to `undefined` — use
            // explicit null-check instead so a real cost of $0.00 is not swallowed.
            const costUsd = payload.cost_usd != null
              ? (payload.cost_usd as number)
              : undefined
            setMessages(prev => prev.map(m =>
              m.id === assistantId
                ? { ...m, text: finalText || m.text, status: 'done',
                    runId, workspacePath, costUsd,
                    // Mark all turns done on completion
                    turns: m.turns.map(t => ({ ...t, status: 'done' as const })) }
                : m,
            ))
            qc.invalidateQueries({ queryKey: ['runs', agentId] })
            doneReceived = true   // signal outer while loop to exit cleanly
            break

          } else if (eventType === 'think_start') {
            // Immediately show "thinking…" in the bubble before LLM call returns
            const content = (payload.content as string) || '正在推理…'
            setMessages(prev => prev.map(m =>
              m.id === assistantId ? { ...m, text: `⏳ ${content}` } : m,
            ))

          } else if (eventType === 'think') {
            // LLM reasoning done — upsert into the step's ReActTurn
            const content    = (payload.content as string) || ''
            const stepIdx    = (payload.step as number) ?? 0
            const durMs      = (payload.duration_ms as number) || undefined

            // Update bubble text: show the currently active tool call label
            let bubbleText = `⏳ Step ${stepIdx + 1} — 推理中…`
            try {
              const cleaned = content.replace(/^```(?:json)?\s*/m, '').replace(/```\s*$/m, '').trim()
              const obj = JSON.parse(cleaned)
              if (obj.tool) {
                const TOOL_LABELS: Record<string, string> = {
                  call_physlit: '📚 文献调研', call_physsim: '🔬 数值模拟',
                  call_physanalyst: '📊 数据分析', call_physwriter: '✍️ 论文写作',
                  terminate: '✅ 完成任务',
                }
                const lbl = TOOL_LABELS[obj.tool] || `🔧 ${obj.tool}`
                const inp = typeof obj.input === 'string'
                  ? obj.input.slice(0, 80) : JSON.stringify(obj.input).slice(0, 80)
                bubbleText = `${lbl}${inp ? `\n${inp}` : ''}`
              }
            } catch { /* raw thought, not a tool call */ }

            setMessages(prev => prev.map(m => {
              if (m.id !== assistantId) return m
              const turns = [...m.turns]
              const existing = turns.findIndex(t => t.step === stepIdx)
              if (existing >= 0) {
                turns[existing] = { ...turns[existing], thought: content, durationMs: durMs, status: 'calling' }
              } else {
                turns.push({ step: stepIdx, thought: content, durationMs: durMs,
                             effects: [], status: 'calling' })
              }
              return { ...m, text: bubbleText, turns,
                       steps: [...m.steps, { type: 'think', content }] }
            }))

          } else if (eventType === 'think_chunk') {
            // Streaming text chunks — accumulate into message bubble
            const chunk = (payload.content as string) || ''
            if (chunk) {
              setMessages(prev => prev.map(m =>
                m.id === assistantId
                  ? { ...m, text: (m.text || '') + chunk }
                  : m,
              ))
            }

          } else if (eventType === 'tool_call') {
            // Tool dispatched — update turn with tool name + input
            const tool    = (payload.tool as string) || '工具'
            const content = (payload.content as string) || ''
            const stepIdx = (payload.step as number) ?? 0
            const effects = inferEffects(tool)

            const TOOL_LABELS: Record<string, string> = {
              call_physlit: '📚 文献调研', call_physsim: '🔬 数值模拟',
              call_physanalyst: '📊 数据分析', call_physwriter: '✍️ 论文写作',
              terminate: '✅ 完成', Bash: '💻 执行命令', WriteFile: '📝 写入文件',
              ReadFile: '📖 读取文件', ListFiles: '📁 列目录', KBSearch: '🔍 检索知识库',
            }
            const label = TOOL_LABELS[tool] || `🔧 ${tool}`
            const preview = content.slice(0, 80)

            setMessages(prev => prev.map(m => {
              if (m.id !== assistantId) return m
              const turns = [...m.turns]
              const existing = turns.findIndex(t => t.step === stepIdx)
              if (existing >= 0) {
                turns[existing] = { ...turns[existing], tool, toolInput: content,
                                    effects, status: 'calling' }
              } else {
                turns.push({ step: stepIdx, tool, toolInput: content,
                             effects, status: 'calling' })
              }
              return { ...m, text: `${label}\n${preview}`, turns,
                       steps: [...m.steps, { type: 'tool_call', content, tool }] }
            }))

          } else if (eventType === 'tool_result') {
            // Result back — mark turn done
            const content = (payload.content as string) || JSON.stringify(payload, null, 2)
            const tool    = (payload.tool as string) || undefined
            const stepIdx = (payload.step as number) ?? 0
            const preview = content.slice(0, 60) + (content.length > 60 ? '…' : '')

            setMessages(prev => prev.map(m => {
              if (m.id !== assistantId) return m
              const turns = [...m.turns]
              const existing = turns.findIndex(t => t.step === stepIdx)
              if (existing >= 0) {
                turns[existing] = { ...turns[existing], result: content, status: 'done' }
              } else {
                turns.push({ step: stepIdx, tool, result: content,
                             effects: inferEffects(tool), status: 'done' })
              }
              return { ...m, text: `✅ ${preview}`, turns,
                       steps: [...m.steps, { type: 'tool_result', content, tool }] }
            }))

          } else if (eventType === 'error') {
            const content = (payload.content as string) || JSON.stringify(payload, null, 2)
            setMessages(prev => prev.map(m =>
              m.id === assistantId
                ? { ...m, steps: [...m.steps, { type: 'error', content }] }
                : m,
            ))
          }
        }
      }

      if (cancelled && !doneReceived) {
        setMessages(prev => prev.map(m =>
          m.id === assistantId ? { ...m, status: 'done', text: m.text || '（已停止）' } : m,
        ))
      } else if (!doneReceived) {
        // 流在没有 done/cancelled 终态事件的情况下结束 —— 孤儿 run 场景。
        // 此前这里什么都不做，气泡永远停在 streaming。
        const final = currentRunId ? await fetchFinalStatus(currentRunId) : null
        setMessages(prev => prev.map(m =>
          m.id === assistantId
            ? { ...m, status: final === 'completed' ? 'done' : 'error',
                text: m.text || `（连接中断，运行${statusLabel(final)}）` }
            : m,
        ))
        qc.invalidateQueries({ queryKey: ['runs', agentId] })
      }
    } catch (e) {
      // User-initiated stop / abort throws AbortError — treat as graceful
      // stop, not as an error red bubble.
      const err = e as Error
      const isAbort = err.name === 'AbortError' || cancelled
      if (isAbort) {
        setMessages(prev => prev.map(m =>
          m.id === assistantId ? { ...m, status: 'done', text: m.text || '（已停止）' } : m,
        ))
      } else {
        // 网络层异常（如后端重启杀掉了连接）：同样优先报告 run 的
        // 真实终态，拉不到再显示原始错误。
        const final = currentRunId ? await fetchFinalStatus(currentRunId) : null
        setMessages(prev => prev.map(m =>
          m.id === assistantId
            ? final
              ? { ...m, status: final === 'completed' ? 'done' : 'error',
                  text: m.text || `（连接中断，运行${statusLabel(final)}）` }
              : { ...m, status: 'error', text: `错误：${err.message}` }
            : m,
        ))
        if (final) qc.invalidateQueries({ queryKey: ['runs', agentId] })
      }
    } finally {
      if (idleTimer) clearInterval(idleTimer)
      setStreaming(false)
      abortRef.current = null
    }
  }, [input, streaming, agentId, apiKey, qc, needsWorkDir])

  // Belt-and-suspenders safety net: if the SSE stream silently drops (eg. the
  // backend was restarted), the page would otherwise leave `streaming` stuck
  // at true and the input box disabled. Pressing Escape now force-unsticks.
  useEffect(() => {
    function onKey(e: KeyboardEvent) {
      if (e.key === 'Escape' && streaming) {
        abortRef.current?.()
        setStreaming(false)
      }
    }
    window.addEventListener('keydown', onKey)
    return () => window.removeEventListener('keydown', onKey)
  }, [streaming])

  function handleKeyDown(e: React.KeyboardEvent<HTMLTextAreaElement>) {
    if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); sendMessage() }
  }

  const EXAMPLES = ['你好，请介绍一下你自己', '帮我解释一下量子计算的基本原理', '写一首关于春天的短诗']

  return (
    <div className="flex flex-col h-screen">
      {/* Header */}
      <div className="flex items-center gap-3 px-6 py-4 border-b border-gray-200 bg-white shrink-0">
        <button onClick={() => navigate('/agents')} className="text-gray-400 hover:text-gray-700 transition-colors">
          <ArrowLeft size={18} />
        </button>
        <div className="w-8 h-8 bg-indigo-100 rounded-full flex items-center justify-center">
          <Bot size={16} className="text-indigo-600" />
        </div>
        <div className="flex-1 min-w-0">
          <p className="text-sm font-semibold text-gray-900">{agent?.name ?? '加载中…'}</p>
          <div className="flex items-center gap-2 flex-wrap">
            <p className="text-xs text-gray-500">
              {(agent?.config?.model as Record<string, unknown>)?.provider as string ?? ''}
              {' · '}
              {(agent?.config?.model as Record<string, unknown>)?.name as string ?? ''}
            </p>
            {/* 工作区对话：当前工作文件夹（点击换文件夹） */}
            {workDir && (
              <button
                onClick={() => setDirPickerOpen(true)}
                className="inline-flex items-center gap-1 text-xs bg-emerald-50 text-emerald-700 border border-emerald-200 hover:bg-emerald-100 px-1.5 py-0.5 rounded-full max-w-[320px] transition-colors"
                title={`工作文件夹（就地读写）：${workDir}\n点击切换文件夹`}
              >
                <Folder size={9} className="shrink-0" />
                <span className="truncate font-mono">{workDir.split('/').slice(-2).join('/')}</span>
                <span className="text-emerald-400 shrink-0">·换</span>
              </button>
            )}
            {/* KB badges */}
            {(agentKBData?.knowledge_bases ?? []).map(kb => (
              <span
                key={kb.id}
                className="inline-flex items-center gap-1 text-xs bg-indigo-50 text-indigo-600 border border-indigo-200 px-1.5 py-0.5 rounded-full"
                title={`知识库: ${kb.name}`}
              >
                <BookMarked size={9} />
                {kb.name}
              </span>
            ))}
          </div>
        </div>
        {/* 会话记忆：新建会话 + 历史会话 */}
        <button
          onClick={startNewThread}
          className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium bg-gray-100 text-gray-600 hover:bg-gray-200 transition-colors"
          title="开始一段新对话（不带前情记忆）"
        >
          <MessageSquarePlus size={13} />
          新会话
        </button>
        <div className="relative">
          <button
            onClick={() => { setHistoryOpen(o => !o); if (!historyOpen) refetchThreads() }}
            className={clsx(
              'flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium transition-colors',
              historyOpen ? 'bg-indigo-100 text-indigo-700' : 'bg-gray-100 text-gray-600 hover:bg-gray-200',
            )}
            title="历史会话"
          >
            <History size={13} />
            历史
          </button>
          {historyOpen && (
            <div className="absolute right-0 top-full mt-1 w-72 max-h-80 overflow-y-auto bg-white border border-gray-200 rounded-xl shadow-lg z-30">
              {(threadsData?.threads ?? []).length === 0 ? (
                <p className="text-xs text-gray-400 px-4 py-3">暂无历史会话</p>
              ) : (
                (threadsData?.threads ?? []).map(t => (
                  <button
                    key={t.id}
                    onClick={() => loadThread(t.id)}
                    className={clsx(
                      'w-full text-left px-4 py-2.5 hover:bg-gray-50 border-b border-gray-50 last:border-0',
                      threadIdRef.current === t.id && 'bg-indigo-50',
                    )}
                  >
                    <p className="text-xs font-medium text-gray-800 truncate">{t.title || '未命名会话'}</p>
                    <p className="text-[10px] text-gray-400 mt-0.5">{t.run_count} 条消息</p>
                  </button>
                ))
              )}
            </div>
          )}
        </div>
        {/* Workspace link */}
        <button
          onClick={() => navigate(`/agents/${agentId}/workspace`)}
          className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium bg-gray-100 text-gray-600 hover:bg-gray-200 transition-colors"
          title="查看运行工作区"
        >
          <LayoutDashboard size={13} />
          工作区
        </button>
        {/* Memory toggle */}
        <button
          onClick={() => setMemoryOpen(o => !o)}
          className={clsx(
            'flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium transition-colors',
            memoryOpen
              ? 'bg-indigo-100 text-indigo-700'
              : 'bg-gray-100 text-gray-600 hover:bg-gray-200',
          )}
          title="查看智能体记忆"
        >
          <Brain size={13} />
          记忆
        </button>
      </div>

      {/* Directory info bar — 专项模式才显示 agent 的来源/产物/日志；
          工作区对话模式(workDir)只认所选文件夹 F，隐藏这条避免目录打架 */}
      {!workDir && (agent?.source_dir || agent?.work_dir || agent?.run_dir) && (
        <DirInfoBar
          sourceDir={agent.source_dir}
          workDir={agent.work_dir}
          runDir={agent.run_dir}
          agentId={agentId!}
          onEdit={() => navigate(`/agents/${agentId}/edit`)}
        />
      )}

      {/* Body — messages + optional memory panel */}
      <div className="flex-1 relative overflow-hidden">
        {/* Messages area */}
        <div className="absolute inset-0 overflow-y-auto px-6 py-6 space-y-5 scrollbar-thin"
          style={{ right: memoryOpen ? '320px' : '0' }}>
          {!historyLoaded && (
            <div className="flex items-center justify-center py-8 gap-2 text-sm text-gray-400">
              <Spinner size={16} />
              正在加载历史记录…
            </div>
          )}

          {historyLoaded && messages.length === 0 && (
            <div className="flex flex-col items-center justify-center h-full gap-6 text-center">
              <div className="w-16 h-16 bg-indigo-100 rounded-full flex items-center justify-center">
                <Bot size={32} className="text-indigo-600" />
              </div>
              <div>
                <p className="text-sm font-medium text-gray-900 mb-1">
                  {agent?.name ?? '智能体'} 已就绪
                </p>
                <p className="text-xs text-gray-500">
                  {isDesktop
                    ? '输入你的研究任务开始，Shift+Enter 换行'
                    : '发送消息开始对话，Shift+Enter 换行'}
                </p>
              </div>
              <div className="flex flex-wrap gap-2 justify-center max-w-lg">
                {EXAMPLES.map(ex => (
                  <button
                    key={ex}
                    onClick={() => setInput(ex)}
                    className="px-3 py-2 text-xs border border-gray-200 rounded-full text-gray-600 hover:border-indigo-400 hover:text-indigo-600 transition-colors"
                  >
                    {ex}
                  </button>
                ))}
              </div>
            </div>
          )}

          {historyLoaded && messages.length > 0 && runsData?.runs?.length ? (
            <div className="flex items-center gap-2 text-xs text-gray-400 pb-2">
              <Clock size={11} />
              已加载最近 {runsData.runs.length} 条历史记录
            </div>
          ) : null}

          {messages.map(msg => (
            <MessageBubble
              key={msg.id}
              msg={msg}
              agentId={agentId!}
              apiKey={apiKey ?? ''}
              agentMaxSteps={agentMaxSteps}
              onContinue={(runId, workspacePath) => {
                continueRunIdRef.current = runId
                continuePathRef.current  = workspacePath
                setContinueFromRunId(runId)
                setContinueFromPath(workspacePath)
                textareaRef.current?.focus()
              }}
            />
          ))}
          <div ref={bottomRef} />
        </div>

        {/* Memory panel — slides in from right */}
        {memoryOpen && agentId && (
          <MemoryPanel agentId={agentId} onClose={() => setMemoryOpen(false)} />
        )}

        {/* 工作区对话：换文件夹 → 换工作目录 + 开新会话 */}
        {dirPickerOpen && (
          <DirPickerModal
            initial={workDir}
            onClose={() => {
              setDirPickerOpen(false)
              // 工作区助手必须选目录:不选就关 → 退回工作台,不留无目录的死会话。
              if (needsWorkDir) navigate('/dashboard')
            }}
            onSelect={(folder) => {
              setDirPickerOpen(false)
              if (!folder || folder === workDir) return
              setWorkDir(folder)
              const u = new URL(window.location.href)
              u.searchParams.set('dir', folder)
              window.history.replaceState({}, '', u.toString())
              startNewThread()  // 换文件夹 = 干净新会话
            }}
          />
        )}
      </div>

      {/* Continue-from-run banner — pinned above input so it's always visible
          right before the user sends. Distinguishes "continue this run"
          from "start a fresh conversation". */}
      {continueFromRunId && !workDir && (() => {
        const prevRun = runsData?.runs?.find(r => r.id === continueFromRunId)
        const prevInputPreview = prevRun?.input?.slice(0, 120) ?? ''
        return (
          <div className="px-6 pt-3 bg-white shrink-0">
            <div className="max-w-4xl mx-auto rounded-xl border border-indigo-300 bg-indigo-50/70 overflow-hidden">
              <div className="flex items-center gap-2 px-4 py-2 bg-indigo-100/60 border-b border-indigo-200">
                <RotateCcw size={13} className="text-indigo-600 shrink-0" />
                <span className="text-xs font-semibold text-indigo-900">
                  继续模式 — 下一条消息会在此 run 的工作区基础上执行
                </span>
                <button
                  onClick={() => {
                    continueRunIdRef.current = null
                    continuePathRef.current = null
                    setContinueFromRunId(null)
                    setContinueFromPath(null)
                  }}
                  className="ml-auto flex items-center gap-1 text-xs text-indigo-600 hover:text-indigo-900 hover:bg-indigo-200/60 px-2 py-0.5 rounded transition-colors"
                  title="取消继续，下一条作为全新对话"
                >
                  <X size={12} />
                  改为新对话
                </button>
              </div>
              <div className="px-4 py-2.5 text-xs text-indigo-900/90 space-y-1.5">
                <div className="flex items-baseline gap-2">
                  <span className="font-medium text-indigo-700/70 shrink-0 w-16">Run ID</span>
                  <code className="font-mono text-indigo-900 bg-white/80 px-1.5 py-0.5 rounded border border-indigo-200">
                    {continueFromRunId.slice(-12)}
                  </code>
                </div>
                {continueFromPath && (
                  <div className="flex items-baseline gap-2">
                    <span className="font-medium text-indigo-700/70 shrink-0 w-16">工作区</span>
                    <code
                      className="font-mono text-indigo-900/90 truncate flex-1"
                      title={continueFromPath}
                    >
                      {continueFromPath}
                    </code>
                    <button
                      onClick={() => navigator.clipboard?.writeText(continueFromPath)}
                      className="text-indigo-500 hover:text-indigo-700 shrink-0 text-xs underline-offset-2 hover:underline"
                      title="复制路径"
                    >
                      复制
                    </button>
                  </div>
                )}
                {prevInputPreview && (
                  <div className="flex items-baseline gap-2">
                    <span className="font-medium text-indigo-700/70 shrink-0 w-16">上一轮</span>
                    <span className="text-indigo-800/80 line-clamp-2 leading-snug">
                      {prevInputPreview}
                      {(prevRun?.input?.length ?? 0) > 120 ? '…' : ''}
                    </span>
                  </div>
                )}

                {/* Mode selector — chat / edit / iterate */}
                <div className="pt-1 border-t border-indigo-200/60 mt-1">
                  <div className="flex items-baseline gap-2">
                    <span className="font-medium text-indigo-700/70 shrink-0 w-16">模式</span>
                    <div className="flex gap-1 flex-wrap">
                      {([
                        { id: 'chat',    icon: '💬', label: 'Chat',    desc: '只读问答，< 2 min' },
                        { id: 'edit',    icon: '✏️', label: 'Edit',    desc: '调一个 sub-agent，~10 min' },
                        { id: 'iterate', icon: '🔄', label: 'Iterate', desc: '新 cycle，~1-2 h' },
                      ] as { id: Mode; icon: string; label: string; desc: string }[]).map(opt => (
                        <button
                          key={opt.id}
                          onClick={() => {
                            modeRef.current = opt.id
                            setMode(opt.id)
                            if (opt.id !== 'edit') {
                              targetSubagentRef.current = ''
                              setTargetSubagent('')
                            }
                          }}
                          className={clsx(
                            'flex items-center gap-1 text-xs px-2 py-0.5 rounded border transition-colors',
                            mode === opt.id
                              ? 'bg-indigo-600 text-white border-indigo-600 shadow-sm'
                              : 'bg-white text-indigo-700 border-indigo-200 hover:border-indigo-400'
                          )}
                          title={opt.desc}
                        >
                          <span>{opt.icon}</span>
                          <span className="font-medium">{opt.label}</span>
                        </button>
                      ))}
                    </div>
                  </div>
                  {/* Edit-mode sub-agent picker */}
                  {mode === 'edit' && (
                    <div className="flex items-baseline gap-2 mt-1.5">
                      <span className="font-medium text-indigo-700/70 shrink-0 w-16">调用</span>
                      <select
                        value={targetSubagent}
                        onChange={e => {
                          targetSubagentRef.current = e.target.value
                          setTargetSubagent(e.target.value)
                        }}
                        className="text-xs bg-white border border-indigo-200 rounded px-2 py-0.5 focus:outline-none focus:border-indigo-500"
                      >
                        <option value="">选择一个 sub-agent…</option>
                        {subAgents.map(sa => (
                          <option key={sa} value={sa}>{sa}</option>
                        ))}
                      </select>
                      {subAgents.length === 0 && (
                        <span className="text-xs text-indigo-500/70">
                          (此 agent 未配置 sub-agents — Edit 模式不可用)
                        </span>
                      )}
                    </div>
                  )}
                </div>
              </div>
            </div>
          </div>
        )
      })()}

      {/* Input bar */}
      <div className={clsx(
        'px-6 py-4 border-t bg-white shrink-0 transition-colors',
        continueFromRunId ? 'border-indigo-200' : 'border-gray-200',
      )}>
        <div className="flex items-end gap-3 max-w-4xl mx-auto">
          <textarea
            ref={textareaRef}
            value={input}
            onChange={e => setInput(e.target.value)}
            onKeyDown={handleKeyDown}
            placeholder={
              needsWorkDir
                ? '请先选择工作文件夹再开始对话…'
                : continueFromRunId
                ? '继续此 run — 输入下一步指令…（Enter 发送）'
                : isDesktop
                  ? '描述你的研究任务…（Enter 发送，Shift+Enter 换行）'
                  : '发消息… （Enter 发送，Shift+Enter 换行）'
            }
            rows={1}
            disabled={streaming || needsWorkDir}
            className={clsx(
              'flex-1 resize-none rounded-xl border px-4 py-3 text-sm focus:outline-none focus:ring-2 disabled:bg-gray-50 max-h-40 leading-relaxed transition-colors',
              continueFromRunId
                ? 'border-indigo-400 focus:ring-indigo-500 bg-indigo-50/30'
                : 'border-gray-300 focus:ring-indigo-500',
            )}
          />
          {streaming ? (
            <button
              onClick={() => abortRef.current?.()}
              className="flex items-center gap-1.5 px-4 py-3 bg-red-100 text-red-600 rounded-xl hover:bg-red-200 transition-colors text-sm font-medium"
            >
              <Square size={14} />
              停止
            </button>
          ) : (
            <button
              onClick={sendMessage}
              disabled={
                !input.trim() ||
                needsWorkDir ||
                // Edit mode requires a chosen sub-agent.
                (!!continueFromRunId && mode === 'edit' && !targetSubagent)
              }
              className={clsx(
                'flex items-center gap-1.5 px-4 py-3 text-white rounded-xl disabled:opacity-40 disabled:cursor-not-allowed transition-colors text-sm font-medium',
                continueFromRunId
                  ? 'bg-indigo-700 hover:bg-indigo-800 ring-2 ring-indigo-300'
                  : 'bg-indigo-600 hover:bg-indigo-700',
              )}
              title={
                continueFromRunId
                  ? (mode === 'edit' && !targetSubagent
                      ? '请先选择要调用的 sub-agent'
                      : `继续 run ${continueFromRunId.slice(-8)} — 模式: ${mode}`)
                  : '发送'
              }
            >
              <Send size={14} />
              {continueFromRunId
                ? (mode === 'chat' ? '💬 问' : mode === 'edit' ? '✏️ 改' : '🔄 继续')
                : '发送'}
            </button>
          )}
        </div>
      </div>
    </div>
  )
}

// ── Directory info bar ────────────────────────────────────────────
// Shown beneath the chat header when the agent has any dir configured.
// Shows only the 3 static directories (source / work / run).
// Per-run workspace paths are shown inside each message bubble instead.

function DirInfoBar({
  sourceDir, workDir, runDir, agentId, onEdit,
}: {
  sourceDir?: string
  workDir?: string
  runDir?: string
  agentId: string
  onEdit: () => void
}) {
  const [open, setOpen] = useState(false)

  const previewItems = [
    sourceDir && { label: '来源', value: sourceDir, color: 'text-blue-600' },
    workDir   && { label: '产物', value: workDir,   color: 'text-amber-600' },
    runDir    && { label: '日志', value: runDir,    color: 'text-emerald-600' },
  ].filter(Boolean) as { label: string; value: string; color: string }[]

  return (
    <div className="border-b border-gray-100 bg-gray-50 shrink-0">
      <button
        onClick={() => setOpen(o => !o)}
        className="w-full flex items-center gap-2 px-6 py-1.5 text-left hover:bg-gray-100 transition-colors"
      >
        <Folder size={11} className="text-gray-400 shrink-0" />
        <span className="flex-1 flex items-center gap-3 text-[11px] text-gray-500 truncate">
          {previewItems.map(d => (
            <span key={d.label} className="flex items-center gap-1">
              <span className="text-gray-400">{d.label}:</span>
              <code className={`font-mono truncate max-w-[180px] ${d.color}`}>{d.value}</code>
            </span>
          ))}
        </span>
        {open
          ? <ChevronDown size={11} className="text-gray-400 shrink-0" />
          : <ChevronRight size={11} className="text-gray-400 shrink-0" />
        }
      </button>
      {open && (
        <div className="px-6 pb-3 pt-1 space-y-1.5">
          {[
            { label: '① 数据来源目录', value: sourceDir, color: 'text-blue-600' },
            { label: '② 工作产物目录', value: workDir,   color: 'text-amber-600' },
            { label: '③ 运行日志目录', value: runDir,    color: 'text-emerald-600' },
          ].map(d => d.value ? (
            <div key={d.label} className="flex items-start gap-2 text-xs">
              <span className="text-gray-500 shrink-0 w-28">{d.label}</span>
              <code className={`font-mono break-all ${d.color}`}>{d.value}</code>
            </div>
          ) : null)}
          <button
            onClick={onEdit}
            className="mt-1 text-[11px] text-indigo-500 hover:text-indigo-700 underline underline-offset-2"
          >
            修改目录配置 →
          </button>
        </div>
      )}
    </div>
  )
}
