/**
 * @file Workspace.tsx
 * @description Merged workspace page combining lanes (agent work units) and runs
 * (Claude Code spawns). Displays a horizontal lane strip at the top with counters,
 * the selected lane's pipeline map, and run configuration/console/history below.
 * Runs are tied to lanes: starting a run posts to POST /api/lanes/:id/start.
 * When a cwd isn't owned by any lane, calls POST /api/lanes/ensure first.
 *
 * @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
 */
/* =============================================================================
 * MODULE_GUIDE — extended in-file reference (comments only; safe to read, never executed)
 * =============================================================================
 * ## Design constraints
 * - Local-first: no telemetry leaves the machine unless the user configures webhooks.
 * - Fail-safe hooks path on the server must never block Claude Code; UI mirrors that
 *   philosophy by degrading gracefully (empty states, stale badges, reconnect loops).
 * - Destructive flows stay behind explicit confirmation modals and server-side gates.
 * - Internationalization: user-visible strings belong in i18n JSON, not literals here.
 * - The console never writes a lane's stage: no POST /api/lanes/:id/stage calls.
 *
 * ## Internal dependencies
 * - `../lib/api` — REST client including lanes and run endpoints.
 * - `../lib/types` — Lane, Lane Counts, run handles, etc.
 * - `../lib/eventBus` — WebSocket subscription.
 * - `../components/lanes/` — LaneCard, PipelineMap, DestructiveLaneModal.
 * - `../components/run/` — RunConsole, RunSetup, RunHistory, ActiveRunsSwitcher.
 *
 * ## Public surface
 * - `Workspace` — merged page; see TSDoc on the symbol for behavior.
 *
 * ============================================================================= */
/* -----------------------------------------------------------------------------
 * EXPORT CATALOG
 * **Workspace**
 *   The main exported page component. Merges lanes and runs into one workspace.
 *
 * ----------------------------------------------------------------------------- */

import { useCallback, useEffect, useRef, useState, useSyncExternalStore } from "react";
import { useSearchParams } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { Play, AlertCircle, X, Plus } from "lucide-react";
import { api } from "../lib/api";
import type {
  CwdSuggestion,
  DashboardRunHistoryItem,
  EffortLevel,
  PermissionMode,
  RunHandle,
  RunListResponse,
  RunMode,
} from "../lib/api";
import type {
  Session,
  TranscriptMessage,
  TranscriptContent,
  Lane,
  LaneFeature,
  LaneCounts,
  ProofFeature,
  WSMessage,
} from "../lib/types";
import { eventBus } from "../lib/eventBus";
import { useRunStream } from "../hooks/useRunStream";
import { BUILTIN_SLASH_COMMANDS, RunConsole } from "../components/run/RunConsole";
import type { SlashCommand } from "../components/run/RunConsole";
import { RunSetup } from "../components/run/RunSetup";
import { ActiveRunsSwitcher } from "../components/run/RunHistory";
import PipelineMap from "../components/lanes/PipelineMap";
import LaneCard from "../components/lanes/LaneCard";
import LaneStripCard from "../components/lanes/LaneStripCard";
import { AddLaneModal } from "../components/lanes/AddLaneModal";
import type { ContentBlock, Envelope, UserMessage } from "../hooks/useRunStream";

// Convert past-session transcript messages into envelope shapes so the chat
// view can render the prior conversation alongside live output from the
// resumed run. The shapes are close but not identical (`thinking.text` vs
// `thinking.thinking`, tool_result `id`/`output` vs `tool_use_id`/`content`),
// so each block is mapped individually.
function transcriptToEnvelopes(messages: TranscriptMessage[]): Envelope[] {
  const mapBlock = (b: TranscriptContent): ContentBlock | null => {
    if (b.type === "text") return { type: "text", text: b.text || "" };
    if (b.type === "thinking") return { type: "thinking", thinking: b.text || "" };
    if (b.type === "tool_use") {
      return { type: "tool_use", id: b.id || "", name: b.name || "", input: b.input };
    }
    if (b.type === "tool_result") {
      return {
        type: "tool_result",
        tool_use_id: b.id || "",
        content: b.output || "",
        is_error: !!b.is_error,
      };
    }
    return null;
  };
  const out: Envelope[] = [];
  // Prepend a synthetic system/init envelope carrying the model so the
  // context-window heuristic in computeTokens can size the meter correctly
  // (e.g., [1m] tag → 1M cap) even when no live `system` envelope has
  // arrived yet because the run was loaded from history.
  const firstModel = messages.find((m) => m.type === "assistant" && m.model)?.model;
  if (firstModel) {
    out.push({ type: "system", subtype: "init", model: firstModel } as Envelope);
  }
  for (const m of messages) {
    const content = m.content.map(mapBlock).filter((x): x is ContentBlock => x !== null);
    if (content.length === 0) continue;
    if (m.type === "assistant") {
      out.push({ type: "assistant", message: { content, usage: m.usage } });
    } else {
      out.push({ type: "user", message: { content } });
    }
  }
  return out;
}

// ── Page ──────────────────────────────────────────────────────────────

export function Workspace() {
  const { t } = useTranslation("run");
  const { t: tLanes } = useTranslation("lanes");
  const [searchParams, setSearchParams] = useSearchParams();
  const wsConnected = useSyncExternalStore(eventBus.onConnection, () => eventBus.connected);

  // Lane state
  const [lanes, setLanes] = useState<Lane[]>([]);
  const [counts, setCounts] = useState<LaneCounts>({ total: 0, running: 0, needs_you: 0, dead: 0 });
  const [selectedLaneId, setSelectedLaneId] = useState<number | null>(null);
  const [laneActionError, setLaneActionError] = useState<string | null>(null);
  const [addLaneOpen, setAddLaneOpen] = useState(false);
  const [viewedFeatureSlug, setViewedFeatureSlug] = useState<string | null>(null);
  const [features, setFeatures] = useState<LaneFeature[]>([]);
  const [viewedFeature, setViewedFeature] = useState<LaneFeature | null>(null);
  const [proofFeatures, setProofFeatures] = useState<ProofFeature[]>([]);

  // Run state
  const [mode, setMode] = useState<RunMode>("conversation");
  const [prompt, setPrompt] = useState("");
  const [model, setModel] = useState("");
  const [permissionMode, setPermissionMode] = useState<PermissionMode>("acceptEdits");
  const [effort, setEffort] = useState<EffortLevel>("");
  const [cwd, setCwd] = useState("");
  const [resumeSession, setResumeSession] = useState<Session | null>(null);
  const [handle, setHandle] = useState<RunHandle | null>(null);
  // Live stream state (envelopes + typewriter view) and the WS subscription
  // live in useRunStream; the page keeps the handle and the run-list refresh.
  // `onAnyStatus` fires for every run_status regardless of id - that is what
  // the old inline subscription did with refreshList().
  const { setEnvelopes, displayEnvelopes } = useRunStream(handle?.id ?? null, {
    onStatus: (p) =>
      setHandle((h) =>
        h
          ? {
              ...h,
              status: p.status,
              endedAt: p.at,
              exitCode: p.exitCode ?? h.exitCode,
              sessionId: p.sessionId ?? h.sessionId,
              error: p.error ?? h.error,
            }
          : h
      ),
    onInputAck: () => {
      // Optimistically add the user envelope so the chat shows it
      // immediately (the spawned `claude` won't echo our user input
      // back on stdout in stream-json; we own that side).
      setEnvelopes((prev) => [
        ...prev,
        { type: "user", message: { content: followUpRef.current || "" } } as UserMessage,
      ]);
    },
    onAnyStatus: () => refreshList(),
  });
  const [followUp, setFollowUp] = useState("");
  const [busy, setBusy] = useState<"start" | "send" | "stop" | "attach" | null>(null);
  const [error, setError] = useState<string | null>(null);
  const [activeRuns, setActiveRuns] = useState<RunListResponse | null>(null);
  const [runHistory, setRunHistory] = useState<DashboardRunHistoryItem[]>([]);
  const [binaryStatus, setBinaryStatus] = useState<{ found: boolean; path: string | null } | null>(
    null
  );
  const [cwdSuggestions, setCwdSuggestions] = useState<CwdSuggestion[]>([]);
  const [slashCommands, setSlashCommands] = useState<SlashCommand[]>(BUILTIN_SLASH_COMMANDS);

  // Pre-flight: probe binary + active runs + cwd suggestions + lanes on mount
  const refreshLanes = useCallback(async () => {
    try {
      const r = await api.lanes.list();
      setLanes(r.lanes);
      setCounts(r.counts);
      setSelectedLaneId((cur) =>
        cur !== null && r.lanes.some((l) => l.id === cur) ? cur : (r.lanes[0]?.id ?? null)
      );
    } catch {
      // Silent fail for lanes load
    }
  }, []);

  useEffect(() => {
    api.run
      .binary()
      .then(setBinaryStatus)
      .catch(() => setBinaryStatus({ found: false, path: null }));
    api.run
      .list()
      .then(setActiveRuns)
      .catch(() => undefined);
    api.run
      .history(50)
      .then((r) => setRunHistory(r.items))
      .catch(() => undefined);
    void refreshLanes();
    api.run
      .cwds()
      .then((r) => {
        setCwdSuggestions(r.items);
        // Pre-fill cwd with the user's home directory — a neutral default.
        // Spawning in the dashboard's own cwd would make ad-hoc runs inherit
        // this repo's project context (.claude/agents, skills, rules,
        // CLAUDE.md, .mcp.json), which is almost never what an ad-hoc run
        // wants and can bloat the initial request (issue #202). Fall back to
        // the dashboard cwd when no home suggestion exists. The user can
        // change it; we just don't want an invisible default.
        const home = r.items.find((s) => s.kind === "home");
        const dashboard = r.items.find((s) => s.kind === "dashboard");
        const preferred = home || dashboard;
        if (preferred) {
          setCwd((current) => current || preferred.path);
        }
      })
      .catch(() => undefined);
    // Discover user / project / plugin slash commands. The CLI's built-ins
    // are appended client-side.
    Promise.all([api.ccConfig.commands(), api.ccConfig.plugins()])
      .then(([cmdsResp, pluginsResp]) => {
        const userProject = cmdsResp.items.map<SlashCommand>((c) => ({
          name: c.name,
          description: (c.frontmatter?.description as string | undefined) || c.preview.slice(0, 80),
          source: c.scope === "project" ? "project" : "user",
          filePath: c.file,
        }));
        const pluginCmds: SlashCommand[] = [];
        for (const p of pluginsResp.plugins || []) {
          const cmds = p.contributes?.commands ?? 0;
          if (!cmds || !p.installPath) continue;
        }
        setSlashCommands([...userProject, ...pluginCmds, ...BUILTIN_SLASH_COMMANDS]);
      })
      .catch(() => undefined);

    // Subscribe to lane updates from the event bus
    return eventBus.subscribe((msg: WSMessage) => {
      if (msg.type !== "lane_update") return;
      const payload = msg.data as { lane?: Lane; removed?: number };
      if (payload.removed !== undefined) {
        void refreshLanes();
        return;
      }
      const lane = payload.lane;
      if (!lane) return;
      setLanes((cur) => {
        const i = cur.findIndex((l) => l.id === lane.id);
        if (i === -1) {
          void refreshLanes();
          return cur;
        }
        const next = [...cur];
        next[i] = lane;
        return next;
      });
    });
  }, [refreshLanes]);

  // A reconnect (e.g. the dashboard server restarting) resumes the WS but does
  // not replay missed lane_update diffs, so a lane whose fields changed while
  // disconnected — status, needs_action — would keep showing its pre-restart
  // badges forever with no further server-side change to broadcast. Refetch
  // the full list whenever the socket comes back up.
  useEffect(
    () =>
      eventBus.onConnection((isConnected) => {
        if (isConnected) void refreshLanes();
      }),
    [refreshLanes]
  );

  const refreshList = useCallback(() => {
    api.run
      .list()
      .then(setActiveRuns)
      .catch(() => undefined);
    // Fetch history for the selected lane only
    if (selectedLaneId !== null) {
      api.run
        .history(50, { laneId: selectedLaneId })
        .then((r) => setRunHistory(r.items))
        .catch(() => undefined);
    } else {
      api.run
        .history(50)
        .then((r) => setRunHistory(r.items))
        .catch(() => undefined);
    }
  }, [selectedLaneId]);

  // Background poll so the run list and history reflect external changes
  // (server-boot reconciliation, sibling tabs, direct DB edits) even when
  // no WS event fires. Lighter than typical WS gaps; aggressive enough that
  // status flips appear within seconds without needing a manual refresh.
  useEffect(() => {
    const tick = setInterval(() => {
      refreshList();
    }, 5000);
    return () => clearInterval(tick);
  }, [refreshList]);

  // Refresh whenever the tab regains focus / visibility - typical when the
  // user comes back from running `claude` in a terminal and wants to see the
  // current state of every run without waiting for the next poll.
  useEffect(() => {
    const onFocus = () => refreshList();
    const onVis = () => {
      if (document.visibilityState === "visible") refreshList();
    };
    window.addEventListener("focus", onFocus);
    document.addEventListener("visibilitychange", onVis);
    return () => {
      window.removeEventListener("focus", onFocus);
      document.removeEventListener("visibilitychange", onVis);
    };
  }, [refreshList]);

  // Resume a run from the persistent history list. Routes through the lane
  // system: ensure a lane for the cwd, then start a new claude process
  // in resumeSessionId mode. If cwd is null, resume as a non-lane run
  // (non-lane runs are those created before this feature or via the CLI).
  const onResumeFromHistory = useCallback(
    async (item: DashboardRunHistoryItem) => {
      if (!item.session_id) return;
      if (busy) return;
      setBusy("start");
      setError(null);
      try {
        const transcript = await api.sessions
          .transcript(item.session_id, { limit: 200 })
          .catch(() => ({ messages: [] as TranscriptMessage[] }));

        let fetched: RunHandle;

        if (item.cwd) {
          // Resume through a lane: ensure the lane exists, then start on it
          const effectiveCwd = item.cwd;
          let targetLaneId = lanes.find((l) => l.cwd === effectiveCwd)?.id;

          if (!targetLaneId) {
            // Ensure lane for this cwd
            const ensureResult = await api.lanes.ensure({ cwd: effectiveCwd });
            targetLaneId = ensureResult.lane.id;
            setLanes((prev) => {
              const exists = prev.some((l) => l.id === ensureResult.lane.id);
              return exists ? prev : [...prev, ensureResult.lane];
            });
          }

          // Start on the lane with resume
          const laneStartResult = await api.lanes.action(targetLaneId, "start", {
            prompt: "",
            mode: "conversation",
            model: item.model || undefined,
            permissionMode: item.permission_mode || undefined,
            effort: item.effort || undefined,
            resumeSessionId: item.session_id,
          });

          if (!laneStartResult.lane?.run_id) {
            throw new Error("No run_id returned from lane start");
          }

          fetched = await api.run.get(laneStartResult.lane.run_id, { envelopes: true });
        } else {
          // No cwd: resume as a non-lane run (backward compatibility).
          // These runs stay outside the lane system and are cleaned up
          // by their own expiry, not by lane release.
          fetched = await api.run.start({
            prompt: "",
            mode: "conversation",
            cwd: undefined,
            model: item.model || undefined,
            permissionMode: item.permission_mode || undefined,
            effort: item.effort || undefined,
            resumeSessionId: item.session_id,
          });
        }

        setHandle(fetched);
        setEnvelopes(transcriptToEnvelopes(transcript.messages));
        setFollowUp("");
        setResumeSession(null);
        refreshList();
      } catch (err) {
        const msg = err instanceof Error ? err.message : "unknown";
        setError(t("errors.startFailed", { message: msg }));
      } finally {
        setBusy(null);
      }
    },
    [busy, refreshList, t, lanes]
  );

  // View a past run inline (no spawn). Headless runs are single-shot, so
  // there's no resume - but the transcript is still worth seeing without
  // navigating away. Seeds the chat view with the past messages and a
  // synthetic completed handle so the UI renders as read-only (no Stop
  // button, no follow-up input - both are gated on isLive).
  const onViewFromHistory = useCallback(
    async (item: DashboardRunHistoryItem) => {
      if (!item.session_id) return;
      if (busy) return;
      setError(null);
      try {
        const transcript = await api.sessions.transcript(item.session_id, { limit: 200 });
        const synthetic: RunHandle = {
          id: item.id,
          pid: null,
          mode: item.mode,
          cwd: item.cwd,
          model: item.model,
          permissionMode: item.permission_mode || "acceptEdits",
          effort: item.effort,
          prompt: item.prompt_preview || "",
          argv: [],
          resumeSessionId: item.resume_session_id,
          status: item.status,
          startedAt: new Date(item.started_at).getTime(),
          endedAt: item.ended_at ? new Date(item.ended_at).getTime() : null,
          exitCode: item.exit_code,
          signal: null,
          error: null,
          sessionId: item.session_id,
          envelopeCount: transcript.messages.length,
          stdoutTail: "",
          stderrTail: "",
        };
        setHandle(synthetic);
        setEnvelopes(transcriptToEnvelopes(transcript.messages));
        setMode(item.mode);
        setFollowUp("");
        setResumeSession(null);
      } catch (err) {
        const msg = err instanceof Error ? err.message : "unknown";
        setError(t("errors.attachFailed", { message: msg }));
      }
    },
    [busy, t]
  );

  // Keep latest follow-up in a ref so the WS handler can read it without
  // closure staleness during ack injection.
  const followUpRef = useRef("");
  useEffect(() => {
    followUpRef.current = followUp;
  }, [followUp]);

  const start = useCallback(async () => {
    if (!prompt.trim() || busy) return;
    setBusy("start");
    setError(null);
    setEnvelopes([]);
    try {
      // Resume always uses conversation mode (server enforces this too).
      const effectiveMode: RunMode = resumeSession ? "conversation" : mode;
      const effectiveCwd = resumeSession?.cwd || cwd || undefined;
      // Expand /user-or-project slash commands client-side so the model
      // receives the rendered template, matching what the CLI does.
      const expandedPrompt = await maybeExpandSlashCommand(prompt, slashCommands);

      // Determine which lane to use. If no lane is selected or the cwd
      // doesn't belong to the selected lane, ensure a lane for this cwd first.
      if (!effectiveCwd) {
        throw new Error(t("errors.cwdRequired"));
      }

      let targetLaneId = selectedLaneId;
      if (
        targetLaneId === null ||
        (selectedLaneId !== null &&
          lanes.find((l) => l.id === selectedLaneId)?.cwd !== effectiveCwd)
      ) {
        // Check if any existing lane owns this cwd
        const ownedLane = lanes.find((l) => l.cwd === effectiveCwd);
        if (ownedLane) {
          targetLaneId = ownedLane.id;
          setSelectedLaneId(ownedLane.id);
        } else {
          // Ensure a new lane for this cwd
          try {
            const ensureResult = await api.lanes.ensure({ cwd: effectiveCwd });
            targetLaneId = ensureResult.lane.id;
            setSelectedLaneId(ensureResult.lane.id);
            setLanes((prev) => {
              const exists = prev.some((l) => l.id === ensureResult.lane.id);
              return exists ? prev : [...prev, ensureResult.lane];
            });
          } catch (err) {
            throw new Error(
              t("errors.laneCreateFailed", {
                message: err instanceof Error ? err.message : "unknown",
              })
            );
          }
        }
      }

      if (targetLaneId === null) {
        throw new Error(t("errors.noLaneSelected"));
      }

      // Start the run on the target lane
      let laneStartResult;
      try {
        laneStartResult = await api.lanes.action(targetLaneId, "start", {
          prompt: expandedPrompt,
          mode: effectiveMode,
          model: model || undefined,
          permissionMode,
          resumeSessionId: resumeSession?.id,
          effort: effort || undefined,
        });
      } catch (laneErr: unknown) {
        // Check for 409 ERUNLIVE — the lane already has a live run
        const msg = laneErr instanceof Error ? laneErr.message : String(laneErr);
        if (msg.includes("409") || msg.includes("ERUNLIVE")) {
          // Re-fetch the lane to get its current run_id. Read the run id off
          // the response, not off `lanes` - refreshLanes() only schedules a
          // setState, so the render-scope `lanes` array here is still the
          // pre-409 snapshot and would never carry the live run.
          const fresh = await api.lanes.list().catch(() => null);
          const updatedLane = fresh?.lanes.find((l) => l.id === targetLaneId);
          await refreshLanes();
          if (updatedLane?.run_id) {
            // Attach to the already-running run
            await attachToRun(updatedLane.run_id);
            return;
          }
        }
        throw laneErr;
      }

      // The response is { lane? } per the API. Read the run_id from the lane.
      if (!laneStartResult.lane?.run_id) {
        throw new Error(t("errors.noRunIdReturned"));
      }

      // Fetch the full RunHandle for the new run; fall back to attachToRun if fetch fails
      try {
        const handle = await api.run.get(laneStartResult.lane.run_id, { envelopes: true });
        setHandle(handle);
        // Optimistic user-turn injection so the chat shows your prompt right away.
        setEnvelopes([{ type: "user", message: { content: prompt } } as UserMessage]);
        refreshList();
      } catch (attachErr: unknown) {
        // Run started but we can't fetch the handle. Attach to the run via the existing path.
        try {
          await attachToRun(laneStartResult.lane.run_id);
          refreshList();
        } catch (fallbackErr: unknown) {
          // Even attach failed. Refresh lanes and report the attach error.
          await refreshLanes();
          const attachMsg = fallbackErr instanceof Error ? fallbackErr.message : "unknown";
          throw new Error(t("errors.runStartedButNotAttached", { message: attachMsg }));
        }
      }
    } catch (err: unknown) {
      const m = err instanceof Error ? err.message : "unknown";
      setError(t("errors.startFailed", { message: m }));
    } finally {
      setBusy(null);
    }
  }, [
    prompt,
    mode,
    cwd,
    model,
    permissionMode,
    busy,
    refreshList,
    t,
    resumeSession,
    selectedLaneId,
    lanes,
  ]);

  const attachToRun = useCallback(
    async (id: string) => {
      if (busy) return;
      setBusy("attach");
      setError(null);
      try {
        const fetched = await api.run.get(id, { envelopes: true });
        const spawnerEnvs = ((fetched.envelopes as Envelope[]) || []).slice();
        let envelopesToUse = spawnerEnvs;

        // The spawner's in-memory envelope log only contains envelopes that
        // came over stdout for this specific spawn. For a resumed run, that
        // means prior history is missing - claude --resume reads the prior
        // transcript as context but doesn't replay it on stdout. Without
        // this, re-attaching to a resumed run after navigating away loses
        // everything from before the resume. The session's JSONL transcript
        // on disk has the full story (prior + current), so we use it
        // whenever it has more user/assistant messages than the spawner has
        // seen; otherwise we keep the spawner's log (which is authoritative
        // for in-progress streaming since stream_event deltas don't land in
        // the transcript file until the turn finishes).
        if (fetched.sessionId) {
          try {
            const transcript = await api.sessions.transcript(fetched.sessionId, { limit: 200 });
            const transcriptEnvs = transcriptToEnvelopes(transcript.messages);
            const spawnerCanonicalCount = spawnerEnvs.filter((e) => {
              const t = (e as { type?: string }).type;
              return t === "user" || t === "assistant";
            }).length;
            if (transcriptEnvs.length > spawnerCanonicalCount) {
              envelopesToUse = transcriptEnvs;
            }
          } catch {
            /* transcript fetch failed - keep the spawner's log */
          }
        }

        setHandle(fetched);
        setEnvelopes(envelopesToUse);
        setFollowUp("");
      } catch (err: unknown) {
        const m = err instanceof Error ? err.message : "unknown";
        setError(t("errors.attachFailed", { message: m }));
      } finally {
        setBusy(null);
      }
    },
    [busy, t]
  );

  // Honor `?session=<id>` deep-links from /sessions and /sessions/:id -
  // map the session id to a live run handle and attach to it instead of
  // dropping the user on the new-run config card. Strip the param once
  // consumed so a refresh of the Run page doesn't keep re-attaching.
  const attachAttemptedRef = useRef<Set<string>>(new Set());
  useEffect(() => {
    const sid = searchParams.get("session");
    if (!sid) return;
    if (handle && handle.sessionId === sid) {
      // Already attached to this session - just clean the URL.
      const next = new URLSearchParams(searchParams);
      next.delete("session");
      setSearchParams(next, { replace: true });
      return;
    }
    if (attachAttemptedRef.current.has(sid)) return;
    attachAttemptedRef.current.add(sid);
    api.run
      .list()
      .then((list) => {
        const target = list.items.find(
          (h) => h.sessionId === sid && (h.status === "running" || h.status === "spawning")
        );
        if (target) {
          void attachToRun(target.id);
        } else {
          setError(
            t(
              "errors.sessionRunNotFound",
              "No active dashboard run is driving this session right now."
            )
          );
        }
      })
      .catch(() => undefined)
      .finally(() => {
        const next = new URLSearchParams(searchParams);
        next.delete("session");
        setSearchParams(next, { replace: true });
      });
  }, [searchParams, setSearchParams, handle, attachToRun, t]);

  // Prefill the prompt box from `?prompt=<text>` (e.g. Tabby's Ask handoff).
  // Apply once, then strip the param so a later refresh doesn't overwrite edits
  // the user has since made to the prompt. When `?autostart=1` is also present
  // (Tabby's "ask" path), arm a pending flag so the run fires automatically
  // once preflight is ready - see the autostart effect below.
  const promptPrefilledRef = useRef(false);
  const pendingAutostartRef = useRef(false);
  useEffect(() => {
    if (promptPrefilledRef.current) return;
    const p = searchParams.get("prompt");
    if (!p) return;
    promptPrefilledRef.current = true;
    if (searchParams.get("autostart") === "1") pendingAutostartRef.current = true;
    setPrompt(p);
    const next = new URLSearchParams(searchParams);
    next.delete("prompt");
    next.delete("autostart");
    setSearchParams(next, { replace: true });
  }, [searchParams, setSearchParams]);

  // Autostart a deep-linked prompt once preflight has settled. We wait for the
  // binary probe (can't spawn without `claude`), the prefilled prompt, and the
  // defaulted cwd so the spawn matches exactly what the manual Start button
  // would do. Fires at most once; if `claude` isn't found or a run is already
  // in flight, it disarms and leaves the prompt prefilled for a manual Start.
  useEffect(() => {
    if (!pendingAutostartRef.current) return;
    if (binaryStatus === null) return; // probe still pending
    if (!binaryStatus.found) {
      pendingAutostartRef.current = false;
      return;
    }
    if (busy || handle) {
      pendingAutostartRef.current = false;
      return;
    }
    if (!prompt.trim() || !cwd) return; // wait for prefill + cwd default
    pendingAutostartRef.current = false;
    void start();
  }, [binaryStatus, prompt, cwd, busy, handle, start]);

  const send = useCallback(async () => {
    if (!handle || !followUp.trim() || busy) return;
    setBusy("send");
    setError(null);
    try {
      const expanded = await maybeExpandSlashCommand(followUp, slashCommands);
      await api.run.send(handle.id, expanded);
      // The user envelope is appended optimistically when the WS ack arrives
      // (so deduping is consistent with stream order). Clear the input now.
      setFollowUp("");
    } catch (err: unknown) {
      const m = err instanceof Error ? err.message : "unknown";
      setError(t("errors.sendFailed", { message: m }));
    } finally {
      setBusy(null);
    }
  }, [handle, followUp, busy, t, slashCommands]);

  const stop = useCallback(async () => {
    if (!handle || busy) return;
    setBusy("stop");
    setError(null);
    try {
      await api.run.kill(handle.id);
    } catch (err: unknown) {
      const m = err instanceof Error ? err.message : "unknown";
      setError(t("errors.killFailed", { message: m }));
    } finally {
      setBusy(null);
    }
  }, [handle, busy, t]);

  const newRun = useCallback(() => {
    setHandle(null);
    setEnvelopes([]);
    setFollowUp("");
    setPrompt("");
    setResumeSession(null);
    setError(null);
  }, []);

  const status = handle?.status ?? "idle";
  const isLive = status === "spawning" || status === "running";
  const hasFinished = status === "completed" || status === "error" || status === "killed";
  const currentLane = selectedLaneId !== null ? lanes.find((l) => l.id === selectedLaneId) : null;

  // Feature list follows the selected lane, resets the viewer on lane switch.
  useEffect(() => {
    setViewedFeatureSlug(null);
    setViewedFeature(null);
    if (currentLane === null || currentLane === undefined) {
      setFeatures([]);
      return;
    }
    api.lanes.features
      .list(currentLane.id)
      .then((data) => setFeatures(data.features))
      .catch(() => setFeatures([]));
  }, [currentLane?.id]);

  // Fetch the archived snapshot when the picker selects one — read-only, never
  // touches the live lane.
  useEffect(() => {
    if (!currentLane || !viewedFeatureSlug) {
      setViewedFeature(null);
      return;
    }
    let cancelled = false;
    api.lanes.features
      .show(currentLane.id, viewedFeatureSlug)
      .then((data) => {
        if (!cancelled) setViewedFeature(data.feature);
      })
      .catch(() => {
        if (!cancelled) setViewedFeature(null);
      });
    return () => {
      cancelled = true;
    };
  }, [currentLane?.id, viewedFeatureSlug]);

  // Proof gallery follows the selected lane.
  useEffect(() => {
    if (!currentLane) {
      setProofFeatures([]);
      return;
    }
    api.lanes.proof
      .list(currentLane.id)
      .then((data) => setProofFeatures(data.features))
      .catch(() => setProofFeatures([]));
  }, [currentLane?.id, viewedFeatureSlug]);

  // Resolve the active feature slug: either the user-selected one, or the lane's active_feature_id
  const activeFeatureSlug =
    viewedFeatureSlug ??
    (currentLane?.active_feature_id
      ? features.find((f) => f.id === currentLane?.active_feature_id)?.slug
      : null);
  const proofFeature = activeFeatureSlug
    ? proofFeatures.find((f) => f.slug === activeFeatureSlug)
    : null;

  // Only lock the page to the viewport when we're showing a live run session.
  // The config-card screen needs normal page flow so the form is fully
  // reachable on short windows. The run-session screen, however, owns the
  // chat panel and we want long chats to scroll inside the panel - never the
  // page - so we constrain only that case.
  // Only a live console needs the viewport-locked shell that lets its chat
  // panel scroll internally. Collapsed, the page is an ordinary scrolling
  // document and the lane grid gets the whole height.
  const viewportLocked = !!handle;

  const handleLaneAction = async (id: number, action: string, body?: Record<string, unknown>) => {
    setLaneActionError(null);
    try {
      if (action === "reset" || action === "remove" || action === "purge") {
        await api.lanes.action(id, action, {
          confirm: true,
          expect: body?.expect,
          ...(body?.force === true ? { force: true } : {}),
        });
      } else {
        await api.lanes.action(id, action, body);
      }
      if (action === "remove") await refreshLanes();
    } catch (err) {
      setLaneActionError(err instanceof Error ? err.message : tLanes("actionErrorUnknown"));
    }
  };

  const consoleSection = (
    <>
      {/* Always attached under the pipeline - no header, no collapse. The
              pipeline panel above already names the lane; unmounting RunConsole
              would throw away a live run's rendered history and scroll
              position, so this stays mounted for the page's whole life. */}
      <div data-testid="console-body" className="flex min-h-0 flex-1 flex-col gap-5">
        <Header
          activeRuns={activeRuns}
          currentHandleId={handle?.id || null}
          onAttach={attachToRun}
          wsConnected={wsConnected}
          runHistory={runHistory}
          onResumeFromHistory={onResumeFromHistory}
          onViewFromHistory={onViewFromHistory}
          onRefresh={refreshList}
        />

        {binaryStatus && !binaryStatus.found && (
          <div className="rounded-lg border border-status-danger/40 bg-status-danger/10 px-4 py-3 text-sm text-status-danger flex items-center gap-2">
            <AlertCircle className="w-4 h-4 flex-shrink-0" />
            <span>{t("binary.missing")}</span>
          </div>
        )}

        {error && (
          <div className="rounded-lg border border-status-danger/40 bg-status-danger/10 px-4 py-3 text-sm text-status-danger flex items-center gap-2">
            <AlertCircle className="w-4 h-4 flex-shrink-0" />
            <span className="flex-1 break-all">{error}</span>
            <button
              onClick={() => setError(null)}
              className="text-status-danger/70 hover:text-status-danger p-0.5"
            >
              <X className="w-3.5 h-3.5" />
            </button>
          </div>
        )}

        {!handle ? (
          // Config card uses normal page flow - page scrolls if needed.
          <RunSetup
            mode={mode}
            onModeChange={(m) => {
              setMode(m);
              // Headless can't resume - clearing keeps the UI honest if the
              // user had a session pinned and then switched mode.
              if (m === "headless") setResumeSession(null);
            }}
            prompt={prompt}
            onPromptChange={setPrompt}
            cwd={cwd}
            onCwdChange={setCwd}
            cwdSuggestions={cwdSuggestions}
            model={model}
            onModelChange={setModel}
            permissionMode={permissionMode}
            onPermissionModeChange={setPermissionMode}
            effort={effort}
            onEffortChange={setEffort}
            binaryFound={binaryStatus?.found ?? true}
            busy={busy === "start"}
            onStart={start}
            activeRuns={activeRuns}
            laneCwd={currentLane?.cwd}
            resumeSession={resumeSession}
            onResumeSessionChange={setResumeSession}
            slashCommands={slashCommands}
            runHistory={runHistory}
            onResumeFromHistory={onResumeFromHistory}
          />
        ) : (
          // Run session is wrapped in a flex container so its inner chat panel
          // can take all remaining viewport height; long chats scroll inside.
          <div className="flex-1 min-h-0 flex flex-col">
            <RunConsole
              handle={handle}
              envelopes={displayEnvelopes}
              mode={handle.mode}
              isLive={isLive}
              hasFinished={hasFinished}
              followUp={followUp}
              onFollowUpChange={setFollowUp}
              busy={busy}
              onSend={send}
              onStop={stop}
              onNewRun={newRun}
              slashCommands={slashCommands}
            />
          </div>
        )}
      </div>
    </>
  );

  return (
    <div
      className={
        viewportLocked
          ? "h-[calc(100vh-2.5rem)] lg:h-[calc(100vh-3rem)] flex flex-col gap-5"
          : "space-y-5"
      }
    >
      {/* Page header: what this screen is, and the four numbers that say
          whether anything needs a human right now. */}
      <div className="flex flex-wrap items-center gap-3 border-b border-border pb-3">
        <h2 className="text-base font-semibold tracking-tight text-fg-primary">
          {tLanes("title")}
        </h2>
        <div className="flex flex-wrap items-center gap-1.5 text-xs">
          <span
            data-testid="count-total"
            className="rounded-full bg-surface-2 px-2.5 py-0.5 text-fg-secondary"
          >
            {counts.total} {tLanes("countTotal")}
          </span>
          <span
            data-testid="count-running"
            className="rounded-full bg-blue-600/20 px-2.5 py-0.5 text-blue-400"
          >
            {counts.running} {tLanes("countRunning")}
          </span>
          {counts.needs_you > 0 && (
            <span
              data-testid="count-needs-you"
              className="rounded-full bg-status-warning/20 px-2.5 py-0.5 text-status-warning"
            >
              {counts.needs_you} {tLanes("countNeedsYou")}
            </span>
          )}
          {counts.dead > 0 && (
            <span
              data-testid="count-dead"
              className="rounded-full bg-status-danger/20 px-2.5 py-0.5 text-status-danger"
            >
              {counts.dead} {tLanes("countDead")}
            </span>
          )}
        </div>
        <button
          onClick={() => setAddLaneOpen(true)}
          className="ml-auto flex items-center gap-1.5 rounded border border-border-light px-3 py-1 text-xs text-fg-secondary transition-colors hover:border-border-light hover:text-fg-primary"
          title={tLanes("addLane")}
        >
          <Plus className="h-3.5 w-3.5" />
          {tLanes("add")}
        </button>
      </div>

      {/* Lane carousel: pick a lane here, read it below. Horizontal scroll with
          snap so a dozen lanes stay in one row instead of a wall of cards. */}
      <div
        data-testid="lane-strip"
        className="flex snap-x snap-mandatory gap-2 overflow-x-auto pb-1"
      >
        {lanes.map((l) => (
          <LaneStripCard
            key={l.id}
            lane={l}
            selected={selectedLaneId === l.id}
            onSelect={() => setSelectedLaneId(l.id)}
          />
        ))}
        {!lanes.length && (
          <p className="text-sm text-fg-muted">
            {tLanes("emptyState")} <code>ccam lanes add --cwd $(pwd)</code>
          </p>
        )}
      </div>

      {/* The selected lane's pipeline, full width — the thing you actually
          come to this page to read. */}
      {currentLane && (
        <section data-testid="lane-detail" className="card p-4">
          <div className="mb-3 flex flex-wrap items-baseline gap-2">
            <span className="text-[11px] font-semibold uppercase tracking-widest text-fg-muted">
              {tLanes("cardId", { id: currentLane.id })}
            </span>
            <span className="truncate text-sm font-semibold text-fg-primary">
              {currentLane.title || currentLane.cwd}
            </span>
            <span className="text-[11px] text-fg-muted">{currentLane.pipeline_name}</span>
            {/* `stage` defaults to the DB sentinel "idle" until the driving
                session ever calls `ccam stage` — that string collides with
                `status`'s own "idle"/"running" vocabulary, so a lane that is
                actively running but has never declared a stage read as if it
                were sitting idle. Show a distinct label instead of the raw
                sentinel whenever it doesn't match any node this pipeline
                actually has. */}
            <span className="rounded bg-surface-2 px-2 py-0.5 text-xs text-fg-secondary">
              {currentLane.pipeline_nodes.some((n) => n.id === currentLane.stage)
                ? currentLane.stage
                : tLanes("stageUndeclared")}
            </span>
            {currentLane.detected_stage && (
              <span
                data-testid="detail-auto-stage"
                title={currentLane.detected_signal || undefined}
                className="rounded border border-dashed border-status-warning px-2 py-0.5 text-xs text-status-warning"
              >
                {tLanes("autoStage", { stage: currentLane.detected_stage })}
              </span>
            )}
            {features.length > 0 && (
              <select
                data-testid="feature-picker"
                className="rounded border border-border bg-surface-1 px-2 py-0.5 text-xs"
                value={viewedFeatureSlug ?? ""}
                onChange={(e) => setViewedFeatureSlug(e.target.value || null)}
              >
                <option value="">{tLanes("features.live")}</option>
                {features.map((f) => (
                  <option key={f.slug} value={f.slug}>
                    {f.slug}
                    {f.archived_at ? ` (${tLanes("features.archived")})` : ""}
                  </option>
                ))}
              </select>
            )}
          </div>
          <div className="mb-3">
            <LaneCard
              lane={currentLane}
              onAction={(a, b) => handleLaneAction(currentLane.id, a, b)}
            />
          </div>
          <div className="mb-3">
            <PipelineMap
              nodes={viewedFeature ? viewedFeature.pipeline_nodes : currentLane.pipeline_nodes}
              detectedSignal={viewedFeature ? undefined : currentLane.detected_signal}
            />
            {viewedFeature && (
              <p data-testid="feature-viewer-banner" className="mb-2 text-xs text-fg-muted">
                {tLanes("features.viewingArchived", { slug: viewedFeature.slug })}
              </p>
            )}
          </div>
          {proofFeature &&
            (Object.keys(proofFeature.groups).length > 0 || proofFeature.ticket_report) && (
              <div data-testid="proof-gallery" className="mt-2">
                {proofFeature.ticket_report && (
                  <a
                    href={`/api/lanes/${currentLane.id}/proof/${proofFeature.ticket_report}`}
                    target="_blank"
                    rel="noreferrer"
                    className="text-xs text-fg-muted"
                  >
                    {tLanes("proof.ticketReport")}
                  </a>
                )}
                {Object.entries(proofFeature.groups).map(([group, images]) => (
                  <div key={group} className="mt-1">
                    <span className="text-xs text-fg-muted">
                      {group} · {images.length}
                    </span>
                    <div className="flex flex-wrap gap-1">
                      {images.slice(0, 8).map((img) => (
                        <img
                          key={img}
                          loading="lazy"
                          className="h-16 w-16 rounded object-cover"
                          src={api.lanes.proof.imageUrl(
                            currentLane.id,
                            proofFeature.slug,
                            group,
                            img
                          )}
                          alt={img}
                        />
                      ))}
                      {images.length > 8 && (
                        <span className="text-xs text-fg-muted">+{images.length - 8}</span>
                      )}
                    </div>
                  </div>
                ))}
              </div>
            )}
          <div className="flex min-h-0 flex-col gap-2 border-t border-border pt-3">
            {consoleSection}
          </div>
        </section>
      )}

      <AddLaneModal
        open={addLaneOpen}
        cwdSuggestions={cwdSuggestions}
        onClose={() => setAddLaneOpen(false)}
        onAdded={(lane) => {
          setLanes((prev) => (prev.some((l) => l.id === lane.id) ? prev : [...prev, lane]));
          setSelectedLaneId(lane.id);
          void refreshLanes();
        }}
      />

      {laneActionError && (
        <p
          role="alert"
          className="rounded border border-status-danger bg-status-danger/40 px-3 py-2 text-sm text-status-danger"
        >
          {tLanes("actionError", { message: laneActionError })}
        </p>
      )}

      {/* No lane selected (none exist, or nothing picked yet): the console has
          nowhere to attach, so it falls back to page level. Without this the
          start form would be unreachable on a fresh install. */}
      {!currentLane && <div className="flex min-h-0 flex-col gap-2">{consoleSection}</div>}
    </div>
  );
}

/**
 * Expand a user/project/plugin slash command client-side. Reads the command
 * markdown body via /api/cc-config/file, strips frontmatter, and substitutes
 * `$ARGUMENTS` with whatever the user typed after the command name. If the
 * command isn't user-defined (built-in or unknown), returns the original
 * text unchanged so it still gets sent (the model will see it as text).
 */
async function maybeExpandSlashCommand(text: string, commands: SlashCommand[]): Promise<string> {
  const trimmed = text.trimStart();
  if (!trimmed.startsWith("/")) return text;
  const m = trimmed.match(/^\/([\w:-]+)(?:\s+([\s\S]*))?$/);
  if (!m) return text;
  const [, name, args = ""] = m;
  const cmd = commands.find((c) => c.name === name);
  if (!cmd || cmd.source === "builtin" || !cmd.filePath) return text;
  try {
    const body = await api.ccConfig.file(cmd.filePath);
    let content = body.text;
    // Strip frontmatter if present
    if (content.startsWith("---")) {
      const end = content.indexOf("\n---", 3);
      if (end >= 0) content = content.slice(end + 4).replace(/^\s*\n/, "");
    }
    return content.replace(/\$ARGUMENTS/g, args);
  } catch {
    return text;
  }
}

// ── Header ────────────────────────────────────────────────────────────

function Header({
  activeRuns,
  currentHandleId,
  onAttach,
  wsConnected,
  runHistory,
  onResumeFromHistory,
  onViewFromHistory,
  onRefresh,
}: {
  activeRuns: RunListResponse | null;
  currentHandleId: string | null;
  onAttach: (id: string) => void;
  wsConnected: boolean;
  runHistory: DashboardRunHistoryItem[];
  onResumeFromHistory: (item: DashboardRunHistoryItem) => void;
  onViewFromHistory: (item: DashboardRunHistoryItem) => void;
  onRefresh: () => void;
}) {
  const { t } = useTranslation("run");
  const { t: tCommon } = useTranslation("common");
  return (
    <header className="flex items-start gap-3">
      <div className="w-9 h-9 rounded-xl bg-accent/15 flex items-center justify-center flex-shrink-0">
        <Play className="w-4.5 h-4.5 text-accent" />
      </div>
      <div className="min-w-0 flex-1">
        <div className="flex items-center gap-2">
          <h1 className="text-lg font-semibold text-fg-primary">{t("title")}</h1>
          {wsConnected ? (
            <span className="flex items-center gap-1.5 text-[11px] text-status-success bg-status-success/10 border border-status-success/20 px-2 py-0.5 rounded-full">
              <span className="w-1.5 h-1.5 rounded-full bg-status-success animate-pulse-dot" />
              {tCommon("live")}
            </span>
          ) : (
            <span className="flex items-center gap-1.5 text-[11px] text-fg-secondary bg-surface-4/10 border border-border-light/20 px-2 py-0.5 rounded-full">
              <span className="w-1.5 h-1.5 rounded-full bg-surface-4" />
              {tCommon("offline")}
            </span>
          )}
        </div>
        <p className="text-xs text-fg-muted max-w-3xl">{t("subtitle")}</p>
      </div>
      <ActiveRunsSwitcher
        activeRuns={activeRuns}
        currentHandleId={currentHandleId}
        onAttach={onAttach}
        runHistory={runHistory}
        onResumeFromHistory={onResumeFromHistory}
        onViewFromHistory={onViewFromHistory}
        onRefresh={onRefresh}
      />
    </header>
  );
}
