summaryrefslogblamecommitdiff
path: root/makima/frontend/src/routes/document-directives.tsx
blob: b583beffccd6a62f76d137b000967828d863dddb (plain) (tree)
1
2
3
4
5
6
7

                                                                          



                                                                         
        











                               
                    







                                                                             
                           


                           




















                                                                                

  






















                                                                              





































































                                                                                                                                                                                                                                            



                                                                               

                                                                                

                            
                        
























                                                                                

 



                          
                
            



                          
                                                                    
                                                             
 




                                                                               
 

                                                                           
 

                                                                        
 











                                                                                
 
                                                                           

                      





































                                                                                 
 
          









                                                                                                                                          
       




                                                                                  
          













                                                                                                  
 
                                                     
                










                                                                                  
 





                                                                                  

                             



                                                                                                                                                                        
               

                                                                   

                       



































































                                                                                                                                                       
                
               
            
              




          

































































































                                                                                                                            

                                                          






                                                                        

                     








                                                                        
 















                                                                          


          






























                                                                                                                                                        
              
        



          


























                                                                             

 

                                                             
          














                                                                                                                        


    


                        

 





















                                                                                                                        

 



                                                                                



                                     



                                                                          

 



                          



                    
                  




                                                                          
      

                                      
     




                                                                           
       
               

                   










                                                                                    
                   










                                                                                      





                                                                                                                         
                   





                                                               
                                            

                                                                                                  
                                









                                                                                      
                             

                








































                                                                                                                                                    







                                                                                




                                                                                 


                                                                                
                                     

                         

                                                                          

 
                      
            

                
                    
                      






                                                                              

              
                              


                 



















































                                                                                
 

                                                                             



                                                                      
                                    
                           

                                                                    




            
                                       

                                                                      
                                                                                    






                                                                      
                                                                                   



            







                                                                                     
     





                                                                          
     





                                                                                    
     
 










                                                                              
 






























                                                                                                                
 
                       





                                                                              









                                       


























                                                                                                              










                                                                                  
                                                               
                                                            



















                                                                                       






                                                                 




















                                                                             
 



























                                                                                    
 


                                                                
      
               

    






                                                                            
      
                                 

    








                                                                     
      
                            

    











                                                                             







                                                                                       
                          

                                                               
                                                                                                                                             


                                   
                                 



                                                     


              
                                     
                    
                               

                                               
                                         




             
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useNavigate, useParams, useSearchParams } from "react-router";
import { Masthead } from "../components/Masthead";
import { useDirective, useDirectives } from "../hooks/useDirectives";
import { useAuth } from "../contexts/AuthContext";
import { DocumentEditor } from "../components/directives/DocumentEditor";
import {
  type DirectiveSummary,
  type DirectiveStatus,
  type DirectiveDocument,
  type DirectiveDocumentStatus,
  type DirectiveStep,
  type Task,
  type DocumentTasksResponse,
  listDirectiveDocuments,
  createDirectiveDocument,
  getDirectiveDocument,
  updateDirectiveDocument,
  listDirectiveDocumentTasks,
} from "../lib/api";

// Status dot color, matching the existing tabular UI's badge palette so the
// document mode feels like a sibling of the existing list, not a foreign UI.
const STATUS_DOT: Record<DirectiveStatus, string> = {
  draft: "bg-[#556677]",
  active: "bg-green-400",
  idle: "bg-yellow-400",
  paused: "bg-orange-400",
  inactive: "bg-[#75aafc]",
  archived: "bg-[#3a4a6a]",
};

// Per-document status palette. Active/draft documents use the same bright
// green-ish accent as a running directive; shipped/archived use a muted blue.
const DOC_STATUS_DOT: Record<DirectiveDocumentStatus, string> = {
  draft: "bg-[#556677]",
  active: "bg-green-400",
  shipped: "bg-[#75aafc]",
  archived: "bg-[#3a4a6a]",
};

// =============================================================================
// Sidebar grouping — group directives by lifecycle stage so the file tree
// reads like a folder per status. We collapse the noisy ones (Archived) by
// default and keep Active / Idle expanded.
// =============================================================================

type SidebarGroup = "active" | "idle" | "archived";

const GROUP_LABEL: Record<SidebarGroup, string> = {
  active: "active",
  idle: "idle",
  archived: "archived",
};

function bucketOf(status: DirectiveStatus): SidebarGroup {
  if (status === "active" || status === "paused") return "active";
  if (status === "archived") return "archived";
  // draft + idle land in the idle bucket (i.e. "not currently running").
  return "idle";
}

// Slugify a document title for the displayed `.md` filename, falling back to
// the directive title and finally the document id slice when the title is
// empty. Mirrors the file-naming fix from step 1 (use the user-readable label
// rather than just an id slice). Accepts either a DirectiveSummary or a full
// DirectiveWithSteps — only `title` is read.
function fileLabel(
  doc: DirectiveDocument,
  directive: { title: string },
): string {
  const docTitle = doc.title.trim();
  if (docTitle.length > 0) return docTitle;
  const dirTitle = directive.title.trim();
  if (dirTitle.length > 0) return dirTitle;
  return doc.id.slice(0, 8);
}

// =============================================================================
// Sidebar icons (inline SVG, no new deps)
// =============================================================================

function FolderIcon({ open = false }: { open?: boolean }) {
  return (
    <svg
      viewBox="0 0 16 16"
      width={12}
      height={12}
      className="shrink-0"
      aria-hidden
    >
      {open ? (
        <path
          d="M1.5 3.5a1 1 0 0 1 1-1h3.382a1 1 0 0 1 .894.553l.448.894a1 1 0 0 0 .894.553H13.5a1 1 0 0 1 1 1V6H1.5V3.5z M1 6.5h13.382a.5.5 0 0 1 .49.598l-.9 5A.5.5 0 0 1 13.482 12.5H2.518a.5.5 0 0 1-.49-.402l-.9-5A.5.5 0 0 1 1.62 6.5H1z"
          fill="#75aafc"
          opacity="0.85"
        />
      ) : (
        <path
          d="M1.5 4a1 1 0 0 1 1-1h3.382a1 1 0 0 1 .894.553l.448.894a1 1 0 0 0 .894.553H13.5a1 1 0 0 1 1 1V12a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1V4z"
          fill="#75aafc"
          opacity="0.65"
        />
      )}
    </svg>
  );
}

function FileIcon() {
  return (
    <svg
      viewBox="0 0 16 16"
      width={12}
      height={12}
      className="shrink-0"
      aria-hidden
    >
      <path
        d="M3 1.5h6.293a1 1 0 0 1 .707.293l3 3A1 1 0 0 1 13.293 5.5H13V14a.5.5 0 0 1-.5.5h-9A.5.5 0 0 1 3 14V1.5z"
        fill="none"
        stroke="#9bc3ff"
        strokeWidth="1"
      />
      <path
        d="M9.5 1.5v3h3"
        fill="none"
        stroke="#9bc3ff"
        strokeWidth="1"
      />
    </svg>
  );
}

function Caret({ open }: { open: boolean }) {
  return (
    <svg
      viewBox="0 0 8 8"
      width={8}
      height={8}
      className={`shrink-0 transition-transform ${open ? "rotate-90" : ""}`}
      aria-hidden
    >
      <path d="M2 1l4 3-4 3z" fill="#7788aa" />
    </svg>
  );
}

// =============================================================================
// SidebarSelection — exactly one of taskId/documentId is non-null. taskId is
// reserved for a future "task selection" feature (we expose it in the URL
// already so the param shape is stable). documentId picks one of the
// directive's documents.
// =============================================================================

interface SidebarSelection {
  directiveId: string;
  taskId: string | null;
  documentId: string | null;
}

// =============================================================================
// Per-directive folder — renders as a collapsible folder containing the
// directive's documents. Loads documents lazily on first open (mirroring the
// pattern from step 1's DirectiveFolder, which fetched the full directive
// only when expanded).
// =============================================================================

interface DirectiveFolderProps {
  directive: DirectiveSummary;
  open: boolean;
  onToggle: () => void;
  /** Called when the user clicks the folder header itself (after toggle). */
  onHeaderClick: () => void;
  selection: SidebarSelection | null;
  onSelectDocument: (directiveId: string, doc: DirectiveDocument) => void;
  onCreateDocument: (directive: DirectiveSummary) => Promise<void>;
  /**
   * Document refresh trigger — bumped externally so the folder refetches its
   * document list after a create/update happens elsewhere. Primarily used so
   * a freshly-created document shows up immediately.
   */
  refreshNonce: number;
}

function DirectiveFolder({
  directive,
  open,
  onToggle,
  onHeaderClick,
  selection,
  onSelectDocument,
  onCreateDocument,
  refreshNonce,
}: DirectiveFolderProps) {
  const dotColor = STATUS_DOT[directive.status] ?? STATUS_DOT.draft;
  const orchestratorRunning = !!directive.orchestratorTaskId;

  // Documents fetched lazily on open. We deliberately scope the fetch to the
  // open-state so closed folders don't pay the network cost on initial render.
  const [docs, setDocs] = useState<DirectiveDocument[] | null>(null);
  const [docsLoading, setDocsLoading] = useState(false);
  const [docsError, setDocsError] = useState<string | null>(null);

  // shipped/ subfolder open state — independent of the directive folder.
  const [shippedOpen, setShippedOpen] = useState(false);

  // Whether a "+ New document" call is in flight (disables the button).
  const [creating, setCreating] = useState(false);

  const refresh = useCallback(async () => {
    setDocsLoading(true);
    setDocsError(null);
    try {
      const list = await listDirectiveDocuments(directive.id);
      setDocs(list);
    } catch (e) {
      setDocsError(e instanceof Error ? e.message : "Failed to load documents");
    } finally {
      setDocsLoading(false);
    }
  }, [directive.id]);

  // Fetch on open; refetch when refreshNonce bumps and the folder is open.
  useEffect(() => {
    if (!open) return;
    void refresh();
  }, [open, refresh, refreshNonce]);

  // Split the documents into the two visual groups. Memoised so we don't
  // recompute on every render.
  const { activeDocs, shippedDocs } = useMemo(() => {
    const active: DirectiveDocument[] = [];
    const shipped: DirectiveDocument[] = [];
    for (const d of docs ?? []) {
      if (d.status === "shipped" || d.status === "archived") {
        shipped.push(d);
      } else {
        active.push(d);
      }
    }
    // Stable order: by createdAt ascending so the first row is the oldest doc.
    active.sort((a, b) => a.createdAt.localeCompare(b.createdAt));
    shipped.sort((a, b) => (b.shippedAt ?? "").localeCompare(a.shippedAt ?? ""));
    return { activeDocs: active, shippedDocs: shipped };
  }, [docs]);

  const handleCreate = useCallback(async () => {
    if (creating) return;
    setCreating(true);
    try {
      await onCreateDocument(directive);
      // Refresh after creating so the new doc appears in the list.
      await refresh();
    } finally {
      setCreating(false);
    }
  }, [creating, onCreateDocument, directive, refresh]);

  // Selection helpers — used to highlight the currently-selected doc row.
  const selectedDocumentId =
    selection && selection.directiveId === directive.id
      ? selection.documentId
      : null;

  return (
    <div className="select-none">
      {/* Directive folder header */}
      <button
        type="button"
        onClick={() => {
          onToggle();
          onHeaderClick();
        }}
        title={directive.title}
        className="w-full flex items-center gap-1.5 pl-9 pr-3 py-1 font-mono text-[11px] text-[#9bc3ff] hover:bg-[rgba(117,170,252,0.06)]"
      >
        <Caret open={open} />
        <FolderIcon open={open} />
        <span
          className={`inline-block w-1.5 h-1.5 rounded-full shrink-0 ${dotColor}`}
          aria-hidden
        />
        <span className="truncate flex-1 text-left">
          {directive.title.trim().length > 0
            ? directive.title
            : directive.id.slice(0, 8)}
          /
        </span>
        {orchestratorRunning && (
          <span
            className="inline-block w-1.5 h-1.5 rounded-full shrink-0 bg-yellow-400 animate-pulse"
            title="Orchestrator running"
            aria-label="Orchestrator running"
          />
        )}
      </button>

      {/* Folder body — rendered only when open */}
      {open && (
        <div className="py-0.5">
          {docsLoading && !docs && (
            <div className="pl-14 pr-3 py-1 font-mono text-[10px] text-[#556677]">
              Loading documents…
            </div>
          )}
          {docsError && (
            <div className="pl-14 pr-3 py-1 font-mono text-[10px] text-red-400">
              {docsError}
            </div>
          )}

          {/* Active group */}
          {docs && (
            <>
              {/* + New document affordance — sits at the top of the active list
                  so the user can always reach it without scrolling past
                  existing docs. */}
              <button
                type="button"
                onClick={handleCreate}
                disabled={creating}
                className="w-full flex items-center gap-1.5 pl-14 pr-3 py-1 font-mono text-[11px] text-emerald-400 hover:bg-[rgba(74,222,128,0.06)] disabled:opacity-50"
                title="Create a new document under this directive"
              >
                <span className="text-[12px] leading-none">+</span>
                <span>New document</span>
              </button>

              {activeDocs.length === 0 && !docsLoading && (
                <div className="pl-14 pr-3 py-1 font-mono text-[10px] text-[#556677] italic">
                  no active documents
                </div>
              )}

              {activeDocs.map((doc) => (
                // Each active document gets its own tasks/ subfolder
                // immediately below it. Active docs default-open the
                // folder so the user sees their live work without an
                // extra click.
                <div key={doc.id}>
                  <DocumentRow
                    doc={doc}
                    directive={directive}
                    selected={doc.id === selectedDocumentId}
                    onSelect={() => onSelectDocument(directive.id, doc)}
                  />
                  <DocumentTasksFolder
                    documentId={doc.id}
                    depth="normal"
                    defaultOpen={doc.status === "active"}
                    refreshNonce={refreshNonce}
                  />
                </div>
              ))}

              {/* shipped/ subfolder — only rendered when at least one shipped
                  or archived doc exists. Hidden entirely otherwise so empty
                  directives stay tidy. */}
              {shippedDocs.length > 0 && (
                <div>
                  <button
                    type="button"
                    onClick={() => setShippedOpen((v) => !v)}
                    className="w-full flex items-center gap-1.5 pl-14 pr-3 py-1 font-mono text-[11px] text-[#9bc3ff] hover:bg-[rgba(117,170,252,0.06)]"
                  >
                    <Caret open={shippedOpen} />
                    <FolderIcon open={shippedOpen} />
                    <span>shipped/</span>
                    <span className="ml-auto text-[10px] text-[#556677]">
                      {shippedDocs.length}
                    </span>
                  </button>
                  {shippedOpen &&
                    shippedDocs.map((doc) => (
                      // Shipped docs render the doc row + its frozen
                      // tasks/ subfolder. The tasks/ folder defaults
                      // closed (history) so it doesn't dominate the
                      // sidebar; users can click to inspect what work
                      // produced this shipped contract.
                      <div key={doc.id}>
                        <DocumentRow
                          doc={doc}
                          directive={directive}
                          selected={doc.id === selectedDocumentId}
                          onSelect={() => onSelectDocument(directive.id, doc)}
                          indent="deep"
                        />
                        <DocumentTasksFolder
                          documentId={doc.id}
                          depth="deep"
                          defaultOpen={false}
                          refreshNonce={refreshNonce}
                        />
                      </div>
                    ))}
                </div>
              )}
            </>
          )}
        </div>
      )}
    </div>
  );
}

// =============================================================================
// DocumentRow — one row inside a directive folder. The indent depth differs
// between active rows (one level deep) and shipped rows (two levels deep).
// =============================================================================

interface DocumentRowProps {
  doc: DirectiveDocument;
  directive: DirectiveSummary;
  selected: boolean;
  onSelect: () => void;
  indent?: "normal" | "deep";
}

function DocumentRow({
  doc,
  directive,
  selected,
  onSelect,
  indent = "normal",
}: DocumentRowProps) {
  const dot = DOC_STATUS_DOT[doc.status] ?? DOC_STATUS_DOT.draft;
  const padLeft = indent === "deep" ? "pl-[88px]" : "pl-14";
  const name = `${fileLabel(doc, directive)}.md`;

  return (
    <button
      type="button"
      onClick={onSelect}
      title={name}
      className={`w-full text-left flex items-center gap-1.5 ${padLeft} pr-3 py-1 font-mono text-[11px] transition-colors ${
        selected
          ? "bg-[rgba(117,170,252,0.12)] text-white border-l-2 border-[#75aafc]"
          : "text-[#9bc3ff] hover:bg-[rgba(117,170,252,0.06)] border-l-2 border-transparent"
      }`}
    >
      <FileIcon />
      <span
        className={`inline-block w-1.5 h-1.5 rounded-full shrink-0 ${dot}`}
        aria-hidden
        title={doc.status}
      />
      <span className="truncate flex-1">{name}</span>
      {/* Status chip — only shown for non-active states so the row stays
          uncluttered for the common case. */}
      {doc.status !== "active" && (
        <span className="text-[9px] uppercase tracking-wide text-[#556677]">
          {doc.status}
        </span>
      )}
      {/* PR badge for shipped docs. The link short-circuits the row's
          onClick so clicking the PR doesn't also re-select the doc. */}
      {doc.prUrl && (doc.status === "shipped" || doc.status === "archived") && (
        <a
          href={doc.prUrl}
          target="_blank"
          rel="noreferrer noopener"
          onClick={(e) => e.stopPropagation()}
          className="text-[9px] text-[#75aafc] hover:text-white border border-[#2a3a5a] rounded px-1"
          title={doc.prUrl}
        >
          PR
        </a>
      )}
    </button>
  );
}

// =============================================================================
// Per-document tasks/ subfolder — fetches the steps + ephemeral tasks for a
// single document and renders a collapsible `tasks/` row beneath the
// document. Lazy: fetch only fires once the user opens the folder, and we
// also keep the folder closed by default for shipped docs (where it's
// historical) and open by default for active docs (where it's live work).
// =============================================================================

interface DocumentTasksFolderProps {
  documentId: string;
  /** Visual indent depth — mirrors the parent DocumentRow's indent so the
   *  tasks/ row sits one level deeper than its parent doc. */
  depth: "normal" | "deep";
  /** Whether to fetch+open by default. Active docs default to open so the
   *  user sees their live tasks immediately; shipped docs default to closed
   *  (historical), and the user can click to expand. */
  defaultOpen: boolean;
  /** Bumped externally so the folder refetches its task list after a save
   *  or status change elsewhere. Same nonce used for the directive folder. */
  refreshNonce: number;
}

function DocumentTasksFolder({
  documentId,
  depth,
  defaultOpen,
  refreshNonce,
}: DocumentTasksFolderProps) {
  const [open, setOpen] = useState(defaultOpen);
  const [data, setData] = useState<DocumentTasksResponse | null>(null);
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState<string | null>(null);

  // Inner row indent is one level deeper than the folder header. Folder
  // header uses pl-[88px] (deep) or pl-14 (normal); tasks rows go one
  // step beyond that.
  const headerPadLeft = depth === "deep" ? "pl-[88px]" : "pl-14";
  const rowPadLeft = depth === "deep" ? "pl-[112px]" : "pl-[72px]";

  const refresh = useCallback(async () => {
    setLoading(true);
    setError(null);
    try {
      const res = await listDirectiveDocumentTasks(documentId);
      setData(res);
    } catch (e) {
      setError(e instanceof Error ? e.message : "Failed to load tasks");
    } finally {
      setLoading(false);
    }
  }, [documentId]);

  // Fetch when the folder is open (initial open or refresh). We don't
  // pre-fetch on closed folders so we don't waste bandwidth on the long
  // tail of historical shipped docs the user never expands.
  useEffect(() => {
    if (!open) return;
    void refresh();
  }, [open, refresh, refreshNonce]);

  const total = (data?.steps.length ?? 0) + (data?.tasks.length ?? 0);

  // Don't render the folder at all if we've fetched and the document has
  // no tasks. This is the cleanest visual: a draft document just shows up
  // as a single row with no children. The empty-folder check is gated on
  // a successful fetch so we don't flash "no tasks/" rows during loading.
  if (data && total === 0 && !loading && !error) {
    return null;
  }

  return (
    <div>
      <button
        type="button"
        onClick={() => setOpen((v) => !v)}
        className={`w-full flex items-center gap-1.5 ${headerPadLeft} pr-3 py-1 font-mono text-[11px] text-[#7788aa] hover:bg-[rgba(117,170,252,0.06)]`}
      >
        <Caret open={open} />
        <FolderIcon open={open} />
        <span>tasks/</span>
        {total > 0 && (
          <span className="ml-auto text-[10px] text-[#556677]">{total}</span>
        )}
      </button>
      {open && (
        <div className="py-0.5">
          {loading && !data && (
            <div className={`${rowPadLeft} pr-3 py-1 font-mono text-[10px] text-[#556677]`}>
              Loading tasks…
            </div>
          )}
          {error && (
            <div className={`${rowPadLeft} pr-3 py-1 font-mono text-[10px] text-red-400`}>
              {error}
            </div>
          )}
          {data?.steps.map((step) => (
            <StepRow key={`step-${step.id}`} step={step} padLeft={rowPadLeft} />
          ))}
          {data?.tasks.map((task) => (
            <TaskRow key={`task-${task.id}`} task={task} padLeft={rowPadLeft} />
          ))}
        </div>
      )}
    </div>
  );
}

// Step status → coloured dot, mirroring directive status palette so the
// sidebar reads consistently.
const STEP_STATUS_DOT: Record<string, string> = {
  pending: "bg-[#556677]",
  ready: "bg-[#9bc3ff]",
  running: "bg-yellow-400",
  completed: "bg-green-400",
  failed: "bg-red-400",
  skipped: "bg-[#3a4a6a]",
};

// Task status → coloured dot. Statuses come from the Task model; the small
// set we expect in directive context is enough — anything else falls back
// to the muted "draft" colour.
const TASK_STATUS_DOT: Record<string, string> = {
  pending: "bg-[#556677]",
  starting: "bg-yellow-400",
  running: "bg-yellow-400",
  completed: "bg-green-400",
  failed: "bg-red-400",
  cancelled: "bg-[#3a4a6a]",
  interrupted: "bg-orange-400",
};

interface StepRowProps {
  step: DirectiveStep;
  padLeft: string;
}

function StepRow({ step, padLeft }: StepRowProps) {
  const dot = STEP_STATUS_DOT[step.status] ?? "bg-[#556677]";
  return (
    <div
      title={`${step.name} (${step.status})`}
      className={`w-full text-left flex items-center gap-1.5 ${padLeft} pr-3 py-1 font-mono text-[11px] text-[#9bc3ff]`}
    >
      <FileIcon />
      <span
        className={`inline-block w-1.5 h-1.5 rounded-full shrink-0 ${dot}`}
        aria-hidden
        title={step.status}
      />
      <span className="truncate flex-1">{step.name}</span>
      <span className="text-[9px] uppercase tracking-wide text-[#556677]">
        step
      </span>
    </div>
  );
}

interface TaskRowProps {
  task: Task;
  padLeft: string;
}

function TaskRow({ task, padLeft }: TaskRowProps) {
  const dot = TASK_STATUS_DOT[task.status] ?? "bg-[#556677]";
  // Supervisor tasks get a small "sup" tag so the user can spot
  // contract orchestrators in the list.
  const isSup = task.isSupervisor;
  return (
    <div
      title={`${task.name} (${task.status})`}
      className={`w-full text-left flex items-center gap-1.5 ${padLeft} pr-3 py-1 font-mono text-[11px] text-[#9bc3ff]`}
    >
      <FileIcon />
      <span
        className={`inline-block w-1.5 h-1.5 rounded-full shrink-0 ${dot}`}
        aria-hidden
        title={task.status}
      />
      <span className="truncate flex-1">{task.name}</span>
      <span className="text-[9px] uppercase tracking-wide text-[#556677]">
        {isSup ? "sup" : "task"}
      </span>
    </div>
  );
}

// =============================================================================
// Sidebar
// =============================================================================

interface SidebarProps {
  directives: DirectiveSummary[];
  loading: boolean;
  selection: SidebarSelection | null;
  onSelectDocument: (directiveId: string, doc: DirectiveDocument) => void;
  onSelectDirective: (directiveId: string) => void;
  onCreateDocument: (directive: DirectiveSummary) => Promise<void>;
  refreshNonce: number;
}

function DocumentSidebar({
  directives,
  loading,
  selection,
  onSelectDocument,
  onSelectDirective,
  onCreateDocument,
  refreshNonce,
}: SidebarProps) {
  const groups: Record<SidebarGroup, DirectiveSummary[]> = useMemo(() => {
    const out: Record<SidebarGroup, DirectiveSummary[]> = {
      active: [],
      idle: [],
      archived: [],
    };
    for (const d of directives) {
      out[bucketOf(d.status)].push(d);
    }
    // Sort each group alphabetically so it feels like a stable file tree.
    (Object.keys(out) as SidebarGroup[]).forEach((k) => {
      out[k].sort((a, b) =>
        a.title.localeCompare(b.title, undefined, { sensitivity: "base" }),
      );
    });
    return out;
  }, [directives]);

  // Default-collapsed state per group folder.
  const [openGroups, setOpenGroups] = useState<Record<SidebarGroup, boolean>>({
    active: true,
    idle: true,
    archived: false,
  });

  // Per-directive open state. We auto-open the directive containing the
  // current selection so the user can see what they're editing.
  const [openDirectives, setOpenDirectives] = useState<Record<string, boolean>>({});

  useEffect(() => {
    if (!selection) return;
    setOpenDirectives((prev) =>
      prev[selection.directiveId] ? prev : { ...prev, [selection.directiveId]: true },
    );
  }, [selection?.directiveId]);

  const toggleGroup = (g: SidebarGroup) =>
    setOpenGroups((prev) => ({ ...prev, [g]: !prev[g] }));

  const toggleDirective = (id: string) =>
    setOpenDirectives((prev) => ({ ...prev, [id]: !prev[id] }));

  return (
    <div className="flex flex-col h-full">
      {/* Sidebar header */}
      <div className="flex items-center justify-between px-3 py-2 border-b border-dashed border-[rgba(117,170,252,0.2)]">
        <span className="text-[11px] font-mono text-[#9bc3ff] uppercase tracking-wide">
          Documents
        </span>
        <span className="text-[10px] font-mono text-[#556677]">
          {directives.length}
        </span>
      </div>

      {/* Top-level "directives/" folder */}
      <div className="flex items-center gap-1.5 px-3 py-1.5 text-[11px] font-mono text-[#9bc3ff]">
        <FolderIcon open />
        <span>directives/</span>
      </div>

      {/* Body */}
      <div className="flex-1 overflow-y-auto pb-4">
        {loading && directives.length === 0 ? (
          <div className="px-3 py-6 text-center text-[#556677] font-mono text-[11px]">
            Loading...
          </div>
        ) : directives.length === 0 ? (
          <div className="px-3 py-6 text-center text-[#556677] font-mono text-[11px]">
            No directives yet
          </div>
        ) : (
          (Object.keys(groups) as SidebarGroup[]).map((group) => {
            const list = groups[group];
            if (list.length === 0) return null;
            const open = openGroups[group];
            return (
              <div key={group} className="select-none">
                {/* Group header (sub-folder) */}
                <button
                  type="button"
                  onClick={() => toggleGroup(group)}
                  className="w-full flex items-center gap-1.5 pl-4 pr-3 py-1 font-mono text-[11px] text-[#9bc3ff] hover:bg-[rgba(117,170,252,0.05)]"
                >
                  <Caret open={open} />
                  <FolderIcon open={open} />
                  <span>{GROUP_LABEL[group]}/</span>
                  <span className="ml-auto text-[10px] text-[#556677]">
                    {list.length}
                  </span>
                </button>

                {/* Each directive is a folder containing N documents. */}
                {open && (
                  <div className="py-0.5">
                    {list.map((d) => (
                      <DirectiveFolder
                        key={d.id}
                        directive={d}
                        open={!!openDirectives[d.id]}
                        onToggle={() => toggleDirective(d.id)}
                        onHeaderClick={() => onSelectDirective(d.id)}
                        selection={selection}
                        onSelectDocument={onSelectDocument}
                        onCreateDocument={onCreateDocument}
                        refreshNonce={refreshNonce}
                      />
                    ))}
                  </div>
                )}
              </div>
            );
          })
        )}
      </div>
    </div>
  );
}

// =============================================================================
// Editor shell — wraps DocumentEditor and handles the "no document selected"
// and loading states. Two modes:
//   1) documentId selected → fetch the DirectiveDocument and edit doc.body via
//      updateDirectiveDocument (the call that auto-reactivates a shipped doc).
//   2) no documentId (legacy fallback, kept for the "select a directive but
//      not a document" transitional case) → edit directive.goal as before.
// =============================================================================

interface EditorShellProps {
  selection: SidebarSelection | null;
  hasDirectives: boolean;
  listLoading: boolean;
  /** Bumped after a successful document save so the sidebar refetches. */
  onDocumentChanged: () => void;
}

function EditorShell({
  selection,
  hasDirectives,
  listLoading,
  onDocumentChanged,
}: EditorShellProps) {
  const directiveId = selection?.directiveId;
  const documentId = selection?.documentId ?? null;

  // We deliberately don't pull `updateGoal` here — in the multi-document
  // world, edits flow through updateDirectiveDocument (which auto-reactivates
  // a shipped doc when its body changes). The legacy directive.goal is
  // unused on this surface.
  const {
    directive,
    loading: directiveLoading,
    cleanup,
    createPR,
    pickUpOrders,
  } = useDirective(directiveId);

  // Document fetch — only when documentId is selected. Refetched whenever the
  // id changes; not polled (the document stream is too low-traffic to warrant
  // background refresh in this iteration).
  const [doc, setDoc] = useState<DirectiveDocument | null>(null);
  const [docLoading, setDocLoading] = useState(false);
  const [docError, setDocError] = useState<string | null>(null);

  useEffect(() => {
    if (!documentId) {
      setDoc(null);
      setDocLoading(false);
      setDocError(null);
      return;
    }
    let cancelled = false;
    setDocLoading(true);
    setDocError(null);
    getDirectiveDocument(documentId)
      .then((d) => {
        if (cancelled) return;
        setDoc(d);
      })
      .catch((e) => {
        if (cancelled) return;
        setDocError(e instanceof Error ? e.message : "Failed to load document");
      })
      .finally(() => {
        if (cancelled) return;
        setDocLoading(false);
      });
    return () => {
      cancelled = true;
    };
  }, [documentId]);

  // Save callback for the document path. The backend re-stamps a shipped doc
  // back to active when its body changes, so we just optimistically update
  // local state with the server's response.
  const onUpdateDocumentBody = useCallback(
    async (body: string) => {
      if (!documentId) return;
      const updated = await updateDirectiveDocument(documentId, { body });
      setDoc(updated);
      // Tell the sidebar to refetch the directive's document list so the
      // status chip flips from `shipped` back to `active` (and any title
      // changes propagate). Cheap — folders only refetch when open.
      onDocumentChanged();
    },
    [documentId, onDocumentChanged],
  );

  // ---- Empty / error / loading states ------------------------------------
  if (!directiveId) {
    return (
      <div className="flex-1 flex items-center justify-center h-full">
        <p className="text-[#556677] font-mono text-[12px]">
          {listLoading
            ? "Loading documents..."
            : hasDirectives
            ? "Select a document from the sidebar"
            : "No directives yet — create one from the legacy UI"}
        </p>
      </div>
    );
  }

  if (directiveLoading && !directive) {
    return (
      <div className="flex-1 flex items-center justify-center h-full">
        <p className="text-[#556677] font-mono text-[12px]">Loading directive...</p>
      </div>
    );
  }

  if (!directive) {
    return (
      <div className="flex-1 flex items-center justify-center h-full">
        <p className="text-[#7788aa] font-mono text-[12px]">Directive not found</p>
      </div>
    );
  }

  // --- Document path: documentId selected --------------------------------
  if (documentId) {
    if (docLoading && !doc) {
      return (
        <div className="flex-1 flex items-center justify-center h-full">
          <p className="text-[#556677] font-mono text-[12px]">Loading document...</p>
        </div>
      );
    }
    if (docError) {
      return (
        <div className="flex-1 flex items-center justify-center h-full">
          <p className="text-red-400 font-mono text-[12px]">{docError}</p>
        </div>
      );
    }
    if (!doc) {
      return (
        <div className="flex-1 flex items-center justify-center h-full">
          <p className="text-[#7788aa] font-mono text-[12px]">Document not found</p>
        </div>
      );
    }

    // Synthesise a directive-shaped object whose `goal` is the document body.
    // DocumentEditor was originally written against DirectiveWithSteps, so we
    // can keep its shape by overriding `goal` with `doc.body` and `title`
    // with the document's filename label. The steps panel still draws from
    // the real directive (passed through StepsBlockContextProvider).
    const docTitle = `${fileLabel(doc, directive)}.md`;
    const directiveAsDocument = {
      ...directive,
      goal: doc.body,
      title: docTitle,
    };

    return (
      <div className="flex-1 flex flex-col h-full overflow-hidden">
        {/* Breadcrumb — directives / <directive title> / <document title>.md */}
        <div className="px-6 py-3 border-b border-dashed border-[rgba(117,170,252,0.2)]">
          <div className="flex items-center gap-2 text-[10px] font-mono uppercase tracking-wide text-[#7788aa]">
            <FileIcon />
            <span>directives /</span>
            <span className="text-[#9bc3ff]">
              {directive.title.trim().length > 0
                ? directive.title
                : directive.id.slice(0, 8)}
            </span>
            <span>/</span>
            <span className="text-white">{docTitle}</span>
            {doc.status === "shipped" && (
              <span className="ml-2 text-[#75aafc] normal-case">shipped</span>
            )}
            {doc.status === "archived" && (
              <span className="ml-2 text-[#7788aa] normal-case">archived</span>
            )}
            {doc.status === "draft" && (
              <span className="ml-2 text-[#556677] normal-case">draft</span>
            )}
            {!!directive.orchestratorTaskId && (
              <span className="ml-auto inline-flex items-center gap-1 text-yellow-400">
                <span className="inline-block w-1.5 h-1.5 rounded-full bg-yellow-400 animate-pulse" />
                orchestrator running
              </span>
            )}
          </div>
        </div>

        <DocumentEditor
          // Keying by document id ensures the Lexical editor remounts cleanly
          // when the user switches documents, so the previous doc's body
          // doesn't bleed into the new one.
          key={doc.id}
          directive={directiveAsDocument}
          onUpdateGoal={onUpdateDocumentBody}
          onCleanup={async () => {
            await cleanup();
          }}
          onCreatePR={async () => {
            await createPR();
          }}
          onPickUpOrders={async () => {
            await pickUpOrders();
          }}
        />
      </div>
    );
  }

  // --- Legacy fallback: directive selected but no document chosen --------
  // We only ever land here transiently while the page resolves the default
  // document selection, so we render a thin "loading" placeholder rather
  // than the full goal editor (which would be confusing alongside the new
  // multi-document model).
  return (
    <div className="flex-1 flex flex-col h-full overflow-hidden">
      <div className="px-6 py-3 border-b border-dashed border-[rgba(117,170,252,0.2)]">
        <div className="flex items-center gap-2 text-[10px] font-mono uppercase tracking-wide text-[#7788aa]">
          <FileIcon />
          <span>directives /</span>
          <span className="text-[#9bc3ff]">
            {directive.title.trim().length > 0
              ? directive.title
              : directive.id.slice(0, 8)}
          </span>
        </div>
      </div>
      <div className="flex-1 flex items-center justify-center">
        <p className="text-[#556677] font-mono text-[12px]">
          Select a document, or click "+ New document" to create one.
        </p>
      </div>
    </div>
  );
}

// =============================================================================
// Page
// =============================================================================

export default function DocumentDirectivesPage() {
  const { isAuthenticated, isAuthConfigured, isLoading: authLoading } = useAuth();
  const navigate = useNavigate();
  const { id: routeDirectiveId } = useParams<{ id: string }>();
  const [searchParams, setSearchParams] = useSearchParams();
  const { directives, loading: listLoading } = useDirectives();

  // refreshNonce — bumped to tell open directive folders to refetch their
  // document lists (after a create or save).
  const [refreshNonce, setRefreshNonce] = useState(0);
  const bumpRefresh = useCallback(() => setRefreshNonce((n) => n + 1), []);

  // Derive the SidebarSelection from the URL. The route param is the
  // directive id; ?document=:id and ?task=:id pick a specific child. Exactly
  // one of taskId/documentId can be set; if both happen to be present in the
  // URL (which shouldn't happen via our nav code) we prefer ?task= since
  // task selection is the more disruptive action.
  const selection: SidebarSelection | null = useMemo(() => {
    if (!routeDirectiveId) return null;
    const taskId = searchParams.get("task");
    const documentId = searchParams.get("document");
    if (taskId) return { directiveId: routeDirectiveId, taskId, documentId: null };
    if (documentId) return { directiveId: routeDirectiveId, taskId: null, documentId };
    return { directiveId: routeDirectiveId, taskId: null, documentId: null };
  }, [routeDirectiveId, searchParams]);

  useEffect(() => {
    if (!authLoading && isAuthConfigured && !isAuthenticated) {
      navigate("/login");
    }
  }, [authLoading, isAuthConfigured, isAuthenticated, navigate]);

  // ------------------------------------------------------------------
  // Default-selection: when the user clicks a directive's folder header (or
  // lands on /directives/:id without ?document=) we pick the first active or
  // draft document and update the URL to point at it. This avoids the
  // "directive selected, but nothing in the editor" intermediate state.
  // ------------------------------------------------------------------
  const lastResolvedRef = useRef<string | null>(null);
  useEffect(() => {
    if (!routeDirectiveId) {
      lastResolvedRef.current = null;
      return;
    }
    // Only auto-resolve when no document/task has been picked yet, AND we
    // haven't already resolved this directive in a prior tick (otherwise
    // navigating away from the doc would instantly re-pick the same one).
    if (selection?.documentId || selection?.taskId) {
      lastResolvedRef.current = routeDirectiveId;
      return;
    }
    if (lastResolvedRef.current === routeDirectiveId) return;
    lastResolvedRef.current = routeDirectiveId;

    let cancelled = false;
    listDirectiveDocuments(routeDirectiveId)
      .then((list) => {
        if (cancelled) return;
        // Prefer the first 'active' doc; fall back to the first 'draft'.
        const firstActive = list.find((d) => d.status === "active");
        const firstDraft = list.find((d) => d.status === "draft");
        const pick = firstActive ?? firstDraft;
        if (pick) {
          setSearchParams(
            (prev) => {
              const next = new URLSearchParams(prev);
              next.set("document", pick.id);
              next.delete("task");
              return next;
            },
            { replace: true },
          );
        }
      })
      .catch(() => {
        // Swallow — the editor pane will show "Document not found" and the
        // user can click "+ New document" to recover.
      });
    return () => {
      cancelled = true;
    };
  }, [routeDirectiveId, selection?.documentId, selection?.taskId, setSearchParams]);

  const handleSelectDocument = useCallback(
    (directiveId: string, doc: DirectiveDocument) => {
      navigate(`/directives/${directiveId}?document=${doc.id}`);
    },
    [navigate],
  );

  // When the user clicks a directive folder header (not a document row), we
  // jump to /directives/:id without ?document= — the default-selection
  // effect above will then pick the first active doc.
  const handleSelectDirective = useCallback(
    (directiveId: string) => {
      if (routeDirectiveId === directiveId) return;
      navigate(`/directives/${directiveId}`);
    },
    [navigate, routeDirectiveId],
  );

  const handleCreateDocument = useCallback(
    async (directive: DirectiveSummary) => {
      const created = await createDirectiveDocument(directive.id, {
        title: "",
        body: "",
      });
      bumpRefresh();
      // Navigate to the new doc so it's selected immediately.
      navigate(`/directives/${directive.id}?document=${created.id}`);
    },
    [bumpRefresh, navigate],
  );

  if (authLoading) {
    return (
      <div className="relative z-10 min-h-screen flex flex-col bg-[#0a1628]">
        <Masthead showNav />
        <main className="flex-1 flex items-center justify-center">
          <p className="text-[#7788aa] font-mono text-sm">Loading...</p>
        </main>
      </div>
    );
  }

  return (
    // h-screen + overflow-hidden so the page itself never scrolls; the
    // sidebar and editor pane each manage their own scroll via flex-1
    // children with overflow-y-auto. Previously we set
    // height: calc(100vh - 80px) on <main>, which assumed an 80px masthead
    // and quietly clipped content when the masthead was taller (or pushed
    // the page below the viewport on shorter screens, which made the
    // whole page scroll instead of the sidebar/editor independently).
    <div className="relative z-10 h-screen flex flex-col bg-[#0a1628] overflow-hidden">
      <Masthead showNav />
      <main className="flex-1 flex min-h-0 overflow-hidden">
        {/* Left: file-tree sidebar — independent scroll. */}
        <div className="w-[260px] shrink-0 border-r border-dashed border-[rgba(117,170,252,0.2)] overflow-hidden flex flex-col bg-[#091428]">
          <DocumentSidebar
            directives={directives}
            loading={listLoading}
            selection={selection}
            onSelectDocument={handleSelectDocument}
            onSelectDirective={handleSelectDirective}
            onCreateDocument={handleCreateDocument}
            refreshNonce={refreshNonce}
          />
        </div>

        {/* Right: Lexical editor */}
        <EditorShell
          selection={selection}
          hasDirectives={directives.length > 0}
          listLoading={listLoading}
          onDocumentChanged={bumpRefresh}
        />
      </main>
    </div>
  );
}