// CreateProjectPage — the create-project surface (Slice 3). PAGE tier: it owns ALL
// fetching for the create flow (composition rule #1) and feeds the pure composites
// (ChatPanel, Timeline) and the AssemblyLineApp board their props.
//
// Two modes, chosen by the `sessionId` prop the router hands down:
//   • no session (/projects/new)      → a resume list + "Start new"
//   • a session  (/projects/new/:id)  → the two-pane workspace (canvas + chat)
//
// The workspace polls the session's event stream (1s) and, once planning starts,
// the draft board snapshot (AssemblyLineApp polls that itself) and plan.json (for
// the Timeline). Transport lives here; the composites stay pure renderers.

const CP_PHASE_LABEL = {
  interviewing: 'Interviewing',
  brief_gate: 'Brief review',
  planning: 'Planning',
  reviewing: 'Reviewing',
  plan_gate: 'Plan review',
  line_setup: 'Line setup',
  done: 'Done',
  errored: 'Needs attention',
};

// Merge freshly-polled events into the running list by seq (dedup + keep ordered).
const cpMergeBySeq = (prev, fresh) => {
  if (!fresh || !fresh.length) return prev;
  const seen = new Set(prev.map((e) => e.seq));
  const merged = prev.concat(fresh.filter((e) => !seen.has(e.seq)));
  merged.sort((a, b) => a.seq - b.seq);
  return merged;
};
const cpMaxSeq = (evs) => (evs && evs.length ? evs[evs.length - 1].seq : 0);

// ── no-session mode: resume an in-flight session or start a fresh one ──────────
const CreateSessionPicker = () => {
  const [sessions, setSessions] = React.useState(null); // null = still loading
  const [starting, setStarting] = React.useState(false);
  const [error, setError] = React.useState(null);

  React.useEffect(() => {
    let alive = true;
    window.Api.listCreateSessions()
      .then((d) => { if (alive) setSessions(Array.isArray(d?.sessions) ? d.sessions : []); })
      .catch(() => { if (alive) setSessions([]); });
    return () => { alive = false; };
  }, []);

  const startNew = () => {
    if (starting) return;
    setStarting(true);
    setError(null);
    window.Api.createSession()
      .then((s) => window.navigate('/projects/new/' + s.id))
      .catch((e) => { setError((e && e.message) || 'Could not start a new project'); setStarting(false); });
  };

  return (
    <div className="max-w-3xl mx-auto p-8">
      <div className="flex items-center justify-between mb-6">
        <div>
          <h1 className="text-2xl font-bold text-neutral-800 mb-1">New project</h1>
          <p className="text-neutral-500">Describe what you want to build — we'll draft a brief and a plan with you.</p>
        </div>
        <button
          onClick={startNew}
          disabled={starting}
          className="px-4 py-2 rounded-lg font-semibold text-sm bg-indigo-600 text-white hover:bg-indigo-700 disabled:opacity-50 shadow-md shadow-indigo-600/20 active:scale-95"
        >{starting ? 'Starting…' : 'Start new'}</button>
      </div>

      {error && <div className="mb-4 rounded-lg bg-red-50 border border-red-200 p-3 text-sm text-red-800">{error}</div>}

      {sessions === null ? (
        <div className="text-neutral-400">Loading…</div>
      ) : sessions.length === 0 ? (
        <div className="rounded-2xl border border-dashed border-neutral-300 p-10 text-center text-neutral-400">
          No projects in progress. Hit <span className="font-semibold text-neutral-500">Start new</span> to begin one.
        </div>
      ) : (
        <>
          <div className="text-xs font-bold uppercase tracking-widest text-neutral-400 mb-2">In progress</div>
          <ul className="space-y-2">
            {sessions.map((s) => (
              <li key={s.id}>
                <button
                  onClick={() => window.navigate('/projects/new/' + s.id)}
                  className="w-full text-left bg-white border border-neutral-200 rounded-xl p-4 shadow-sm hover:shadow-md hover:border-indigo-300 transition-all flex items-center justify-between gap-4"
                >
                  <span className="font-semibold text-neutral-800 truncate">{s.name || 'Untitled project'}</span>
                  <span className="flex items-center gap-3 shrink-0">
                    <span className="text-xs font-semibold uppercase tracking-widest text-neutral-500">{CP_PHASE_LABEL[s.phase] || s.phase}</span>
                    <span className="text-xs text-neutral-400">{s.updatedAt ? new Date(s.updatedAt).toLocaleString() : ''}</span>
                  </span>
                </button>
              </li>
            ))}
          </ul>
        </>
      )}
    </div>
  );
};

// ── deep-link with an unknown id ──────────────────────────────────────────────
const CreateNotFound = () => (
  <div className="max-w-xl mx-auto p-16 text-center">
    <div className="text-lg font-semibold text-neutral-600 mb-1">That project draft could not be found</div>
    <div className="text-neutral-400 mb-6">It may have been removed, or the link is wrong.</div>
    <button
      onClick={() => window.navigate('/projects/new')}
      className="px-4 py-2 rounded-lg font-semibold text-sm bg-neutral-800 text-white hover:bg-neutral-900"
    >← Back to new project</button>
  </div>
);

// ── with-session mode: the two-pane workspace ─────────────────────────────────
// --- the document-flow workspace views -------------------------------------
// Board stays a canvas (it is genuinely spatial). Brief, Timeline and Stations
// are read top-to-bottom, so they render as ordinary scrolling pages in the same
// pane — the chat rail beside them, and every gate on it, is untouched by a view
// switch. Page chrome matches plan-page / planning-view: a max-width column with
// a small uppercase section label.
// `wide` opts out of the reading-width column. Brief and Timeline are prose and
// want the measure; the station line is a diagram and should use the pane it has,
// so it fits more cards per row instead of wrapping early against dead space.
const CreateDocPage = ({ label, wide = false, children }) => (
  <div className="h-full overflow-y-auto">
    <div className={`${wide ? 'max-w-none' : 'max-w-5xl'} mx-auto px-6 py-8`}>
      {label && (
        <div className="mb-3 px-1 text-[10px] uppercase font-bold tracking-widest text-neutral-500 select-none">{label}</div>
      )}
      {children}
    </div>
  </div>
);

const CreateDocEmpty = ({ children }) => (
  <div className="rounded-2xl border border-dashed border-neutral-300 bg-white px-6 py-14 text-center text-sm text-neutral-400">{children}</div>
);

// The Stations PAGE lays the line out in document flow: cards wrap into rows that
// read left-to-right, top-to-bottom, with a connector after each card so the eye
// snakes into the next row. The CANVAS keeps StationGraphSection — that one is a
// single absolutely-positioned row sized for pan/zoom, which off-canvas clipped
// the line mid-card at the pane's right edge while leaving the page mostly empty.
// Card and connector wrap together as one flex item, so a row break never strands
// an arrow. Cards are richer here because the page has the room the canvas didn't:
// full untruncated title, template, human-review badge, covers count and a
// one-line instructions preview.

// Pipeline order comes from the same layoutGraph the canvas uses (left-to-right,
// then top-to-bottom), so the page and the canvas tell the same story in the same
// order — no second ordering rule to drift.
const stationReadingOrder = (graph) => {
  const nodes = (graph && Array.isArray(graph.nodes)) ? graph.nodes : [];
  if (!nodes.length) return [];
  const { positions } = window.layoutGraph(graph);
  return [...nodes].sort((a, b) => {
    const pa = positions[a.id] || { x: 0, y: 0 };
    const pb = positions[b.id] || { x: 0, y: 0 };
    return pa.x - pb.x || pa.y - pb.y;
  });
};

const StationCard = ({ node, selected, issues, reworkTo, onSelect }) => {
  const covers = Array.isArray(node.covers) ? node.covers.length : 0;
  return (
    <div
      onClick={(e) => { e.stopPropagation(); onSelect && onSelect(node.id); }}
      className={`relative w-72 shrink-0 rounded-xl border bg-white shadow-sm cursor-pointer transition-shadow hover:shadow-md ${selected ? 'border-indigo-300 ring-2 ring-indigo-500' : 'border-neutral-200'}`}
    >
      <div className="p-3.5">
        {/* Full title — it wraps rather than truncating; the whole point of the
            page layout is that there is room for the real names. */}
        <div className="text-[13px] font-bold text-neutral-800 leading-snug">{node.title || node.id}</div>
        <div className="mt-2 flex flex-wrap items-center gap-1.5">
          <span className="text-[10px] font-semibold uppercase tracking-wide bg-neutral-100 text-neutral-500 rounded px-1.5 py-0.5">{node.templateId || '—'}</span>
          {node.humanReview && <span title="Human review" className="text-[9px] font-bold text-amber-700 bg-amber-100 rounded px-1 py-0.5">HR</span>}
          <span className="text-[10px] font-semibold text-neutral-400">
            {covers === 0 ? 'No features' : `${covers} feature${covers === 1 ? '' : 's'}`}
          </span>
          {reworkTo && (
            <span title={`Rework to ${reworkTo}`} className="text-[9px] font-bold text-orange-700 bg-orange-100 rounded px-1 py-0.5">↩ REWORK</span>
          )}
        </div>
        {node.instructions && (
          <div className="mt-2 text-[11px] text-neutral-500 truncate" title={node.instructions}>{node.instructions}</div>
        )}
      </div>
      {issues > 0 && (
        <span className="absolute -top-2 -right-2 min-w-[18px] h-[18px] px-1 rounded-full bg-red-500 text-white text-[10px] font-bold flex items-center justify-center shadow">{issues}</span>
      )}
    </div>
  );
};

const CreateStationsPage = ({ stationGraph, onSelectStation, onTargetChat }) => {
  const {
    graph, issuesByNode = {}, selectedId = null,
    proposalRunning = false, revising = false, stale = false, error = null, onRetry,
  } = stationGraph || {};
  const nodes = stationReadingOrder(graph);
  const edges = (graph && Array.isArray(graph.edges)) ? graph.edges : [];
  // A rework edge cannot be drawn sanely across wrapped rows, so it rides on the
  // card it leaves from instead of becoming an invisible relationship.
  const reworkFrom = {};
  for (const e of edges) {
    if (e.kind !== 'rework') continue;
    const target = nodes.find((n) => n.id === e.to);
    reworkFrom[e.from] = (target && target.title) || e.to;
  }

  const header = (
    <div className="mb-4 flex items-center gap-2 text-[10px] uppercase font-bold tracking-widest text-neutral-500 select-none">
      Stations · the line that builds it
      {window.StaleBadge && <window.StaleBadge stale={stale} />}
      {typeof onTargetChat === 'function' && (
        <button
          type="button"
          onClick={(e) => { e.stopPropagation(); onTargetChat('line'); }}
          className="normal-case tracking-normal rounded-full bg-indigo-50 border border-indigo-200 px-2 py-0.5 text-[10px] font-bold text-indigo-700 hover:bg-indigo-100 cursor-pointer"
        >Chat about the line</button>
      )}
      {revising && (
        <span className="normal-case tracking-normal flex items-center gap-1.5 rounded-full bg-indigo-50 border border-indigo-200 px-2 py-0.5 text-[10px] font-bold text-indigo-700">
          <span className="relative flex w-1.5 h-1.5">
            <span className="absolute inline-flex h-full w-full rounded-full bg-indigo-500 opacity-75 animate-ping" />
            <span className="relative inline-flex rounded-full h-1.5 w-1.5 bg-indigo-500" />
          </span>
          Revising the line…
        </span>
      )}
    </div>
  );

  let body;
  if (error) {
    body = (
      <div className="rounded-2xl border-2 border-dashed border-red-300 bg-red-50/60 p-6">
        <div className="text-sm font-semibold text-red-800 mb-3">{error}</div>
        <button onClick={() => onRetry && onRetry()} className="px-3 py-1.5 rounded-lg text-xs font-bold bg-red-600 text-white hover:bg-red-700">Try again</button>
      </div>
    );
  } else if (!nodes.length && proposalRunning) {
    body = (
      <div className="rounded-2xl border-2 border-dashed border-indigo-300 bg-indigo-50/40 p-6 flex items-center gap-3">
        <span className="relative flex w-2.5 h-2.5">
          <span className="absolute inline-flex h-full w-full rounded-full bg-indigo-500 opacity-75 animate-ping" />
          <span className="relative inline-flex rounded-full h-2.5 w-2.5 bg-indigo-500" />
        </span>
        <span className="text-sm font-semibold text-indigo-800">Proposing station line…</span>
      </div>
    );
  } else if (!nodes.length) {
    body = <CreateDocEmpty>The station line appears here once the plan is approved.</CreateDocEmpty>;
  } else {
    body = (
      <div className="flex flex-wrap items-stretch gap-y-4">
        {nodes.map((n, i) => (
          <div key={n.id} className="flex items-center">
            <StationCard
              node={n}
              selected={n.id === selectedId}
              issues={issuesByNode[n.id] || 0}
              reworkTo={reworkFrom[n.id]}
              onSelect={onSelectStation}
            />
            {i < nodes.length - 1 && (
              <span aria-hidden="true" className="px-2 text-neutral-300 text-lg select-none">→</span>
            )}
          </div>
        ))}
      </div>
    );
  }

  return <>{header}{body}</>;
};

// The SETUP step — the first thing a new project shows, before the interview.
// The name is required and lands immediately (PATCH /create/sessions/:id), so the
// project appears named in the switcher and on All projects instead of sitting as
// "Untitled project" until propose_brief. The two connections are optional and
// purely a head start: they are remembered as the launch page's prefill, and
// nothing here contacts Teamwork or Railway.
const CP_SETUP_KEY = (id) => `al-create-setup:${id}`;

const CreateSetupStep = ({ sessionId, initialName, onDone }) => {
  const [name, setName] = React.useState(initialName && initialName !== 'Untitled project' ? initialName : '');
  const [pmProjectId, setPmProjectId] = React.useState('');
  const [railwayProjectId, setRailwayProjectId] = React.useState('');
  const [isPrivate, setIsPrivate] = React.useState(false);
  const [saving, setSaving] = React.useState(false);
  const [error, setError] = React.useState(null);
  const trimmed = name.trim();

  const submit = (e) => {
    if (e) e.preventDefault();
    if (!trimmed || saving) return;
    setSaving(true); setError(null);
    window.Api.renameCreateSession(sessionId, trimmed, isPrivate ? 'private' : 'team')
      .then((s) => {
        // Optional connections are stashed for the launch page to prefill. They are
        // NOT sent anywhere now — launch is where a connection is actually made.
        try {
          const pm = pmProjectId.trim();
          const rw = railwayProjectId.trim();
          if (pm || rw) {
            window.localStorage.setItem(CP_SETUP_KEY(sessionId), JSON.stringify({
              pmProjectId: pm ? Number(pm) : null,
              railwayProjectId: rw || null,
            }));
          }
        } catch { /* private mode / quota — the prefill is a convenience, not state */ }
        onDone(s);
      })
      .catch((err) => setError((err && err.message) || 'Could not save the project name.'))
      .finally(() => setSaving(false));
  };

  const input = 'w-full text-sm px-3 py-2 rounded-lg border border-neutral-300 focus:outline-none focus:ring-2 focus:ring-indigo-300';
  return (
    <div className="h-full overflow-y-auto">
      <form onSubmit={submit} className="max-w-xl mx-auto px-6 py-10">
        <div className="text-[10px] uppercase font-bold tracking-widest text-neutral-500 mb-2">New project · setup</div>
        <h1 className="text-xl font-bold text-neutral-900 mb-1">What should we call this project?</h1>
        <p className="text-sm text-neutral-500 mb-6">
          The name is all we need to start. You can connect the boards now or leave it for Launch.
        </p>

        <label className="block mb-5">
          <div className="text-xs font-semibold text-neutral-600 mb-1">Project name</div>
          <input
            autoFocus
            value={name}
            onChange={(e) => setName(e.target.value)}
            className={input}
            placeholder="e.g. KiroTurnus — digital turnusløsning"
          />
        </label>

        {/* Private (experimental): the project is listed and served only to its
            creator and admins — enforced by the viewer API, not just hidden here. */}
        <label className="flex items-start gap-3 rounded-2xl border border-neutral-200 bg-neutral-50/60 px-4 py-3 mb-4 cursor-pointer" data-testid="create-private-toggle">
          <input type="checkbox" checked={isPrivate} onChange={(e) => setIsPrivate(e.target.checked)} className="mt-0.5" />
          <span>
            <span className="block text-xs font-semibold text-neutral-700">Private (experimental)</span>
            <span className="block text-xs text-neutral-500">Only you and admins can see this project. Leave off for a team project.</span>
          </span>
        </label>

        <div className="rounded-2xl border border-neutral-200 bg-neutral-50/60 px-4 py-4 mb-6">
          <div className="text-xs font-semibold text-neutral-600 mb-3">Connections — optional, all skippable</div>
          <label className="block mb-3">
            <div className="text-xs font-semibold text-neutral-600 mb-1 flex items-center">
              Project Management (PM) Board id
              <CreateInfo tip={CP_PM_BOARD_TIP} />
            </div>
            <input type="number" min="1" value={pmProjectId} onChange={(e) => setPmProjectId(e.target.value)} className={input} placeholder="Teamwork project id — leave blank for none" />
          </label>
          <label className="block">
            <div className="text-xs font-semibold text-neutral-600 mb-1">Railway project id</div>
            <input value={railwayProjectId} onChange={(e) => setRailwayProjectId(e.target.value)} className={input} placeholder="leave blank for none" />
          </label>
        </div>

        {error && <div className="mb-4 rounded-lg bg-red-50 border border-red-200 p-3 text-sm text-red-800">{error}</div>}

        <div className="flex items-center gap-3">
          <button
            type="submit"
            disabled={!trimmed || saving}
            className="px-4 py-2 text-sm font-semibold rounded-lg bg-neutral-900 text-white hover:bg-black disabled:opacity-40"
          >{saving ? 'Saving…' : 'Continue to the interview →'}</button>
          {!trimmed && <span className="text-xs text-neutral-400">A name is required.</span>}
        </div>
      </form>
    </div>
  );
};

// Shared with the launch page's wording: full name first, shorthand after. The
// tooltip describes what the code does with the board, not what it is called.
const CP_PM_BOARD_TIP = 'Project Management (PM) Board — the customer-facing Teamwork project where work is ordered and decisions are made. Optional: connect one and Launch writes a task per feature onto it and registers the webhooks that feed tagged tasks into the line. Leave it blank and the line runs without a customer-facing board.';

const CreateInfo = ({ tip }) => (
  <span
    title={tip}
    tabIndex={0}
    role="img"
    aria-label={tip}
    className="ml-1.5 inline-flex h-4 w-4 shrink-0 cursor-help items-center justify-center rounded-full border border-neutral-300 text-[10px] font-bold text-neutral-400 hover:border-neutral-400 hover:text-neutral-600"
  >i</span>
);

const CreateDocView = ({ view, name, briefDoc, timelineGantt, timelineState, stationGraph, onSelectStation, onTargetChat }) => {
  if (view === 'brief') {
    return (
      <CreateDocPage label="Brief · what we're building">
        {briefDoc ? (
          <div className="rounded-2xl border border-neutral-200 bg-white shadow-sm overflow-hidden">
            <div className="px-5 py-3 border-b border-neutral-200 flex items-center justify-between gap-3">
              <span className="text-sm font-bold text-neutral-800 truncate">{briefDoc.name || name}</span>
              {typeof onTargetChat === 'function' && (
                <button
                  type="button"
                  onClick={() => onTargetChat('brief')}
                  className="shrink-0 rounded-full bg-indigo-50 border border-indigo-200 px-2.5 py-0.5 text-[10px] font-bold text-indigo-700 hover:bg-indigo-100"
                >Chat about the brief</button>
              )}
            </div>
            <div className="px-5 py-4"><window.MarkdownView markdown={briefDoc.markdown} /></div>
          </div>
        ) : <CreateDocEmpty>The brief appears here once it has been proposed.</CreateDocEmpty>}
      </CreateDocPage>
    );
  }
  if (view === 'timeline') {
    const { stale = false, regenerating = false, rereview = null, onApproveRereview, onReviseRereview } = timelineState || {};
    return (
      <CreateDocPage label={null}>
        <div className="mb-3 px-1 flex items-center gap-2 text-[10px] uppercase font-bold tracking-widest text-neutral-500 select-none">
          Timeline · the plan
          {window.StaleBadge && <window.StaleBadge stale={stale} regenerating={regenerating} label="Regenerating the plan…" />}
        </div>
        {/* The plan re-review gate. It used to live only on the canvas timeline
            section, and ChatPanel deliberately dedupes it out of the transcript —
            so if the Timeline view did not carry it, the gate would have no
            surface at all and the plan could not be re-approved. */}
        {rereview && (
          <div className="mb-3 rounded-xl border border-amber-200 bg-amber-50 shadow-sm px-4 py-3 flex items-center justify-between gap-3">
            <span className="text-sm font-semibold text-amber-800">The plan changed — re-review it.</span>
            <span className="flex gap-2 shrink-0">
              <button
                type="button"
                onClick={() => onApproveRereview && onApproveRereview()}
                className="px-3 py-1.5 text-xs font-semibold rounded-md bg-emerald-600 text-white hover:bg-emerald-700"
              >Approve</button>
              <button
                type="button"
                onClick={() => onReviseRereview && onReviseRereview()}
                className="px-3 py-1.5 text-xs font-semibold rounded-md bg-white border border-neutral-300 text-neutral-700 hover:bg-neutral-100"
              >Revise</button>
            </span>
          </div>
        )}
        {timelineGantt ? (
          <div className="rounded-2xl border border-neutral-200 bg-white shadow-sm px-5 py-4">
            <window.Timeline gantt={timelineGantt} />
          </div>
        ) : <CreateDocEmpty>The timeline appears here once planning starts.</CreateDocEmpty>}
      </CreateDocPage>
    );
  }
  // stations — no page label: StationGraphSection draws its own (with the chat
  // pill and busy spinner in it), and two labels would just repeat each other.
  return (
    <CreateDocPage wide>
      <CreateStationsPage stationGraph={stationGraph} onSelectStation={onSelectStation} onTargetChat={onTargetChat} />
    </CreateDocPage>
  );
};

const CreateSessionWorkspace = ({ sessionId, view = 'board' }) => {
  const [loadState, setLoadState] = React.useState('loading'); // loading | ready | notfound | error
  const [session, setSession] = React.useState(null);
  const [events, setEvents] = React.useState([]);
  const [phase, setPhase] = React.useState(null);
  const [busy, setBusy] = React.useState(false);
  const [attaching, setAttaching] = React.useState(false); // an attach upload / folder-link in flight
  const [pageError, setPageError] = React.useState(null);  // last action failure (attach, folder-link)
  const [transport, setTransport] = React.useState('sse'); // 'sse' | 'poll'
  const [contextOpen, setContextOpen] = React.useState(false);
  const [streamingText, setStreamingText] = React.useState(null); // live agent_text_delta text, never merged as an event

  // Poll cursor (max merged seq) and the "action fired at" marker that clears busy
  // once the agent's response appears past it. Refs so the 1s interval closure
  // always reads the latest without re-subscribing.
  const lastSeqRef = React.useRef(0);
  const pendingFromRef = React.useRef(null);

  const draftSlug = (session && session.draftSlug) || null;
  // Whether this draft has already shipped. The done-phase footer must point at the
  // running board, not back at "set up the line" — the project is past that.
  const [launchStatus, setLaunchStatus] = React.useState(null);
  React.useEffect(() => {
    if (!draftSlug) { setLaunchStatus(null); return undefined; }
    let alive = true;
    window.Api.getLaunch(draftSlug)
      .then((r) => { if (alive) setLaunchStatus(r && r.launch ? r.launch.status || null : null); })
      .catch(() => { if (alive) setLaunchStatus(null); });
    return () => { alive = false; };
  }, [draftSlug, phase]);
  const name = (session && session.name) || 'New project';

  const applyFullSession = (s) => {
    if (!s) return;
    setStreamingText(null);
    setSession(s);
    if (s.phase) setPhase(s.phase);
    if (Array.isArray(s.events)) {
      setEvents((prev) => cpMergeBySeq(prev, s.events));
      lastSeqRef.current = Math.max(lastSeqRef.current, cpMaxSeq(s.events));
    }
    pendingFromRef.current = null;
    setBusy(false);
  };

  // Bootstrap — load the full session (events + phase). 404 → the not-found card.
  React.useEffect(() => {
    let alive = true;
    setLoadState('loading');
    setSession(null); setEvents([]); setPhase(null); setBusy(false);
    lastSeqRef.current = 0; pendingFromRef.current = null;
    window.Api.getCreateSession(sessionId)
      .then((s) => {
        if (!alive) return;
        setSession(s);
        setEvents(s.events || []);
        setPhase(s.phase);
        lastSeqRef.current = cpMaxSeq(s.events || []);
        setLoadState('ready');
      })
      .catch((e) => { if (alive) setLoadState(e && e.status === 404 ? 'notfound' : 'error'); });
    return () => { alive = false; };
  }, [sessionId]);

  // Keep the session record (draftSlug / name / brief) fresh. draftSlug is patched
  // in a beat AFTER the phase flips to planning (startPlanning is fire-and-forget),
  // so while it's still missing in a board phase we poll until it lands; otherwise a
  // single refresh per phase change is enough. When draftSlug arrives the dep changes
  // and this effect re-runs with the interval branch off.
  React.useEffect(() => {
    if (loadState !== 'ready' || !phase) return;
    let alive = true;
    const refresh = () => window.Api.getCreateSession(sessionId).then((s) => { if (alive) setSession(s); }).catch(() => {});
    refresh();
    const boardPhase = phase === 'planning' || phase === 'reviewing' || phase === 'plan_gate' || phase === 'line_setup' || phase === 'done';
    if (boardPhase && !draftSlug) {
      const iv = setInterval(refresh, 2000);
      return () => { alive = false; clearInterval(iv); };
    }
    return () => { alive = false; };
  }, [phase, loadState, sessionId, draftSlug]);

  // Event transport: one SSE stream via the proxy (server pushes each event as it
  // lands), falling back to the pre-SSE 1s poll when EventSource is unavailable or
  // the stream can't stay open. Both paths stop once the session is done and feed
  // the same merge; applyFresh mirrors the poll contract, with phase read from the
  // phase events themselves (setPhase always appends one, so parity holds).
  React.useEffect(() => {
    if (loadState !== 'ready' || phase === 'done') return;
    const applyFresh = (fresh) => {
      if (!fresh || !fresh.length) return;
      if (fresh.some((e) => e.kind !== 'user_text' && typeof e.seq === 'number')) setStreamingText(null); // a durable agent event finalizes the stream
      setEvents((prev) => cpMergeBySeq(prev, fresh));
      lastSeqRef.current = Math.max(lastSeqRef.current, cpMaxSeq(fresh));
      // Attachment chips land BEFORE the interviewer replies (in-chat link ingest
      // runs pre-agent) — they must not release busy, or the thinking indicator
      // dies while the agent is still working and the chat sits silent.
      if (pendingFromRef.current != null &&
          fresh.some((e) => e.seq > pendingFromRef.current && e.kind !== 'user_text' && e.kind !== 'attachment')) {
        pendingFromRef.current = null;
        setBusy(false);
      }
      const phEv = [...fresh].reverse().find((e) => e.kind === 'phase' && e.phase);
      if (phEv) {
        setPhase(phEv.phase);
        // Entering errored always releases busy — a stuck busy flag would disable
        // the Retry button, dead-locking the only way out of the error.
        if (phEv.phase === 'errored') { pendingFromRef.current = null; setBusy(false); }
      }
    };
    if (transport === 'sse' && window.CreateStream) {
      const sub = window.CreateStream.subscribeCreateEvents({
        sessionId,
        after: lastSeqRef.current,
        onEvent: (ev) => {
          if (ev && ev.kind === 'agent_text_delta') { setStreamingText(ev.text || null); return; }
          applyFresh([ev]);
        },
        onFallback: () => setTransport('poll'),
      });
      return () => sub.close();
    }
    // Poll fallback — the pre-SSE transport, unchanged.
    let alive = true;
    const tick = () => {
      window.Api.getCreateEvents(sessionId, lastSeqRef.current)
        .then(({ events: fresh, phase: ph }) => {
          if (!alive) return;
          applyFresh(fresh);
          if (ph) setPhase(ph);
          if (ph === 'errored') { pendingFromRef.current = null; setBusy(false); }
        })
        .catch(() => {}); // transient poll failure — the next tick retries
    };
    tick();
    const iv = setInterval(tick, 1000);
    return () => { alive = false; clearInterval(iv); };
  }, [sessionId, loadState, phase, transport]);

  // Transport for the ChatPanel + retry. Hold busy ONLY on a 409 { error: "busy" }
  // (an agent turn is running — the poll will settle it). Every other failure — incl.
  // a wrong-phase 409 that appended no event — releases busy and clears the pending marker.
  const onSend = (text) => {
    pendingFromRef.current = lastSeqRef.current;
    setBusy(true);
    setStreamingText(null);
    // chatTarget (null by default) rides along as the a4 `target`; an out-of-reach
    // target 409s (not error:"busy") and falls through to the normal error path below,
    // which releases busy exactly like any other wrong-phase POST failure.
    window.Api.sendCreateMessage(sessionId, text, chatTarget)
      .catch((e) => { if (!(e && e.status === 409 && e.body?.error === 'busy')) { pendingFromRef.current = null; setBusy(false); } });
  };
  const onGate = (gateId, decision, note) => {
    pendingFromRef.current = lastSeqRef.current;
    setBusy(true);
    setStreamingText(null);
    window.Api.postCreateGate(sessionId, gateId, decision, note)
      .then((s) => {
        applyFullSession(s); // returns the FULL refreshed session — flips phase at once
        // Approving the line is the hand-off to Launch — land the operator there.
        if (gateId === 'line' && decision === 'approve' && draftSlug) window.navigate(`/projects/${draftSlug}/launch`);
      })
      .catch((e) => { if (!(e && e.status === 409 && e.body?.error === 'busy')) { pendingFromRef.current = null; setBusy(false); } });
  };
  const onRetry = () => {
    pendingFromRef.current = lastSeqRef.current;
    setBusy(true);
    setStreamingText(null);
    window.Api.retryCreateSession(sessionId)
      .then(applyFullSession)
      .catch((e) => { if (!(e && e.status === 409 && e.body?.error === 'busy')) { pendingFromRef.current = null; setBusy(false); } });
  };
  // Attach materials — upload files or link a server-side folder. Both flip the
  // shared `attaching` flag (folded into ChatPanel's busy prop) and surface any
  // failure through the page's error banner. The resulting `attachment` event
  // arrives over the normal stream, so there's nothing to merge here.
  const onAttach = (files) => {
    setPageError(null);
    setAttaching(true);
    window.Api.uploadCreateFiles(sessionId, files).catch(setPageError).finally(() => setAttaching(false));
  };
  const onLinkFolder = (path) => {
    setPageError(null);
    setAttaching(true);
    window.Api.linkCreateFolder(sessionId, path).catch(setPageError).finally(() => setAttaching(false));
  };
  const onAddLink = (url) => {
    setPageError(null);
    setAttaching(true);
    window.Api.addCreateLink(sessionId, url).catch(setPageError).finally(() => setAttaching(false));
  };

  // Draft plan → gantt for the in-canvas timeline. Appears mid-planning; keep
  // polling until the session is done (the plan is then final).
  const [plan, setPlan] = React.useState(null);
  React.useEffect(() => {
    if (!draftSlug) return;
    let alive = true;
    const load = () => fetch('/data/projects/' + draftSlug + '/plan.json')
      .then((r) => (r.ok ? r.json() : null))
      .then((p) => { if (alive) setPlan(p); })
      .catch(() => { if (alive) setPlan(null); });
    load();
    if (phase === 'done') return () => { alive = false; };
    const iv = setInterval(load, 5000);
    return () => { alive = false; clearInterval(iv); };
  }, [draftSlug, phase]);
  const timelineGantt = plan ? window.PlanToGantt.planToGantt(plan) : null;

  // Advisory staleness (brief→plan→line) + the plan ledger's regenerating flag, ridden
  // off the SAME per-slug lineKey change stream the line section already consumes (I7):
  // a brief.md / plan-ledger / line-ledger write refreshes it with no new stream. A 5s
  // poll is only the SSE-unavailable fallback (the primary transport is the stream).
  const [staleness, setStaleness] = React.useState(null);
  const [stalenessTransport, setStalenessTransport] = React.useState('sse');
  React.useEffect(() => {
    if (!draftSlug) return;
    let alive = true;
    const refresh = () => window.Api.getDraftStaleness(draftSlug).then((d) => { if (alive) setStaleness(d); }).catch(() => {});
    refresh();
    if (stalenessTransport === 'sse' && window.CreateStream) {
      const sub = window.CreateStream.subscribeDraftChange({ slug: draftSlug, surface: 'line', onChange: refresh, onFallback: () => setStalenessTransport('poll') });
      return () => { alive = false; sub.close(); };
    }
    if (phase === 'done') return () => { alive = false; };
    const iv = setInterval(refresh, 5000);
    return () => { alive = false; clearInterval(iv); };
  }, [draftSlug, stalenessTransport, phase]);

  // Composer target for any-step chat. R9b-8 owns the chip UI + composer consumption;
  // b7 lands only the setter, which the plan re-review "Revise" invokes with 'plan'.
  const [chatTarget, setChatTarget] = React.useState(null);

  // Height = viewport minus the MEASURED header, not a hardcoded 3.25rem: the
  // header's real height varies with fonts/zoom, and any mismatch pushed the
  // gate footer below the fold — an approve button you could only reach by
  // rubber-band overscroll (2026-08-11). Hoisted above the early returns so
  // hook order stays stable across load states.
  const [headerH, setHeaderH] = React.useState(52);
  React.useEffect(() => {
    const el = document.querySelector('header');
    if (!el) return;
    const update = () => setHeaderH(el.offsetHeight);
    update();
    const ro = typeof ResizeObserver !== 'undefined' ? new ResizeObserver(update) : null;
    if (ro) ro.observe(el);
    window.addEventListener('resize', update);
    return () => { if (ro) ro.disconnect(); window.removeEventListener('resize', update); };
  }, []);

  // Brief pane while interviewing / at the brief gate (or if it errored before a
  // plan ever started); the board once planning has produced a draft. Hoisted
  // above the surface-broadcast effect so hook order stays stable across renders.
  const briefMode = phase === 'interviewing' || phase === 'brief_gate' || (phase === 'errored' && !draftSlug);

  // Tell the app header which canvas sections exist (Board/Timeline buttons).
  const hasBoard = !briefMode && !!draftSlug;

  // ── line setup, in-canvas (ported from LineSetupPage's working-copy machinery) ──
  // `done` is included so legacy already-done sessions still get the read-only
  // graph; the footer/gate below only renders for line_setup.
  const lineActive = (phase === 'line_setup' || phase === 'done') && !!draftSlug;

  const [lineData, setLineData] = React.useState(null);
  const [lineTransport, setLineTransport] = React.useState('sse');
  const [templates, setTemplates] = React.useState([]);
  const [working, setWorking] = React.useState(null);
  const [baseVersion, setBaseVersion] = React.useState(null);
  const [dirty, setDirty] = React.useState(false);
  const [saveState, setSaveState] = React.useState('idle'); // idle | saving | error
  const [stale, setStale] = React.useState(null);
  const [lineError, setLineError] = React.useState(null);
  const [selectedStationId, setSelectedStationId] = React.useState(null);

  // Server-latest derivations — needed by the effects below, so derived up here.
  const lineSetup = lineData && lineData.lineSetup;
  const lineVersions = (lineSetup && Array.isArray(lineSetup.versions)) ? lineSetup.versions : [];
  const latestLine = lineVersions.length ? lineVersions[lineVersions.length - 1] : null;
  const latestVersionNum = latestLine ? latestLine.version : 0;
  const proposalRunning = !!(lineSetup && lineSetup.proposal && lineSetup.proposal.status === 'running');

  // Fetch on entry; refresh on the draft's 'line' change pings (initial ping on
  // open covers anything missed). Fallback transport: 2.5s poll while a proposal
  // runs (chat revisions land via the gate/session refresh in that mode).
  React.useEffect(() => {
    if (!lineActive) return;
    let alive = true;
    const refresh = () => { window.Api.getLineSetup(draftSlug).then((d) => { if (alive) setLineData(d); }).catch(() => {}); };
    refresh();
    if (lineTransport === 'sse' && window.CreateStream) {
      const sub = window.CreateStream.subscribeDraftChange({
        slug: draftSlug,
        surface: 'line',
        onChange: refresh,
        onFallback: () => setLineTransport('poll'),
      });
      return () => { alive = false; sub.close(); };
    }
    if (!proposalRunning) return () => { alive = false; };
    const iv = setInterval(refresh, 2500);
    return () => { alive = false; clearInterval(iv); };
  }, [lineActive, draftSlug, lineTransport, proposalRunning]);

  // Station templates for the modal's template picker.
  React.useEffect(() => {
    if (!lineActive) return;
    let alive = true;
    window.Api.getLineTemplates().then((d) => { if (alive) setTemplates((d && d.templates) || []); }).catch(() => {});
    return () => { alive = false; };
  }, [lineActive]);

  // Adopt a NEW server latest into the working copy (proposal completes, chat
  // revision lands, save round-trips) — verbatim LineSetupPage semantics.
  React.useEffect(() => {
    if (!latestLine) { setWorking(null); return; }
    setWorking(JSON.parse(JSON.stringify(latestLine.graph)));
    setBaseVersion(latestLine.version);
    setDirty(false);
    setStale(null);
    setLineError(null);
    setSaveState('idle');
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [draftSlug, latestVersionNum]);

  // op → GraphEdit dispatch table (copied verbatim from LineSetupPage).
  const applyOp = (graph, op) => {
    const fn = window.GraphEdit[op.type];
    if (typeof fn !== 'function') return graph;
    switch (op.type) {
      case 'rename': return fn(graph, op.id, op.title);
      case 'setInstructions': return fn(graph, op.id, op.text);
      case 'setTemplate': return fn(graph, op.id, op.templateId);
      case 'toggleHumanReview': return fn(graph, op.id);
      case 'setCovers': return fn(graph, op.id, op.keys);
      case 'addNode': return fn(graph, op.template);
      case 'removeNode': return fn(graph, op.id);
      case 'addEdge': return fn(graph, op.edge);
      case 'removeEdge': return fn(graph, op.edge);
      default: return graph;
    }
  };
  const onEditStation = (op) => {
    setWorking((w) => (w ? applyOp(w, op) : w));
    setDirty(true);
  };

  // Close the station modal; a dirty working copy saves on close (one version
  // per modal session). Stale/locked failures surface via the lineError banner.
  const closeStationModal = () => {
    setSelectedStationId(null);
    if (!dirty || saveState === 'saving') return;
    setSaveState('saving');
    window.Api.saveLineGraph(draftSlug, working, baseVersion)
      .then((res) => {
        setLineData((d) => {
          if (!d || !d.lineSetup) return d;
          const v = { version: res.version, hash: res.hash, validation: res.validation, graph: working, createdBy: 'operator' };
          return { ...d, lineSetup: { ...d.lineSetup, versions: [...(d.lineSetup.versions || []), v] } };
        });
        setSaveState('idle');
      })
      .catch((e) => {
        if (e && e.status === 409 && e.body && e.body.error === 'stale') setStale({ latest: e.body.latest });
        setSaveState('error');
        setLineError((e && e.message) || 'Could not save the station edits.');
      });
  };

  // Reload after a stale save: adopt the server's latest wholesale.
  const reloadLine = () => {
    window.Api.getLineSetup(draftSlug).then((d) => {
      setLineData(d);
      const vs = d && d.lineSetup && Array.isArray(d.lineSetup.versions) ? d.lineSetup.versions : [];
      const lt = vs.length ? vs[vs.length - 1] : null;
      if (lt) { setWorking(JSON.parse(JSON.stringify(lt.graph))); setBaseVersion(lt.version); }
      setDirty(false); setStale(null); setLineError(null); setSaveState('idle');
    }).catch(() => {});
  };

  // Derived props for the canvas's stations section.
  const lineGraph = working || (latestLine && latestLine.graph) || null;
  const proposalError = (lineSetup && lineSetup.proposal && lineSetup.proposal.status === 'errored')
    ? (lineSetup.proposal.error || 'The proposal failed.') : null;
  const lineErrors = (latestLine && latestLine.validation && latestLine.validation.errors) || [];
  const lineWarnings = (latestLine && latestLine.validation && latestLine.validation.warnings) || [];
  const lineErrorsByNode = {};
  for (const e of lineErrors) if (e && e.nodeId) lineErrorsByNode[e.nodeId] = (lineErrorsByNode[e.nodeId] || 0) + 1;
  const lineIssuesByNode = dirty && lineGraph ? window.GraphEdit.quickIssues(lineGraph) : lineErrorsByNode;
  const lineLocked = !!(lineData && (lineData.status === 'live' || lineData.status === 'launching' || lineData.status === 'launch_failed'));

  // Per-section staleness/regenerating flags from the advisory payload: plan → the
  // timeline section, line → the stations section. (The brief is the chain root — no
  // upstream hash and no proposal producer — so its flags stay false.) planRegenerating
  // rides the plan ledger's running proposal; the line's already surfaces via proposalRunning.
  const planStale = !!(staleness && staleness.plan);
  const planRegenerating = !!(staleness && staleness.planRegenerating);
  const lineStale = !!(staleness && staleness.line);

  const stationGraph = lineActive ? {
    graph: lineGraph,
    issuesByNode: lineIssuesByNode,
    selectedId: selectedStationId,
    proposalRunning,
    revising: busy && phase === 'line_setup',
    stale: lineStale,
    error: proposalError,
    onRetry: () => window.Api.generateLineProposal(draftSlug).catch((e) => setLineError((e && e.message) || 'Could not restart the proposal')),
  } : null;
  const lineChip = latestLine ? (lineErrors.length > 0
    ? { cls: 'bg-red-100 text-red-700', text: `${lineErrors.length} error${lineErrors.length === 1 ? '' : 's'} · ${lineWarnings.length} warning${lineWarnings.length === 1 ? '' : 's'}` }
    : lineWarnings.length > 0
      ? { cls: 'bg-amber-100 text-amber-700', text: `0 errors · ${lineWarnings.length} warning${lineWarnings.length === 1 ? '' : 's'}` }
      : { cls: 'bg-emerald-100 text-emerald-700', text: 'Ready to launch' }) : null;
  // A disabled approve button must say WHY (a silent grey button dead-ends the
  // operator — 2026-08-11). Mirrors the gate button's disabled expression.
  const lineBlockReason = busy ? 'Waiting for the agent to finish…'
    : dirty ? 'Unsaved station edits — save or discard them first'
    : proposalRunning ? 'A line proposal is still generating…'
    : !latestLine ? 'No line version yet'
    : lineErrors.length > 0 ? 'Fix the validation errors before approving'
    : null;

  // Prefer the latest brief_artifact event, but fall back to session.brief — the
  // canonical field (kept fresh by the session poll / gate responses) — so both
  // the standalone brief pane and the on-canvas brief section still show it if the
  // event was pruned or missed. Hoisted above the surface-broadcast effect so
  // hasBrief can feed the header's section buttons.
  const latestBriefEvent = events.filter((e) => e.kind === 'brief_artifact').slice(-1)[0];
  const briefDoc = (latestBriefEvent && latestBriefEvent.brief) || (session && session.brief) || null;
  // Once the board exists the brief lives on the canvas as a world-layer section
  // (pre-draft phases keep the standalone brief pane below). stale/regenerating
  // are placeholders here — the staleness/proposal transport wires them next.
  const briefSection = hasBoard && briefDoc ? { markdown: briefDoc.markdown, stale: false, regenerating: false, readOnly: lineLocked } : null;

  // The plan re-review affordance: the latest plan gate that is a kind:"rereview" with
  // no decision AND not since resolved by a later plan-gate decision (mirrors ChatPanel's
  // open/resolved rule). It surfaces ONLY on the canvas plan section (ChatPanel dedupes it).
  const planRereview = (() => {
    const planGates = events.filter((e) => e.kind === 'gate' && e.gate && e.gate.id === 'plan');
    const open = [...planGates].reverse().find((e) => e.gate.kind === 'rereview' && !e.gate.decision);
    if (!open) return null;
    return planGates.some((e) => e.seq > open.seq && e.gate.decision) ? null : open;
  })();

  // Plan (timeline) section state fed to the canvas. Approve acknowledges via the plan
  // gate (the a4 ack branch); Revise targets the plan step for the next chat message.
  const timelineState = {
    stale: planStale,
    regenerating: planRegenerating,
    rereview: planRereview,
    onApproveRereview: () => onGate('plan', 'approve'),
    onReviseRereview: () => setChatTarget('plan'),
  };

  React.useEffect(() => {
    window.dispatchEvent(new CustomEvent('al-create-surface', { detail: { hasBoard, hasBrief: !!briefSection, hasTimeline: !!timelineGantt, hasStations: !!stationGraph, stationsBusy: proposalRunning || (busy && phase === 'line_setup') } }));
    return () => window.dispatchEvent(new CustomEvent('al-create-surface', { detail: { hasBoard: false, hasBrief: false, hasTimeline: false, hasStations: false, stationsBusy: false } }));
  }, [hasBoard, !!briefSection, !!timelineGantt, !!stationGraph, proposalRunning, busy && phase === 'line_setup']);

  if (loadState === 'loading') return <div className="p-16 text-center text-neutral-400">Loading…</div>;

  // Setup comes FIRST: a brand-new session (still unnamed, nothing said yet) shows
  // the naming step instead of dropping the user into an untitled chat. Keyed on
  // "no events" as well as the name so an in-flight interview can never be sent
  // back to setup by a session that merely kept the default name.
  const needsSetup = loadState === 'ready'
    && events.length === 0
    && (!session || !session.name || session.name === 'Untitled project');
  if (needsSetup) {
    return (
      <CreateSetupStep
        sessionId={sessionId}
        initialName={session && session.name}
        // The Root owns the project list the switcher renders; tell it to refetch so
        // the new name appears in the menu straight away rather than on next load.
        onDone={(s) => { applyFullSession(s); window.dispatchEvent(new Event('al-projects-changed')); }}
      />
    );
  }
  if (loadState === 'notfound') return <CreateNotFound />;
  if (loadState === 'error') {
    return (
      <div className="max-w-xl mx-auto p-16 text-center">
        <div className="text-lg font-semibold text-neutral-600 mb-1">Could not load this project draft</div>
        <button onClick={() => window.navigate('/projects/new')} className="mt-4 px-4 py-2 rounded-lg font-semibold text-sm bg-neutral-800 text-white hover:bg-neutral-900">← Back</button>
      </div>
    );
  }

  const contextItems = events.filter((e) => e.kind === 'attachment' && e.attachment).map((e) => e.attachment);

  const canvas = briefMode ? (
    <div className="h-full overflow-y-auto p-6">
      {briefDoc ? (
        <div className="max-w-2xl mx-auto rounded-2xl border border-neutral-200 bg-white overflow-hidden shadow-sm">
          <div className="px-4 py-3 border-b border-neutral-200 text-xs font-bold uppercase tracking-widest text-neutral-500">
            {briefDoc.name || name}
          </div>
          <div className="px-4 py-3">
            <window.MarkdownView markdown={briefDoc.markdown} />
          </div>
        </div>
      ) : (
        <div className="h-full flex items-center justify-center">
          <div className="max-w-md text-center rounded-2xl border border-dashed border-neutral-300 p-10">
            <div className="text-neutral-600 font-semibold mb-1">Your brief will appear here</div>
            <div className="text-sm text-neutral-400">Answer the questions on the right and we'll assemble a project brief for your review.</div>
          </div>
        </div>
      )}
    </div>
  ) : view !== 'board' ? (
    // Brief / Timeline / Stations are PAGES, not canvas sections: they are read
    // (or clicked through) top-to-bottom, and panning a canvas to read markdown
    // was the thing that made the old workspace tiring. The board keeps the
    // canvas because it genuinely is a spatial artifact.
    <CreateDocView
      view={view}
      name={name}
      briefDoc={briefDoc}
      timelineGantt={timelineGantt}
      timelineState={timelineState}
      stationGraph={stationGraph}
      onSelectStation={setSelectedStationId}
      onTargetChat={setChatTarget}
    />
  ) : (
    <div className="h-full min-h-0 overflow-auto">
      {!draftSlug ? (
        <div className="p-8 text-sm text-neutral-400">The board appears when planning starts.</div>
      ) : (
        // Board ONLY: brief/timeline/stations are deliberately not passed, so the
        // canvas renders the board alone (each section is null-guarded there).
        <window.AssemblyLineApp key={draftSlug} project={{ slug: draftSlug, name }} onSelectStation={setSelectedStationId} onTargetChat={setChatTarget} />
      )}
    </div>
  );

  return (
    <div className={`grid grid-cols-1 ${contextOpen ? 'lg:grid-cols-[1fr_18rem_32rem]' : 'lg:grid-cols-[1fr_32rem]'} min-h-0`} style={{ height: `calc(100vh - ${headerH}px)` }}>
      <section className="min-w-0 min-h-0 overflow-hidden border-r border-neutral-200 bg-neutral-50">{canvas}</section>
      {contextOpen && (
        <aside className="min-w-0 min-h-0 border-r border-neutral-200 hidden lg:block">
          <window.ContextSidebar
            items={contextItems}
            attaching={attaching}
            onAttachFiles={onAttach}
            onLinkFolder={onLinkFolder}
            onAddLink={onAddLink}
            onClose={() => setContextOpen(false)}
          />
        </aside>
      )}
      <aside className="min-w-0 min-h-0 flex flex-col bg-white">
        <div className="px-4 py-3 border-b border-neutral-200 flex items-center justify-between gap-3">
          <span className="font-bold text-neutral-800 truncate">{name}</span>
          <span className="text-[10px] font-bold uppercase tracking-widest text-neutral-400 shrink-0">{CP_PHASE_LABEL[phase] || phase}</span>
        </div>
        {pageError && (
          <div className="mx-3 mt-3 rounded-lg bg-red-50 border border-red-200 p-3 text-sm text-red-800 flex items-start justify-between gap-3">
            <span>{(pageError && pageError.message) || 'Could not attach those materials.'}</span>
            <button onClick={() => setPageError(null)} className="text-red-500 hover:text-red-700 shrink-0 font-semibold">✕</button>
          </div>
        )}
        <div className="flex-1 min-h-0">
          <window.ChatPanel events={events} phase={phase} busy={busy || attaching} streamingText={streamingText} onSend={onSend} onGate={onGate} contextCount={contextItems.length} onToggleContext={() => setContextOpen((v) => !v)} target={chatTarget} onClearTarget={() => setChatTarget(null)} onOpenBrief={briefDoc ? () => window.navigate(`/projects/new/${encodeURIComponent(sessionId)}/brief`) : null} />
        </div>
        {phase === 'reviewing' && (
          <div className="border-t border-emerald-200 bg-emerald-50 p-3 flex items-center justify-between gap-3">
            <span className="text-sm text-emerald-800">Happy with the plan? Approving saves it as a draft project.</span>
            <button
              onClick={() => onGate('plan', 'approve')}
              disabled={busy}
              className="px-4 py-2 text-sm font-semibold rounded-lg bg-emerald-600 text-white hover:bg-emerald-700 disabled:opacity-50 shrink-0"
            >Approve plan</button>
          </div>
        )}
        {phase === 'errored' && (
          <div className="border-t border-red-200 bg-red-50 p-3 flex items-center justify-between gap-3">
            <span className="text-sm text-red-800">Something went wrong on the last step.</span>
            <button
              onClick={onRetry}
              disabled={busy}
              className="px-4 py-2 text-sm font-semibold rounded-lg bg-red-600 text-white hover:bg-red-700 disabled:opacity-50 shrink-0"
            >Retry</button>
          </div>
        )}
        {phase === 'line_setup' && (
          <div className="border-t border-indigo-200 bg-indigo-50 p-3 flex items-center justify-between gap-3">
            <span className="text-sm text-indigo-800 flex items-center gap-2">
              {lineChip && <span className={`rounded-full px-2.5 py-0.5 text-xs font-bold ${lineChip.cls}`}>{lineChip.text}</span>}
              {stale && <button onClick={reloadLine} className="text-xs font-bold underline">Newer version — reload</button>}
              {lineBlockReason && <span className="text-xs text-indigo-700">{lineBlockReason}</span>}
              {lineError && <span className="text-xs text-red-700">{lineError}</span>}
            </span>
            <button
              onClick={() => onGate('line', 'approve')}
              disabled={busy || dirty || proposalRunning || !latestLine || lineErrors.length > 0}
              className="px-4 py-2 text-sm font-semibold rounded-lg bg-indigo-600 text-white hover:bg-indigo-700 disabled:opacity-50 shrink-0"
            >Approve line · Continue to Launch</button>
          </div>
        )}
        {phase === 'done' && launchStatus === 'live' && (
          <div className="border-t border-emerald-200 bg-emerald-50 p-3 flex items-center justify-between gap-3">
            <span className="text-sm text-emerald-800">Launched. The line is running.</span>
            <button
              onClick={() => draftSlug && window.navigate(`/projects/${draftSlug}`)}
              disabled={!draftSlug}
              className="px-4 py-2 text-sm font-semibold rounded-lg bg-emerald-600 text-white hover:bg-emerald-700 disabled:opacity-50 shrink-0"
            >Open the live board →</button>
          </div>
        )}
        {phase === 'done' && launchStatus !== 'live' && (
          <div className="border-t border-indigo-200 bg-indigo-50 p-3 flex items-center justify-between gap-3">
            <span className="text-sm text-indigo-800">Plan approved. Next: design the station line.</span>
            <button
              onClick={() => draftSlug && window.navigate(`/projects/${draftSlug}/line`)}
              disabled={!draftSlug}
              className="px-4 py-2 text-sm font-semibold rounded-lg bg-indigo-600 text-white hover:bg-indigo-700 disabled:opacity-50 shrink-0"
            >Set up line →</button>
          </div>
        )}
      </aside>
      {selectedStationId && lineGraph && (() => {
        const node = (lineGraph.nodes || []).find((n) => n.id === selectedStationId);
        if (!node) return null;
        return (
          <window.StationModal
            node={node}
            template={templates.find((t) => t.id === node.templateId)}
            templates={templates}
            features={(lineData && lineData.plan && lineData.plan.features) || []}
            edges={lineGraph.edges || []}
            nodes={lineGraph.nodes || []}
            // Always read-only: the modal is an inspector. Editing a line goes
            // through the line chat (the reviser), which keeps one write path,
            // versioned graphs, and no confusing covers checkboxes / dirty
            // working-copy state blocking the approve gate.
            readOnly={true}
            onClose={closeStationModal}
          />
        );
      })()}
    </div>
  );
};

const CreateProjectPage = ({ sessionId, view }) =>
  sessionId
    ? <CreateSessionWorkspace key={sessionId} sessionId={sessionId} view={view} />
    : <CreateSessionPicker />;

if (typeof window !== 'undefined') {
  window.CreateProjectPage = CreateProjectPage;
}
