'use client';

import { useState, useEffect, useCallback, useRef, lazy, Suspense } from 'react';
import { useSidebarResize } from '@/hooks/useSidebarResize';
import type { TaskLogEntry } from '@/src/types';

const PipelineEditor = lazy(() => import('./PipelineEditor'));
const ConversationEditor = lazy(() => import('./ConversationEditor'));

// ─── Live Task Log Hook ──────────────────────────────────
// Subscribes to SSE stream for a running task, returns live log entries
function useTaskStream(taskId: string | undefined, isRunning: boolean) {
  const [log, setLog] = useState<TaskLogEntry[]>([]);
  const [status, setStatus] = useState<string>('');

  useEffect(() => {
    if (!taskId || !isRunning) { setLog([]); return; }

    const es = new EventSource(`/api/tasks/${taskId}/stream`);
    es.onmessage = (event) => {
      try {
        const data = JSON.parse(event.data);
        if (data.type === 'log') setLog(prev => [...prev, data.entry]);
        else if (data.type === 'status') setStatus(data.status);
        else if (data.type === 'complete' && data.task) setLog(data.task.log);
      } catch {}
    };
    es.onerror = () => es.close();
    return () => es.close();
  }, [taskId, isRunning]);

  return { log, status };
}

// ─── Compact log renderer ─────────────────────────────────
function LiveLog({ log, maxHeight = 200 }: { log: TaskLogEntry[]; maxHeight?: number }) {
  const endRef = useRef<HTMLDivElement>(null);
  useEffect(() => { endRef.current?.scrollIntoView({ behavior: 'smooth' }); }, [log]);

  if (log.length === 0) return <div className="text-[10px] text-[var(--text-secondary)] italic">Starting...</div>;

  return (
    <div className="overflow-y-auto text-[9px] font-mono leading-relaxed space-y-0.5" style={{ maxHeight }}>
      {log.slice(-50).map((entry, i) => (
        <div key={i} className={
          entry.type === 'result' ? 'text-green-400' :
          entry.subtype === 'error' ? 'text-red-400' :
          entry.type === 'system' ? 'text-yellow-400/70' :
          'text-[var(--text-secondary)]'
        }>
          {entry.type === 'assistant' && entry.subtype === 'tool_use'
            ? `⚙ ${entry.tool || 'tool'}: ${entry.content.slice(0, 80)}${entry.content.length > 80 ? '...' : ''}`
            : entry.content.slice(0, 200)}{entry.content.length > 200 ? '...' : ''}
        </div>
      ))}
      <div ref={endRef} />
    </div>
  );
}

interface WorkflowNode {
  id: string;
  project: string;
  prompt: string;
  mode?: 'claude' | 'shell';
  agent?: string;
  branch?: string;
  dependsOn: string[];
  outputs: { name: string; extract: string }[];
  routes: { condition: string; next: string }[];
  maxIterations: number;
}

interface Workflow {
  name: string;
  type?: 'dag' | 'conversation';
  description?: string;
  builtin?: boolean;
  vars: Record<string, string>;
  input: Record<string, string>;
  nodes: Record<string, WorkflowNode>;
  conversation?: {
    agents: { id: string; agent: string; role: string }[];
    maxRounds: number;
    stopCondition?: string;
    initialPrompt: string;
  };
}

interface PipelineNodeState {
  status: 'pending' | 'running' | 'done' | 'failed' | 'skipped';
  taskId?: string;
  outputs: Record<string, string>;
  iterations: number;
  startedAt?: string;
  completedAt?: string;
  error?: string;
}

interface ConversationMessage {
  round: number;
  agentId: string;
  agentName: string;
  content: string;
  timestamp: string;
  taskId?: string;
  status: 'pending' | 'running' | 'done' | 'failed';
}

interface Pipeline {
  id: string;
  workflowName: string;
  type?: 'dag' | 'conversation';
  status: 'running' | 'done' | 'failed' | 'cancelled';
  input: Record<string, string>;
  vars: Record<string, string>;
  nodes: Record<string, PipelineNodeState>;
  nodeOrder: string[];
  createdAt: string;
  completedAt?: string;
  conversation?: {
    config: {
      agents: { id: string; agent: string; role: string; project?: string }[];
      maxRounds: number;
      stopCondition?: string;
      initialPrompt: string;
    };
    messages: ConversationMessage[];
    currentRound: number;
    currentAgentIndex: number;
  };
}

const STATUS_ICON: Record<string, string> = {
  pending: '⏳',
  running: '🔄',
  done: '✅',
  failed: '❌',
  skipped: '⏭',
};

const STATUS_COLOR: Record<string, string> = {
  pending: 'text-gray-400',
  running: 'text-yellow-400',
  done: 'text-green-400',
  failed: 'text-red-400',
  skipped: 'text-gray-500',
};

// ─── DAG Node Card with live logs ─────────────────────────

function DagNodeCard({ nodeId, node, nodeDef, onViewTask, onRetry }: {
  nodeId: string;
  node: PipelineNodeState;
  nodeDef?: WorkflowNode;
  onViewTask?: (taskId: string) => void;
  onRetry?: (nodeId: string) => void;
}) {
  const isRunning = node.status === 'running';
  const { log } = useTaskStream(node.taskId, isRunning);
  const [retryBusy, setRetryBusy] = useState(false);

  return (
    <div className={`border rounded-lg p-3 ${
      isRunning ? 'border-yellow-500/50 bg-yellow-500/5' :
      node.status === 'done' ? 'border-green-500/30 bg-green-500/5' :
      node.status === 'failed' ? 'border-red-500/30 bg-red-500/5' :
      'border-[var(--border)]'
    }`}>
      <div className="flex items-center gap-2">
        <span className={STATUS_COLOR[node.status]}>{STATUS_ICON[node.status]}</span>
        <span className="text-xs font-semibold text-[var(--text-primary)]">{nodeId}</span>
        {nodeDef && nodeDef.mode !== 'shell' && (
          <span className="text-[8px] px-1 rounded bg-purple-500/20 text-purple-400">{nodeDef.agent || 'default'}</span>
        )}
        {node.taskId && (
          <button onClick={() => onViewTask?.(node.taskId!)} className="text-[9px] text-[var(--accent)] font-mono hover:underline">
            task:{node.taskId}
          </button>
        )}
        {node.iterations > 1 && <span className="text-[9px] text-yellow-400">iter {node.iterations}</span>}
        {node.status === 'failed' && onRetry && (
          <button
            onClick={async () => { setRetryBusy(true); try { await onRetry(nodeId); } finally { setRetryBusy(false); } }}
            disabled={retryBusy}
            className="text-[9px] px-1.5 py-0.5 rounded bg-yellow-500/20 hover:bg-yellow-500/30 text-yellow-300 disabled:opacity-50"
            title="Reset this node + all downstream to pending and re-run. Useful when an upstream node like fix-code already produced a valid worktree commit and only the later step (push-and-mr / notify-teams) needs to be re-tried."
          >
            {retryBusy ? 'retrying…' : '↻ retry'}
          </button>
        )}
        <span className="text-[9px] text-[var(--text-secondary)] ml-auto">{node.status}</span>
      </div>

      {/* Live log for running nodes */}
      {isRunning && (
        <div className="mt-2 p-2 bg-[var(--bg-tertiary)] rounded">
          <LiveLog log={log} maxHeight={160} />
        </div>
      )}

      {node.error && <div className="text-[10px] text-red-400 mt-1">{node.error}</div>}

      {/* Outputs */}
      {Object.keys(node.outputs).length > 0 && (
        <div className="mt-2 space-y-1">
          {Object.entries(node.outputs).map(([key, val]) => (
            <details key={key} className="text-[10px]">
              <summary className="cursor-pointer text-[var(--accent)]">output: {key} ({val.length} chars)</summary>
              <pre className="mt-1 p-2 bg-[var(--bg-tertiary)] rounded text-[9px] text-[var(--text-secondary)] max-h-32 overflow-auto whitespace-pre-wrap">
                {val.slice(0, 1000)}{val.length > 1000 ? '...' : ''}
              </pre>
            </details>
          ))}
        </div>
      )}

      {node.startedAt && (
        <div className="text-[8px] text-[var(--text-secondary)] mt-1">
          {`Started: ${new Date(node.startedAt).toLocaleTimeString()}`}
          {node.completedAt && ` · Done: ${new Date(node.completedAt).toLocaleTimeString()}`}
        </div>
      )}
    </div>
  );
}

// ─── Agent color palette for conversation bubbles ────────
const AGENT_COLORS = [
  { bg: 'bg-blue-500/10', border: 'border-blue-500/30', badge: 'bg-blue-500/20 text-blue-400', dot: 'text-blue-400' },
  { bg: 'bg-purple-500/10', border: 'border-purple-500/30', badge: 'bg-purple-500/20 text-purple-400', dot: 'text-purple-400' },
  { bg: 'bg-green-500/10', border: 'border-green-500/30', badge: 'bg-green-500/20 text-green-400', dot: 'text-green-400' },
  { bg: 'bg-orange-500/10', border: 'border-orange-500/30', badge: 'bg-orange-500/20 text-orange-400', dot: 'text-orange-400' },
  { bg: 'bg-pink-500/10', border: 'border-pink-500/30', badge: 'bg-pink-500/20 text-pink-400', dot: 'text-pink-400' },
];

function ConversationMessageBubble({ msg, colors, agentDef, isLeft, onViewTask }: {
  msg: ConversationMessage; colors: typeof AGENT_COLORS[0]; agentDef?: { id: string; role: string };
  isLeft: boolean; onViewTask?: (taskId: string) => void;
}) {
  const isRunning = msg.status === 'running';
  const { log } = useTaskStream(msg.taskId, isRunning);

  return (
    <div className={`flex ${isLeft ? 'justify-start' : 'justify-end'}`}>
      <div className={`max-w-[85%] border rounded-lg p-3 ${colors.bg} ${colors.border}`}>
        {/* Agent header */}
        <div className="flex items-center gap-2 mb-1.5">
          <span className={`text-[9px] px-1.5 py-0.5 rounded font-medium ${colors.badge}`}>{msg.agentName}</span>
          <span className="text-[8px] text-[var(--text-secondary)]">
            {msg.agentId}{agentDef?.role ? ` — ${agentDef.role.slice(0, 40)}${agentDef.role.length > 40 ? '...' : ''}` : ''}
          </span>
          {isRunning && <span className="text-[8px] text-yellow-400 animate-pulse">● running</span>}
          <span className="text-[8px] text-[var(--text-secondary)] ml-auto">R{msg.round}</span>
        </div>

        {/* Content */}
        {isRunning ? (
          <LiveLog log={log} maxHeight={250} />
        ) : msg.status === 'failed' ? (
          <div className="text-[10px] text-red-400">{msg.content || 'Failed'}</div>
        ) : (
          <div className="text-[10px] text-[var(--text-primary)] whitespace-pre-wrap leading-relaxed">
            {msg.content.slice(0, 3000)}{msg.content.length > 3000 ? '\n\n[... truncated]' : ''}
          </div>
        )}

        {/* Footer */}
        <div className="flex items-center gap-2 mt-1.5">
          {msg.taskId && (
            <button onClick={() => onViewTask?.(msg.taskId!)} className="text-[8px] text-[var(--accent)] font-mono hover:underline">
              task:{msg.taskId}
            </button>
          )}
          <span className="text-[7px] text-[var(--text-secondary)] ml-auto">{new Date(msg.timestamp).toLocaleTimeString()}</span>
        </div>
      </div>
    </div>
  );
}

const ConversationGraphView = lazy(() => import('./ConversationGraphView'));
const ConversationTerminalView = lazy(() => import('./ConversationTerminalView'));

function ConversationView({ pipeline, onViewTask }: { pipeline: Pipeline; onViewTask?: (taskId: string) => void }) {
  const conv = pipeline.conversation!;
  const { config, messages, currentRound } = conv;
  const [injectText, setInjectText] = useState('');
  const [injectTarget, setInjectTarget] = useState(config.agents[0]?.id || '');
  const [injecting, setInjecting] = useState(false);
  const [viewMode, setViewMode] = useState<'terminal' | 'graph' | 'chat'>('terminal');
  const scrollRef = useRef<HTMLDivElement>(null);

  // Assign stable colors per agent
  const agentColorMap: Record<string, typeof AGENT_COLORS[0]> = {};
  config.agents.forEach((a, i) => {
    agentColorMap[a.id] = AGENT_COLORS[i % AGENT_COLORS.length];
  });

  // Auto-scroll on new messages
  useEffect(() => {
    scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight, behavior: 'smooth' });
  }, [messages.length]);

  const handleInject = async () => {
    if (!injectText.trim() || injecting) return;
    setInjecting(true);
    try {
      await fetch(`/api/pipelines/${pipeline.id}`, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ action: 'inject', agentId: injectTarget, message: injectText }),
      });
      setInjectText('');
    } catch {}
    setInjecting(false);
  };

  return (
    <div className="flex-1 flex flex-col overflow-hidden">
      {/* Conversation info bar */}
      <div className="px-4 py-2 border-b border-[var(--border)] bg-[var(--bg-tertiary)]/50 shrink-0">
        <div className="flex items-center gap-3">
          <span className="text-[9px] px-1.5 py-0.5 rounded bg-[var(--accent)]/20 text-[var(--accent)] font-medium">Conversation</span>
          <span className="text-[9px] text-[var(--text-secondary)]">Round {currentRound}/{config.maxRounds}</span>
          <div className="flex items-center gap-2 ml-auto">
            {/* View mode toggle */}
            <div className="flex border border-[var(--border)] rounded overflow-hidden">
              {(['terminal', 'graph', 'chat'] as const).map(mode => (
                <button
                  key={mode}
                  onClick={() => setViewMode(mode)}
                  className={`text-[8px] px-2 py-0.5 capitalize ${viewMode === mode ? 'bg-[var(--accent)]/20 text-[var(--accent)]' : 'text-[var(--text-secondary)] hover:text-[var(--text-primary)]'}`}
                >{mode}</button>
              ))}
            </div>
            {config.agents.map(a => {
              const colors = agentColorMap[a.id];
              const isRunning = messages.some(m => m.agentId === a.id && m.status === 'running');
              return (
                <span key={a.id} className={`text-[8px] px-1.5 py-0.5 rounded ${colors.badge} ${isRunning ? 'ring-1 ring-yellow-400/50' : ''}`}>
                  {isRunning ? '● ' : ''}{a.id} ({a.agent})
                </span>
              );
            })}
          </div>
        </div>
        {config.stopCondition && (
          <div className="text-[8px] text-[var(--text-secondary)] mt-1">Stop: {config.stopCondition}</div>
        )}
      </div>

      {/* Terminal / Graph / Chat view */}
      {viewMode === 'terminal' ? (
        <div className="flex-1 min-h-0">
          <Suspense fallback={<div className="flex items-center justify-center h-full text-xs text-[var(--text-secondary)]">Loading...</div>}>
            <ConversationTerminalView pipeline={pipeline} onViewTask={onViewTask} />
          </Suspense>
        </div>
      ) : viewMode === 'graph' ? (
        <div className="flex-1 min-h-0">
          <Suspense fallback={<div className="flex items-center justify-center h-full text-xs text-[var(--text-secondary)]">Loading graph...</div>}>
            <ConversationGraphView pipeline={pipeline} />
          </Suspense>
        </div>
      ) : (
        <>
          {/* Initial prompt */}
          <div className="px-4 pt-3">
            <div className="border border-[var(--border)] rounded-lg p-3 bg-[var(--bg-tertiary)]/50">
              <div className="text-[9px] text-[var(--text-secondary)] font-medium mb-1">Initial Prompt</div>
              <div className="text-[11px] text-[var(--text-primary)] whitespace-pre-wrap">{config.initialPrompt}</div>
            </div>
          </div>

          {/* Messages — chat-like view with live logs */}
          <div ref={scrollRef} className="flex-1 overflow-y-auto px-4 py-3 space-y-3">
            {messages.map((msg, i) => {
              const colors = agentColorMap[msg.agentId] || AGENT_COLORS[0];
              const agentDef = config.agents.find(a => a.id === msg.agentId);
              const isLeft = config.agents.indexOf(agentDef!) % 2 === 0;

              return (
                <ConversationMessageBubble
                  key={`${msg.taskId || i}-${msg.status}`}
                  msg={msg}
                  colors={colors}
                  agentDef={agentDef}
                  isLeft={isLeft}
                  onViewTask={onViewTask}
                />
              );
            })}

            {/* Completion indicator */}
            {pipeline.status === 'done' && (
              <div className="flex justify-center py-2">
                <span className="text-[10px] px-3 py-1 rounded-full bg-green-500/10 text-green-400 border border-green-500/30">
                  Conversation complete — {messages.length} messages in {Math.max(...messages.map(m => m.round), 0)} rounds
                </span>
              </div>
            )}
            {pipeline.status === 'failed' && (
              <div className="flex justify-center py-2">
                <span className="text-[10px] px-3 py-1 rounded-full bg-red-500/10 text-red-400 border border-red-500/30">
                  Conversation failed
                </span>
              </div>
            )}
          </div>
        </>
      )}

      {/* Inject command bar */}
      {pipeline.status === 'running' && (
        <div className="px-4 py-2 border-t border-[var(--border)] bg-[var(--bg-tertiary)]/50 shrink-0">
          <div className="flex items-center gap-2">
            <select
              value={injectTarget}
              onChange={e => setInjectTarget(e.target.value)}
              className="text-[10px] bg-[var(--bg-tertiary)] border border-[var(--border)] rounded px-2 py-1 text-[var(--text-primary)]"
            >
              {config.agents.map(a => (
                <option key={a.id} value={a.id}>@{a.id}</option>
              ))}
            </select>
            <input
              value={injectText}
              onChange={e => setInjectText(e.target.value)}
              onKeyDown={e => e.key === 'Enter' && !e.shiftKey && handleInject()}
              placeholder="Send instruction to agent..."
              className="flex-1 text-[10px] bg-[var(--bg-primary)] border border-[var(--border)] rounded px-2 py-1 text-[var(--text-primary)] focus:outline-none focus:border-[var(--accent)]"
            />
            <button
              onClick={handleInject}
              disabled={!injectText.trim() || injecting}
              className="text-[10px] px-3 py-1 bg-[var(--accent)] text-white rounded hover:opacity-90 disabled:opacity-50"
            >Send</button>
          </div>
        </div>
      )}
    </div>
  );
}

export default function PipelineView({ onViewTask, focusPipelineId, onFocusHandled }: { onViewTask?: (taskId: string) => void; focusPipelineId?: string | null; onFocusHandled?: () => void }) {
  const { sidebarWidth, onSidebarDragStart } = useSidebarResize({ defaultWidth: 256, minWidth: 140, maxWidth: 480 });
  const [pipelines, setPipelines] = useState<Pipeline[]>([]);
  const [workflows, setWorkflows] = useState<Workflow[]>([]);
  const [selectedPipeline, setSelectedPipeline] = useState<Pipeline | null>(null);
  const [activeWorkflow, setActiveWorkflow] = useState<string | null>(null); // selected workflow in left panel
  const [showCreate, setShowCreate] = useState(false);
  const [selectedWorkflow, setSelectedWorkflow] = useState<string>('');
  const [projects, setProjects] = useState<{ name: string; path: string }[]>([]);
  const [inputValues, setInputValues] = useState<Record<string, string>>({});
  const [creating, setCreating] = useState(false);
  const [showEditor, setShowEditor] = useState(false);
  const [editorYaml, setEditorYaml] = useState<string | undefined>(undefined);
  const [editorIsConversation, setEditorIsConversation] = useState(false);
  const [showImport, setShowImport] = useState(false);
  const [importYaml, setImportYaml] = useState('');
  const importFileRef = useRef<HTMLInputElement | null>(null);

  // Marketplace state — pull pipeline templates from the configured
  // workflowRepoUrl (default: aiwatching/forge-workflow). Stays collapsed
  // until the user clicks the header button.
  type MarketRow = {
    kind: 'pipeline'; name: string; display_name: string; description?: string;
    version: string; author?: string; tags?: string[];
    source: 'registry' | 'local'; installed: boolean;
    installed_version?: string; has_update?: boolean;
  };
  const [showMarketplace, setShowMarketplace] = useState(false);
  const [marketRows, setMarketRows] = useState<MarketRow[] | null>(null);
  const [marketBusy, setMarketBusy] = useState(false);
  const [marketErr, setMarketErr] = useState<string>('');

  async function fetchMarketplace() {
    try {
      const res = await fetch('/api/workflows/marketplace');
      const data = await res.json();
      setMarketRows((data.pipelines || []) as MarketRow[]);
    } catch (e) {
      setMarketErr(e instanceof Error ? e.message : String(e));
      setMarketRows([]);
    }
  }

  async function syncMarketplace() {
    setMarketBusy(true);
    setMarketErr('');
    try {
      const res = await fetch('/api/workflows/marketplace', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ action: 'sync' }),
      });
      const data = await res.json();
      if (!data.ok) setMarketErr(data.error || 'sync failed');
      await fetchMarketplace();
    } catch (e) {
      setMarketErr(e instanceof Error ? e.message : String(e));
    } finally {
      setMarketBusy(false);
    }
  }

  async function installFromMarketplace(name: string) {
    setMarketBusy(true);
    setMarketErr('');
    try {
      const res = await fetch('/api/workflows/marketplace', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ action: 'install', kind: 'pipeline', name }),
      });
      const data = await res.json();
      if (!data.ok) { setMarketErr(data.error || 'install failed'); return; }
      await Promise.all([fetchMarketplace(), fetchData()]);
    } catch (e) {
      setMarketErr(e instanceof Error ? e.message : String(e));
    } finally {
      setMarketBusy(false);
    }
  }
  const [agents, setAgents] = useState<{ id: string; name: string; detected?: boolean }[]>([]);

  // "Load older runs" state, per-workflow. The initial /api/pipelines fetch
  // returns the newest 100 across ALL workflows; if a particular workflow
  // had older history beyond that window, the user can expand it and click
  // "Load older runs". `expanded` = the user opted in to show all loaded
  // runs (not just slice 20); `exhausted` = last load returned <50 → no more.
  const [expandedHistory, setExpandedHistory] = useState<Set<string>>(new Set());
  const [exhaustedHistory, setExhaustedHistory] = useState<Set<string>>(new Set());
  const [loadingMoreFor, setLoadingMoreFor] = useState<string | null>(null);

  async function loadOlderRuns(workflowName: string) {
    const existing = pipelines.filter(p => p.workflowName === workflowName);
    let oldest = existing[0]?.createdAt;
    for (const p of existing) if (p.createdAt < oldest) oldest = p.createdAt;
    if (!oldest) return;
    setLoadingMoreFor(workflowName);
    try {
      const res = await fetch(`/api/pipelines?workflow=${encodeURIComponent(workflowName)}&before=${encodeURIComponent(oldest)}&limit=50`);
      const more: Pipeline[] = await res.json();
      if (Array.isArray(more) && more.length > 0) {
        setPipelines(prev => {
          const seen = new Set(prev.map(p => p.id));
          return [...prev, ...more.filter(p => !seen.has(p.id))];
        });
      }
      setExpandedHistory(prev => new Set(prev).add(workflowName));
      if (!Array.isArray(more) || more.length < 50) {
        setExhaustedHistory(prev => new Set(prev).add(workflowName));
      }
    } finally {
      setLoadingMoreFor(null);
    }
  }

  // Lightweight metadata only — no pipeline runs. Runs are lazy-loaded
  // per-workflow on click. Reduces initial page to a single workflow
  // table fetch + projects + agents, even with hundreds of runs on disk.
  const fetchMeta = useCallback(async () => {
    const [wRes, projRes, agentRes] = await Promise.all([
      fetch('/api/pipelines?type=workflows'),
      fetch('/api/projects'),
      fetch('/api/agents'),
    ]);
    const [wData, projData, agentData] = await Promise.all([wRes.json(), projRes.json(), agentRes.json()]);
    if (Array.isArray(wData)) setWorkflows(wData);
    if (Array.isArray(projData)) setProjects(projData.map((p: any) => ({ name: p.name, path: p.path })));
    if (Array.isArray(agentData?.agents)) setAgents(agentData.agents);
  }, []);

  // Fetch the run history for ONE workflow. Used on first expand + on
  // the 5s polling tick (only for the currently active workflow).
  const fetchWorkflowRuns = useCallback(async (workflowName: string, opts: { append?: boolean } = {}) => {
    try {
      const res = await fetch(`/api/pipelines?workflow=${encodeURIComponent(workflowName)}&limit=100`);
      const data: Pipeline[] = await res.json();
      if (!Array.isArray(data)) return;
      setPipelines(prev => {
        if (!opts.append) {
          // Replace this workflow's runs (covers status changes during polling),
          // keep other workflows' loaded runs untouched.
          const others = prev.filter(p => p.workflowName !== workflowName);
          return [...others, ...data];
        }
        const seen = new Set(prev.map(p => p.id));
        return [...prev, ...data.filter(p => !seen.has(p.id))];
      });
    } catch { /* ignore — polling will retry */ }
  }, []);

  // fetchData kept for places that explicitly want "refresh everything"
  // (e.g. after Cancel/Delete actions). Just wraps the two pieces.
  const fetchData = useCallback(async () => {
    await fetchMeta();
    if (activeWorkflow) await fetchWorkflowRuns(activeWorkflow);
  }, [fetchMeta, fetchWorkflowRuns, activeWorkflow]);

  useEffect(() => { void fetchMeta(); }, [fetchMeta]);

  // Polling — refresh ONLY the active workflow (5s while expanded).
  // Stops polling entirely when nothing is expanded — no background
  // network traffic just from having the page open.
  useEffect(() => {
    if (!activeWorkflow) return;
    void fetchWorkflowRuns(activeWorkflow);
    const timer = setInterval(() => { void fetchWorkflowRuns(activeWorkflow); }, 5000);
    return () => clearInterval(timer);
  }, [activeWorkflow, fetchWorkflowRuns]);

  // Focus on a specific pipeline (from external navigation)
  useEffect(() => {
    if (!focusPipelineId) return;
    // External navigation may target a pipeline whose workflow isn't
    // expanded yet (and so its runs aren't in `pipelines`). Look it up
    // directly via /api/pipelines/:id, set as selected, and also expand
    // its workflow rail-side so the user can see context.
    // Always fetch the FULL pipeline — the in-memory cache only holds
    // summary projections (no node.outputs, no conversation.messages),
    // and the detail panel needs the heavy fields.
    let cancelled = false;
    void (async () => {
      try {
        const res = await fetch(`/api/pipelines/${encodeURIComponent(focusPipelineId)}`);
        if (!res.ok) return;
        const data: Pipeline = await res.json();
        if (cancelled || !data?.id) return;
        setSelectedPipeline(data);
        setShowEditor(false);
        if (data.workflowName) setActiveWorkflow(data.workflowName);
        onFocusHandled?.();
      } catch { /* ignore */ }
    })();
    return () => { cancelled = true; };
  }, [focusPipelineId, pipelines, onFocusHandled]);

  // Refresh selected pipeline
  useEffect(() => {
    if (!selectedPipeline || selectedPipeline.status !== 'running') return;
    const timer = setInterval(async () => {
      const res = await fetch(`/api/pipelines/${selectedPipeline.id}`);
      const data = await res.json();
      if (data.id) setSelectedPipeline(data);
    }, 3000);
    return () => clearInterval(timer);
  }, [selectedPipeline?.id, selectedPipeline?.status]);

  const handleCreate = async () => {
    if (!selectedWorkflow) return;
    setCreating(true);
    try {
      const res = await fetch('/api/pipelines', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ workflow: selectedWorkflow, input: inputValues }),
      });
      const data = await res.json();
      if (data.id) {
        setSelectedPipeline(data);
        setShowCreate(false);
        setInputValues({});
        fetchData();
      }
    } catch {}
    setCreating(false);
  };

  const handleCancel = async (id: string) => {
    await fetch(`/api/pipelines/${id}`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ action: 'cancel' }),
    });
    fetchData();
    if (selectedPipeline?.id === id) {
      const res = await fetch(`/api/pipelines/${id}`);
      setSelectedPipeline(await res.json());
    }
  };

  const handleRetryNode = async (pipelineId: string, nodeId: string) => {
    const res = await fetch(`/api/pipelines/${pipelineId}`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ action: 'retry-node', nodeId }),
    });
    if (!res.ok) {
      const j = await res.json().catch(() => ({}));
      alert(`Retry failed: ${j?.error || res.statusText}`);
      return;
    }
    fetchData();
    if (selectedPipeline?.id === pipelineId) {
      const r = await fetch(`/api/pipelines/${pipelineId}`);
      setSelectedPipeline(await r.json());
    }
  };

  const handleDelete = async (id: string) => {
    if (!confirm('Delete this pipeline?')) return;
    await fetch(`/api/pipelines/${id}`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ action: 'delete' }),
    });
    if (selectedPipeline?.id === id) setSelectedPipeline(null);
    fetchData();
  };

  const generateConversationTemplate = () => {
    const detectedAgents = agents.filter(a => a.detected);
    const agentEntries = detectedAgents.length >= 2
      ? detectedAgents.slice(0, 2)
      : [{ id: 'claude', name: 'Claude Code' }, { id: 'claude', name: 'Claude Code' }];

    return `name: my-conversation
type: conversation
description: "Multi-agent collaboration"
input:
  project: "Project name"
  task: "Task description"
agents:
  - id: designer
    agent: ${agentEntries[0].id}
    role: "You are a software architect. Design the solution and review implementations."
  - id: builder
    agent: ${agentEntries[1].id}
    role: "You are a developer. Implement what the designer proposes."
max_rounds: 5
stop_condition: "both agents say DONE"
initial_prompt: "{{input.task}}"
`;
  };

  const currentWorkflow = workflows.find(w => w.name === selectedWorkflow);

  return (
    <div className="flex-1 flex flex-col min-h-0">
      <MissingToolsBanner />
      <div className="flex-1 flex min-h-0">
      {/* Left — Workflow list */}
      <aside style={{ width: sidebarWidth }} className="flex flex-col shrink-0 overflow-hidden">
        <div className="px-3 py-2 border-b border-[var(--border)] flex items-center gap-1.5">
          <span className="text-[11px] font-semibold text-[var(--text-primary)] flex-1">Workflows</span>
          <button
            onClick={() => setShowImport(v => !v)}
            className="text-[9px] text-green-400 hover:underline"
          >Import</button>
          <button
            onClick={() => { setEditorYaml(undefined); setEditorIsConversation(false); setShowEditor(true); }}
            className="text-[9px] text-[var(--accent)] hover:underline"
          >+ DAG</button>
          <button
            onClick={() => { setImportYaml(generateConversationTemplate()); setShowImport(true); }}
            className="text-[9px] text-purple-400 hover:underline"
          >+ Conversation</button>
          <button
            onClick={() => {
              const next = !showMarketplace;
              setShowMarketplace(next);
              if (next && marketRows == null) void fetchMarketplace();
            }}
            className="text-[9px] text-blue-400 hover:underline"
            title="Import a workflow from the marketplace as a new local copy"
          >+ From marketplace</button>
        </div>

        {/* Marketplace import — each pipeline in the registry can be
            cloned to as many local copies as you want, each with a
            unique name. The local copy is fully independent of the
            upstream — edits don't propagate either way. */}
        {showMarketplace && (
          <div className="p-3 border-b border-[var(--border)] space-y-2">
            <div className="flex items-center gap-2 mb-1">
              <span className="text-[10px] font-semibold text-[var(--text-secondary)] flex-1">
                Pipelines from forge-workflow
              </span>
              <button
                onClick={() => void syncMarketplace()}
                disabled={marketBusy}
                className="text-[9px] px-2 py-0.5 border border-[var(--border)] text-[var(--text-secondary)] rounded hover:text-[var(--text-primary)] disabled:opacity-50"
              >{marketBusy ? '…' : 'Sync'}</button>
            </div>
            {marketRows == null ? (
              <div className="text-[10px] text-[var(--text-secondary)]">Loading…</div>
            ) : marketRows.length === 0 ? (
              <div className="text-[10px] text-[var(--text-secondary)]">
                Nothing yet — click <b>Sync</b> to pull the registry.
              </div>
            ) : (
              <div className="space-y-1.5 max-h-80 overflow-y-auto">
                {marketRows.map((r) => (
                  <div key={r.name} className="border border-[var(--border)]/60 rounded p-2 bg-[var(--bg-tertiary)]/40">
                    <div className="flex items-baseline gap-1.5">
                      <span className="text-[10px] font-semibold text-[var(--text-primary)] truncate flex-1">{r.display_name}</span>
                      <span className="text-[8px] text-[var(--text-secondary)] font-mono">v{r.version}</span>
                      {r.installed && (
                        <span className="text-[8px] text-[var(--green)]">installed</span>
                      )}
                      {r.installed && (
                        <button
                          onClick={async () => {
                            if (!confirm(`Reinstall "${r.name}" from registry (v${r.version})?\n\nThis OVERWRITES your local copy at ~/.forge/data/flows/${r.name}.yaml — any local edits will be lost.`)) return;
                            setMarketBusy(true);
                            setMarketErr('');
                            try {
                              const res = await fetch('/api/workflows/marketplace', {
                                method: 'POST',
                                headers: { 'Content-Type': 'application/json' },
                                body: JSON.stringify({ action: 'install', kind: 'pipeline', name: r.name, target_name: r.name, overwrite: true }),
                              });
                              const data = await res.json();
                              if (!data.ok) { setMarketErr(data.error || 'reinstall failed'); return; }
                              await Promise.all([fetchMarketplace(), fetchData()]);
                              alert(`"${r.name}" reinstalled from registry (v${r.version}).`);
                            } catch (e) {
                              setMarketErr(e instanceof Error ? e.message : String(e));
                            } finally { setMarketBusy(false); }
                          }}
                          disabled={marketBusy}
                          className="text-[8px] px-2 py-0.5 border border-[var(--yellow)] text-[var(--yellow)] rounded hover:bg-[var(--yellow)] hover:text-black disabled:opacity-50"
                          title="Overwrite the local copy with the latest from registry"
                        >Reinstall</button>
                      )}
                      <button
                        onClick={async () => {
                          const installed = r.installed;
                          const defaultName = installed ? `${r.name}-2` : r.name;
                          const localName = window.prompt(
                            installed
                              ? `"${r.name}" is already installed locally. Pick a NEW name for an additional copy (or click Reinstall to overwrite):`
                              : `Create a local copy of "${r.name}". Pick a name for your copy:`,
                            defaultName,
                          );
                          if (!localName) return;
                          const trimmed = localName.trim();
                          if (!/^[a-z0-9][a-z0-9_-]*$/.test(trimmed)) {
                            alert('Name must be lowercase alphanumerics + hyphens/underscores');
                            return;
                          }
                          setMarketBusy(true);
                          setMarketErr('');
                          try {
                            const res = await fetch('/api/workflows/marketplace', {
                              method: 'POST',
                              headers: { 'Content-Type': 'application/json' },
                              body: JSON.stringify({ action: 'install', kind: 'pipeline', name: r.name, target_name: trimmed }),
                            });
                            const data = await res.json();
                            if (!data.ok) { setMarketErr(data.error || 'import failed'); return; }
                            await Promise.all([fetchMarketplace(), fetchData()]);
                            alert(`Imported as "${data.installed_as}". Open it from the Workflows list.`);
                          } catch (e) {
                            setMarketErr(e instanceof Error ? e.message : String(e));
                          } finally { setMarketBusy(false); }
                        }}
                        disabled={marketBusy}
                        className="text-[8px] px-2 py-0.5 border border-[var(--accent)] text-[var(--accent)] rounded hover:bg-[var(--accent)] hover:text-white disabled:opacity-50"
                      >{r.installed ? 'Import as new copy…' : 'Import as…'}</button>
                    </div>
                    {r.description && (
                      <p className="text-[9px] text-[var(--text-secondary)] mt-1 line-clamp-3">{r.description}</p>
                    )}
                    {r.tags && r.tags.length > 0 && (
                      <div className="text-[8px] text-[var(--text-secondary)] mt-1">
                        {r.tags.map((t) => `#${t}`).join(' ')}
                      </div>
                    )}
                  </div>
                ))}
              </div>
            )}
            {marketErr && (
              <div className="text-[10px] text-[var(--red)]">{marketErr}</div>
            )}
          </div>
        )}

        {/* Import form */}
        {showImport && (
          <div className="p-3 border-b border-[var(--border)] space-y-2">
            <input
              ref={importFileRef}
              type="file"
              accept=".yaml,.yml"
              className="hidden"
              onChange={(e) => {
                const f = e.target.files?.[0];
                e.target.value = '';
                if (!f) return;
                f.text().then(setImportYaml).catch(() => alert('Failed to read file'));
              }}
            />
            <textarea
              value={importYaml}
              onChange={e => setImportYaml(e.target.value)}
              placeholder="Paste YAML workflow here, or pick a .yaml file →"
              className="w-full h-40 text-xs font-mono bg-[var(--bg-tertiary)] border border-[var(--border)] rounded p-2 text-[var(--text-primary)] resize-none focus:outline-none focus:border-[var(--accent)]"
              spellCheck={false}
            />
            <div className="flex gap-2">
              <button
                onClick={() => importFileRef.current?.click()}
                className="text-[10px] px-3 py-1 border border-[var(--border)] text-[var(--text-secondary)] rounded hover:text-[var(--text-primary)]"
              >Pick file…</button>
              <button
                onClick={async () => {
                  if (!importYaml.trim()) return;
                  try {
                    const res = await fetch('/api/pipelines', {
                      method: 'POST',
                      headers: { 'Content-Type': 'application/json' },
                      body: JSON.stringify({ action: 'save-workflow', yaml: importYaml }),
                    });
                    const data = await res.json();
                    if (data.ok) {
                      setShowImport(false);
                      setImportYaml('');
                      fetchData();
                      alert(`Workflow "${data.name}" imported successfully`);
                    } else {
                      alert(`Import failed: ${data.error}`);
                    }
                  } catch { alert('Import failed'); }
                }}
                disabled={!importYaml.trim()}
                className="text-[10px] px-3 py-1 bg-[var(--accent)] text-white rounded hover:opacity-90 disabled:opacity-50"
              >Save Workflow</button>
              <button
                onClick={() => { setShowImport(false); setImportYaml(''); }}
                className="text-[10px] text-[var(--text-secondary)] hover:text-[var(--text-primary)]"
              >Cancel</button>
            </div>
          </div>
        )}

        {/* Create form */}
        {showCreate && (
          <div className="p-3 border-b border-[var(--border)] space-y-2">
            <select
              value={selectedWorkflow}
              onChange={e => { setSelectedWorkflow(e.target.value); setInputValues({}); }}
              className="w-full text-xs bg-[var(--bg-tertiary)] border border-[var(--border)] rounded px-2 py-1.5 text-[var(--text-primary)]"
            >
              <option value="">Select workflow...</option>
              {workflows.map(w => (
                <option key={w.name} value={w.name}>{w.builtin ? '[Built-in] ' : ''}{w.name}{w.description ? ` — ${w.description}` : ''}</option>
              ))}
            </select>

            {/* Input fields — project fields get a dropdown */}
            {currentWorkflow && Object.keys(currentWorkflow.input).length > 0 && (
              <div className="space-y-1.5">
                {Object.entries(currentWorkflow.input).map(([key, desc]) => (
                  <div key={key}>
                    <label className="text-[9px] text-[var(--text-secondary)]">{key}: {desc}</label>
                    {key.toLowerCase() === 'project' ? (
                      <select
                        value={inputValues[key] || ''}
                        onChange={e => setInputValues(prev => ({ ...prev, [key]: e.target.value }))}
                        className="w-full text-xs bg-[var(--bg-tertiary)] border border-[var(--border)] rounded px-2 py-1.5 text-[var(--text-primary)]"
                      >
                        <option value="">Select project...</option>
                        {projects.map(p => <option key={p.path} value={p.name}>{p.name}</option>)}
                      </select>
                    ) : (
                      <input
                        value={inputValues[key] || ''}
                        onChange={e => setInputValues(prev => ({ ...prev, [key]: e.target.value }))}
                        className="w-full text-xs bg-[var(--bg-tertiary)] border border-[var(--border)] rounded px-2 py-1 text-[var(--text-primary)] focus:outline-none focus:border-[var(--accent)]"
                      />
                    )}
                  </div>
                ))}
              </div>
            )}

            {/* Workflow preview */}
            {currentWorkflow && (
              <div className="text-[9px] text-[var(--text-secondary)] space-y-0.5">
                {Object.entries(currentWorkflow.nodes).map(([id, node]) => (
                  <div key={id} className="flex items-center gap-1">
                    <span className="text-[var(--accent)]">{id}</span>
                    {node.dependsOn.length > 0 && <span>← {node.dependsOn.join(', ')}</span>}
                    <span className="text-[var(--text-secondary)] truncate ml-auto">{node.project}</span>
                  </div>
                ))}
              </div>
            )}

            <button
              onClick={handleCreate}
              disabled={!selectedWorkflow || creating}
              className="w-full text-[10px] px-2 py-1.5 bg-[var(--accent)] text-white rounded hover:opacity-90 disabled:opacity-50"
            >
              {creating ? 'Starting...' : 'Start Pipeline'}
            </button>

            {workflows.length === 0 && (
              <p className="text-[9px] text-[var(--text-secondary)]">
                No workflows found. Create YAML files in ~/.forge/flows/
              </p>
            )}
          </div>
        )}

        {/* Workflow list + execution history */}
        <div className="flex-1 overflow-y-auto">
          {workflows.map(w => {
            const isActive = activeWorkflow === w.name;
            const runs = pipelines.filter(p => p.workflowName === w.name);
            return (
              <div key={w.name}>
                <div
                  onClick={() => { setActiveWorkflow(isActive ? null : w.name); setSelectedPipeline(null); }}
                  className={`w-full text-left px-3 py-2 border-b border-[var(--border)]/30 flex items-center gap-2 cursor-pointer ${
                    isActive ? 'bg-[var(--accent)]/10 border-l-2 border-l-[var(--accent)]' : 'hover:bg-[var(--bg-tertiary)] border-l-2 border-l-transparent'
                  }`}
                >
                  <span className="text-[8px] text-[var(--text-secondary)]">{isActive ? '▾' : '▸'}</span>
                  {w.builtin && <span className="text-[7px] text-[var(--text-secondary)]">⚙</span>}
                  <span className="text-[11px] text-[var(--text-primary)] truncate flex-1">{w.name}</span>
                  {runs.length > 0 && <span className="text-[8px] text-[var(--text-secondary)]">{runs.length}</span>}
                  <button
                    onClick={async (e) => {
                      e.stopPropagation();
                      setSelectedWorkflow(w.name);
                      setInputValues({});
                      setShowCreate(true);
                      setActiveWorkflow(w.name);
                    }}
                    className="text-[8px] text-[var(--accent)] hover:underline shrink-0"
                    title="Run this workflow"
                  >Run</button>
                  <button
                    onClick={async (e) => {
                      e.stopPropagation();
                      try {
                        const res = await fetch(`/api/pipelines?type=workflow-yaml&name=${encodeURIComponent(w.name)}`);
                        const data = await res.json();
                        setEditorYaml(data.yaml || undefined);
                        setEditorIsConversation(w.type === 'conversation' || (data.yaml || '').includes('type: conversation'));
                      } catch { setEditorYaml(undefined); setEditorIsConversation(false); }
                      setShowEditor(true);
                    }}
                    className="text-[8px] text-green-400 hover:underline shrink-0"
                    title={w.builtin ? 'View YAML' : 'Edit'}
                  >{w.builtin ? 'View' : 'Edit'}</button>
                  <button
                    onClick={async (e) => {
                      e.stopPropagation();
                      try {
                        const res = await fetch(`/api/pipelines?type=workflow-yaml&name=${encodeURIComponent(w.name)}`);
                        const data = await res.json();
                        if (!data.yaml) { alert('Failed to load yaml'); return; }
                        // Trigger a file download — share = send this .yaml file.
                        const blob = new Blob([data.yaml], { type: 'application/x-yaml' });
                        const url = URL.createObjectURL(blob);
                        const a = document.createElement('a');
                        a.href = url; a.download = `${w.name}.yaml`; a.click();
                        URL.revokeObjectURL(url);
                      } catch { alert('Export failed'); }
                    }}
                    className="text-[8px] text-blue-400 hover:underline shrink-0"
                    title="Download YAML to share"
                  >Export</button>
                  {!w.builtin && (
                    <button
                      onClick={async (e) => {
                        e.stopPropagation();
                        if (!confirm(`Delete workflow "${w.name}"? This removes the .yaml file from disk.`)) return;
                        try {
                          const res = await fetch('/api/pipelines', {
                            method: 'POST',
                            headers: { 'Content-Type': 'application/json' },
                            body: JSON.stringify({ action: 'delete-workflow', name: w.name }),
                          });
                          const data = await res.json();
                          if (!res.ok || data.error) { alert(`Delete failed: ${data.error || res.status}`); return; }
                          if (activeWorkflow === w.name) setActiveWorkflow(null);
                          fetchData();
                        } catch { alert('Delete failed'); }
                      }}
                      className="text-[8px] text-[var(--red)] hover:underline shrink-0"
                      title="Delete this user workflow"
                    >Del</button>
                  )}
                </div>
                {/* Execution history for this workflow */}
                {isActive && (() => {
                  const sorted = runs.slice().sort((a, b) => b.createdAt.localeCompare(a.createdAt));
                  // After clicking "Load older runs", show all loaded for this
                  // workflow. Otherwise slice to 20 to keep the rail compact.
                  const expanded = expandedHistory.has(w.name);
                  const visible = expanded ? sorted : sorted.slice(0, 20);
                  const showLoadMore = !exhaustedHistory.has(w.name) && (expanded || sorted.length >= 20);
                  return (
                    <div className="bg-[var(--bg-tertiary)]/50">
                      {sorted.length === 0 ? (
                        <div className="px-4 py-2 text-[9px] text-[var(--text-secondary)]">No runs yet</div>
                      ) : (
                        visible.map(p => (
                          <button
                            key={p.id}
                            onClick={async () => {
                              // The list payload is a LIGHT summary — no
                              // node.outputs, no conversation.messages.
                              // The detail panel walks those (e.g.
                              // Object.entries(p.nodes[id].outputs)) and
                              // crashes with "Cannot convert undefined to
                              // object" when handed a summary. Fetch the
                              // full pipeline before selecting.
                              try {
                                const res = await fetch(`/api/pipelines/${encodeURIComponent(p.id)}`);
                                if (res.ok) {
                                  const full = await res.json();
                                  setSelectedPipeline(full);
                                  return;
                                }
                              } catch { /* fall back to summary */ }
                              setSelectedPipeline(p);
                            }}
                            className={`w-full text-left px-4 py-1.5 border-b border-[var(--border)]/20 hover:bg-[var(--bg-tertiary)] ${
                              selectedPipeline?.id === p.id ? 'bg-[var(--accent)]/5' : ''
                            }`}
                          >
                            <div className="flex items-center gap-1.5">
                              <span className={`text-[9px] ${STATUS_COLOR[p.status]}`}>●</span>
                              <span className="text-[9px] text-[var(--text-secondary)] font-mono">{p.id.slice(0, 8)}</span>
                              {p.type === 'conversation' ? (
                                <span className="text-[7px] px-1 rounded bg-[var(--accent)]/15 text-[var(--accent)]">
                                  R{p.conversation?.currentRound || 0}/{p.conversation?.config.maxRounds || '?'}
                                </span>
                              ) : (
                                <div className="flex gap-0.5 ml-1">
                                  {p.nodeOrder.map(nodeId => (
                                    <span key={nodeId} className={`text-[8px] ${STATUS_COLOR[p.nodes[nodeId]?.status || 'pending']}`}>
                                      {STATUS_ICON[p.nodes[nodeId]?.status || 'pending']}
                                    </span>
                                  ))}
                                </div>
                              )}
                              <span className="text-[8px] text-[var(--text-secondary)] ml-auto">
                                {new Date(p.createdAt).toLocaleString([], { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' })}
                              </span>
                            </div>
                          </button>
                        ))
                      )}
                      {showLoadMore && (
                        <button
                          onClick={() => void loadOlderRuns(w.name)}
                          disabled={loadingMoreFor === w.name}
                          className="w-full text-center px-4 py-1.5 text-[9px] text-[var(--accent)] hover:bg-[var(--bg-tertiary)] disabled:opacity-50 border-b border-[var(--border)]/20"
                        >
                          {loadingMoreFor === w.name
                            ? 'Loading…'
                            : expanded
                              ? `↓ Load more (currently showing ${sorted.length})`
                              : `↓ Load older runs (more than 20)`}
                        </button>
                      )}
                      {exhaustedHistory.has(w.name) && expanded && (
                        <div className="px-4 py-1.5 text-[8px] text-center text-[var(--text-secondary)]">— end of history ({sorted.length} runs) —</div>
                      )}
                    </div>
                  );
                })()}
              </div>
            );
          })}
          {workflows.length === 0 && (
            <div className="p-4 text-center text-xs text-[var(--text-secondary)]">
              No workflows. Click Import or + New to create one.
            </div>
          )}
        </div>
      </aside>

      {/* Sidebar resize handle */}
      <div
        onMouseDown={onSidebarDragStart}
        className="w-1 bg-[var(--border)] cursor-col-resize shrink-0 hover:bg-[var(--accent)]/50 transition-colors"
      />

      {/* Right — Pipeline detail / Editor */}
      <main className="flex-1 flex flex-col min-w-0 overflow-hidden">
        {showEditor ? (
          editorIsConversation ? (
            <Suspense fallback={<div className="flex-1 flex items-center justify-center text-xs text-[var(--text-secondary)]">Loading editor...</div>}>
              <ConversationEditor
                initialYaml={editorYaml || ''}
                onSave={async (yaml) => {
                  await fetch('/api/pipelines', {
                    method: 'POST',
                    headers: { 'Content-Type': 'application/json' },
                    body: JSON.stringify({ action: 'save-workflow', yaml }),
                  });
                  setShowEditor(false);
                  fetchData();
                }}
                onClose={() => setShowEditor(false)}
              />
            </Suspense>
          ) : (
          <Suspense fallback={<div className="flex-1 flex items-center justify-center text-xs text-[var(--text-secondary)]">Loading editor...</div>}>
            <PipelineEditor
              initialYaml={editorYaml}
              onSave={async (yaml) => {
                await fetch('/api/pipelines', {
                  method: 'POST',
                  headers: { 'Content-Type': 'application/json' },
                  body: JSON.stringify({ action: 'save-workflow', yaml }),
                });
                setShowEditor(false);
                fetchData();
              }}
              onClose={() => setShowEditor(false)}
            />
          </Suspense>
          )
        ) : selectedPipeline ? (
          <>
            {/* Header */}
            <div className="px-4 py-3 border-b border-[var(--border)] shrink-0">
              <div className="flex items-center gap-2">
                <span className={`text-sm ${STATUS_COLOR[selectedPipeline.status]}`}>
                  {STATUS_ICON[selectedPipeline.status]}
                </span>
                <span className="text-sm font-semibold text-[var(--text-primary)]">{selectedPipeline.workflowName}</span>
                {selectedPipeline.type === 'conversation' && (
                  <span className="text-[8px] px-1.5 py-0.5 rounded bg-[var(--accent)]/20 text-[var(--accent)]">conversation</span>
                )}
                <span className="text-[10px] text-[var(--text-secondary)] font-mono">{selectedPipeline.id}</span>
                <div className="flex items-center gap-2 ml-auto">
                  {selectedPipeline.status === 'running' && (
                    <button
                      onClick={() => handleCancel(selectedPipeline.id)}
                      className="text-[10px] px-2 py-0.5 text-red-400 border border-red-400/30 rounded hover:bg-red-400 hover:text-white"
                    >
                      Cancel
                    </button>
                  )}
                  <button
                    onClick={() => handleDelete(selectedPipeline.id)}
                    className="text-[10px] px-2 py-0.5 text-[var(--text-secondary)] hover:text-red-400"
                  >
                    Delete
                  </button>
                </div>
              </div>
              <div className="text-[9px] text-[var(--text-secondary)] mt-1">
                Started: {new Date(selectedPipeline.createdAt).toLocaleString()}
                {selectedPipeline.completedAt && ` · Completed: ${new Date(selectedPipeline.completedAt).toLocaleString()}`}
              </div>
              {Object.keys(selectedPipeline.input).length > 0 && (() => {
                const inputStr = Object.entries(selectedPipeline.input).map(([k, v]) => `${k}="${v}"`).join(', ');
                return (
                  <div
                    className="text-[9px] text-[var(--text-secondary)] mt-1 max-h-20 overflow-y-auto whitespace-pre-wrap break-words"
                    title={inputStr}
                  >Input: {inputStr}</div>
                );
              })()}
            </div>

            {/* Conversation or DAG visualization */}
            {selectedPipeline.type === 'conversation' && selectedPipeline.conversation ? (
              <ConversationView
                pipeline={selectedPipeline}
                onViewTask={onViewTask}
              />
            ) : (
            <div className="p-4 space-y-2 overflow-y-auto">
              {selectedPipeline.nodeOrder.map((nodeId, idx) => {
                const node = selectedPipeline.nodes[nodeId];
                const wf = workflows.find(w => w.name === selectedPipeline.workflowName);
                const nodeDef = wf?.nodes?.[nodeId];
                return (
                  <div key={nodeId}>
                    {idx > 0 && (
                      <div className="flex items-center pl-5 py-1">
                        <div className="w-px h-4 bg-[var(--border)]" />
                      </div>
                    )}
                    <DagNodeCard
                      nodeId={nodeId}
                      node={node}
                      nodeDef={nodeDef}
                      onViewTask={onViewTask}
                      onRetry={(nid) => handleRetryNode(selectedPipeline.id, nid)}
                    />
                  </div>
                );
              })}
            </div>
            )}
          </>
        ) : activeWorkflow ? (() => {
          const w = workflows.find(wf => wf.name === activeWorkflow);
          if (!w) return null;
          const nodeEntries = Object.entries(w.nodes);
          return (
            <div className="flex-1 flex flex-col overflow-y-auto">
              {/* Workflow header */}
              <div className="px-4 py-3 border-b border-[var(--border)] shrink-0">
                <div className="flex items-center gap-2">
                  <span className="text-sm font-semibold text-[var(--text-primary)]">{w.name}</span>
                  {w.type === 'conversation' && <span className="text-[8px] px-1.5 py-0.5 rounded bg-[var(--accent)]/20 text-[var(--accent)]">conversation</span>}
                  {w.builtin && <span className="text-[8px] px-1.5 py-0.5 rounded bg-[var(--bg-tertiary)] text-[var(--text-secondary)]">built-in</span>}
                  <div className="ml-auto flex gap-2">
                    <button
                      onClick={() => { setSelectedWorkflow(w.name); setInputValues({}); setShowCreate(true); }}
                      className="text-[10px] px-3 py-1 bg-[var(--accent)] text-white rounded hover:opacity-90"
                    >Run</button>
                    <button
                      onClick={async () => {
                        try {
                          const res = await fetch(`/api/pipelines?type=workflow-yaml&name=${encodeURIComponent(w.name)}`);
                          const data = await res.json();
                          setEditorYaml(data.yaml || undefined);
                          setEditorIsConversation(w.type === 'conversation' || (data.yaml || '').includes('type: conversation'));
                        } catch { setEditorYaml(undefined); setEditorIsConversation(false); }
                        setShowEditor(true);
                      }}
                      className="text-[10px] px-3 py-1 border border-[var(--border)] text-[var(--text-secondary)] rounded hover:text-[var(--text-primary)]"
                    >{w.builtin ? 'View YAML' : 'Edit'}</button>
                  </div>
                </div>
                {w.description && (
                  <p
                    className="text-[10px] text-[var(--text-secondary)] mt-1 max-h-20 overflow-y-auto whitespace-pre-wrap break-words"
                    title={w.description}
                  >{w.description}</p>
                )}
                {Object.keys(w.input).length > 0 && (
                  <div className="mt-2 flex flex-wrap gap-1">
                    {Object.entries(w.input).map(([k, v]) => (
                      <span key={k} className="text-[8px] px-1.5 py-0.5 rounded bg-[var(--accent)]/10 text-[var(--accent)]">{k}</span>
                    ))}
                  </div>
                )}
              </div>

              {/* Conversation or Node flow visualization */}
              {w.type === 'conversation' && w.conversation ? (
                <div className="p-4 space-y-3">
                  {/* Initial prompt */}
                  <div className="border border-[var(--border)] rounded-lg p-3 bg-[var(--bg-tertiary)]">
                    <div className="text-[9px] text-[var(--text-secondary)] font-medium mb-1">Initial Prompt</div>
                    <p className="text-[10px] text-[var(--text-primary)]">{w.conversation.initialPrompt}</p>
                  </div>
                  {/* Agents */}
                  <div className="text-[9px] text-[var(--text-secondary)] font-medium">Agents ({w.conversation.agents.length})</div>
                  <div className="space-y-2">
                    {w.conversation.agents.map((a, i) => {
                      const colors = AGENT_COLORS[i % AGENT_COLORS.length];
                      return (
                        <div key={a.id} className={`border rounded-lg p-3 ${colors.bg} ${colors.border}`}>
                          <div className="flex items-center gap-2">
                            <span className={`text-[9px] px-1.5 py-0.5 rounded font-medium ${colors.badge}`}>{a.agent}</span>
                            <span className="text-[11px] font-semibold text-[var(--text-primary)]">{a.id}</span>
                          </div>
                          {a.role && <p className="text-[9px] text-[var(--text-secondary)] mt-1">{a.role}</p>}
                        </div>
                      );
                    })}
                  </div>
                  {/* Config */}
                  <div className="text-[9px] text-[var(--text-secondary)] space-y-0.5">
                    <div>Max rounds: {w.conversation.maxRounds}</div>
                    {w.conversation.stopCondition && <div>Stop: {w.conversation.stopCondition}</div>}
                  </div>
                </div>
              ) : (
              <div className="p-4 space-y-2">
                {nodeEntries.map(([nodeId, node], i) => (
                  <div key={nodeId}>
                    {/* Connection line */}
                    {i > 0 && (
                      <div className="flex items-center justify-center py-1">
                        <div className="w-px h-4 bg-[var(--border)]" />
                      </div>
                    )}
                    {/* Node card */}
                    <div className="border border-[var(--border)] rounded-lg p-3 bg-[var(--bg-tertiary)]">
                      <div className="flex items-center gap-2">
                        <span className={`text-[9px] px-1.5 py-0.5 rounded font-medium ${
                          node.mode === 'shell' ? 'bg-yellow-500/20 text-yellow-400' : 'bg-purple-500/20 text-purple-400'
                        }`}>{node.mode === 'shell' ? 'shell' : (node.agent || 'default')}</span>
                        <span className="text-[11px] font-semibold text-[var(--text-primary)]">{nodeId}</span>
                        {node.project && <span className="text-[9px] text-[var(--text-secondary)] ml-auto">{node.project}</span>}
                      </div>
                      {node.dependsOn.length > 0 && (
                        <div className="text-[8px] text-[var(--text-secondary)] mt-1">depends: {node.dependsOn.join(', ')}</div>
                      )}
                      <p className="text-[9px] text-[var(--text-secondary)] mt-1 line-clamp-2">{node.prompt.slice(0, 120)}{node.prompt.length > 120 ? '...' : ''}</p>
                      {node.outputs.length > 0 && (
                        <div className="flex gap-1 mt-1">
                          {node.outputs.map(o => (
                            <span key={o.name} className="text-[7px] px-1 rounded bg-green-500/10 text-green-400">{o.name}</span>
                          ))}
                        </div>
                      )}
                    </div>
                  </div>
                ))}
              </div>
              )}
            </div>
          );
        })() : (
          <div className="flex-1 flex items-center justify-center text-[var(--text-secondary)]">
            <p className="text-xs">Select a workflow to view details or run</p>
          </div>
        )}
      </main>
      </div>
    </div>
  );
}

/**
 * Small banner shown atop PipelineView when an external CLI a built-in
 * workflow depends on (glab, gh, jq, git) isn't installed. Polls /api/health/tools
 * once on mount + lets the user expand each row for the copy-pasteable
 * install hint.
 */
function MissingToolsBanner() {
  const [tools, setTools] = useState<Array<{ name: string; label: string; needed_for: string; installed: boolean; install_hint: string; version: string }>>([]);
  const [expanded, setExpanded] = useState<string>('');
  const [dismissed, setDismissed] = useState(false);

  useEffect(() => {
    fetch('/api/health/tools')
      .then((r) => r.json())
      .then((j) => setTools(j.tools || []))
      .catch(() => {});
  }, []);

  if (dismissed) return null;
  const missing = tools.filter((t) => !t.installed);
  if (missing.length === 0) return null;

  return (
    <div className="border-b border-[var(--border)] bg-yellow-500/10 px-4 py-2">
      <div className="flex items-center gap-2">
        <span className="text-[11px] font-semibold text-yellow-500">⚠ Missing tools</span>
        <span className="text-[10.5px] text-[var(--text-secondary)]">
          Some built-in pipelines need these CLIs. Click a name for install instructions.
        </span>
        <span className="flex-1" />
        <button
          onClick={() => setDismissed(true)}
          className="text-[10px] text-[var(--text-secondary)] hover:text-[var(--text-primary)]"
          title="Dismiss until next reload"
        >
          ×
        </button>
      </div>
      <div className="mt-1.5 flex flex-wrap gap-1.5">
        {missing.map((t) => (
          <div key={t.name} className="flex flex-col">
            <button
              onClick={() => setExpanded(expanded === t.name ? '' : t.name)}
              className="text-[10.5px] px-2 py-0.5 border border-yellow-500/40 text-yellow-500 rounded hover:bg-yellow-500/10"
              title={t.needed_for}
            >
              {t.name} {expanded === t.name ? '▾' : '▸'}
            </button>
            {expanded === t.name && (
              <pre className="text-[10px] font-mono whitespace-pre-wrap mt-1 p-2 bg-black/30 text-[var(--text-secondary)] border border-[var(--border)] rounded max-w-[520px]">
                {`# ${t.label} — needed for: ${t.needed_for}\n\n${t.install_hint}`}
              </pre>
            )}
          </div>
        ))}
      </div>
    </div>
  );
}
