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

                                                                          



                                                                         
        

                        

                                                 

                     
                                                               
                                                       




                                                  



                            
                           
                      







                    
                    
                                                                                     
                                                                                 







                                                                             
                           


                           



                                                                        
                                                        
                        
                         










                                                                                


                                                                          






                                                                              
                








                                           





































































                                                                                                                                                                                                                                            



                                                                               

                                                                                

                            
                        
















                                                                                
                                                                 
                                                                   

                                                                        

                                                                                    

                                                                               





                                                                               

 



                          
                
            

                   
                        
                
               

                          



                                                       
                                                                    
                                                             
 

                                                                               
                                                            

                                                                  
 

                                                                           
 

                                                                        
 



                                           
                                                     






                                                                                
 
                                                                           

                      





                                                                         

                                   





























                                                                                 
 























                                                                              
          
                                 



                                                                            





                          


                                                                                                                                          
       

                                  












                                                                                                  





                                                                              
               
 
                                                     
                










                                                                                  
 





                                                                                  

                             



                                                                                                                                                                        
               

                                                                   

                       












                                                                                                                                                   
















                                                                                             















                                                                             


                                       
                                              


                                                         

                                                            





































                                                                                                                                                       
                                                    


                                                     

                                                                  



                            
                
               
            
              




          





                                                                                
                



                              









                                                                                   







                      






                    




                                                                 




                                                                   




                        





















                                                                                                                                          














































                                                                                                     


                                                                          









                                                                              



                                                                               



                              
              


               

                 

                                                
                                                                       
                                                

                                                          






                                                                        

                     
         
                                                      






                                                                        
 















                                                                          


          

























                                                                                                                                                        







                                                                           

                                      







                                                   
             
              
        



          

























                                                                             

                      
                  
                                                          

 






                  
                                                             





                                                                      
          















                                                                                                                            










                                                                           
             


    

                        

                      
                  
                                                          

 






                  




                                                                


                                                    
                                             




                                                                                                                            










                                                                           
             
    

 



                                                                                



                                     
                                                                 

                                                                   

                                                               
                                                                            
                                                              
                       

 



                          


                    

                        
                
               
               
                  











                                                                           
      




                                                                                
       

                   



                                                                                    
                   





                                                                                      

                                                                



                                          
                                                                                                                               
                                                                                       
                   
               












                                                                                                                                                                        

            
                                            

                                                                                                  
                                

            
                                                                                   






                                                                                      
                            

                












                                                             
                                           



                                           






                                                                                



























































































































































































































                                                                                                                    
                                                                               
                                 

                                                                        

                                                                             


                                                                                
                                     

                         

                                                                          

 
                      
            

                
                    
                      



                                                                           
                                                                     

                                                                       

              
                              


                 




                                                                                
                                                        












                                                                
                           






















                                                                                
                                                                 







                                                                         
 

                                                                             



                                                                      
                                    
                           

                                                                    




            
                                       

                                                                      
                                                                                    






                                                                      
                                                                                   



            











































                                                                                                                







                                                                                     
     





                                                                          
     





                                                                                    
     
 



                                                                           
                                                       
 

                                                                   








                                           
 
                       



                                                                              




                                             









                                       


























                                                                                                              










                                                                                  
                                                               
                                                            



















                                                                                       






                                                                 




















                                                                             
 
                          
                                   

























                                                                                    
 
                                           
                                             
                                                                
      
               

    






                                                                            
      
                                 

    

                                            
                                                          





                                                                     
      
                            

    








                                                                             































                                                                                        





































                                                                          











                                                                             







                                                                                       
                          

                                                               
                                                                                                                                             


                                   
                                 


                                                     

                                                                
                                             
                                           
                                       


              
                                     
                    
                               

                                               
                                         

             













                                                   













































































                                                                                  




















































































































































































































































                                                                                                                                                                                                        


          
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 DirectiveContract as Contract,
  type DirectiveContractStatus as ContractStatus,
  type DirectiveStep,
  type Task,
  type DirectiveContractTasksResponse as ContractTasksResponse,
  type DirectiveContractMergeMode as ContractMergeMode,
  listDirectiveContracts as listContracts,
  createDirectiveContract as createContract,
  getDirectiveContract as getContract,
  updateDirectiveContract as updateContract,
  listDirectiveContractTasks as listContractTasks,
  startDirectiveContract,
  pauseDirectiveContract,
  completeDirectiveContract,
  unlockDirectiveContract,
  reorderDirectiveContract,
  createDirectiveTask,
  startDirective,
  pauseDirective,
  updateDirective,
  deleteDirective,
  createDirectivePR,
  advanceDirective,
  cleanupDirective,
  pickUpOrders,
} from "../lib/api";
import { DirectiveContextMenu } from "../components/directives/DirectiveContextMenu";
import { DocumentTaskStream } from "../components/directives/DocumentTaskStream";

// 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-contract status palette. Active = bright green (currently driving
// daemons); queued = amber (locked, waiting for the active slot); draft
// = grey (editable spec); shipped = muted blue (work done); archived =
// faint navy.
const DOC_STATUS_DOT: Record<ContractStatus, string> = {
  draft: "bg-[#556677]",
  queued: "bg-amber-400",
  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.
// =============================================================================

// Sidebar is now a flat list ordered by status precedence — see
// `sortedDirectives` in DocumentSidebar. Status is shown as a colored dot
// on the right of each row, no per-status grouping.

// 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: Contract,
  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: Contract) => void;
  onCreateDocument: (directive: DirectiveSummary) => Promise<void>;
  /** Open the inline "+ New ephemeral task" form for this directive. */
  onCreateEphemeralTask: (directive: DirectiveSummary) => void;
  /** Right-click handler — opens DirectiveContextMenu with start/pause/PR/etc. */
  onContextMenu: (e: React.MouseEvent, directive: DirectiveSummary) => void;
  /** Click handler for task/step rows — navigates to the live transcript. */
  onSelectTask: (directiveId: string, taskId: string) => 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,
  onCreateEphemeralTask,
  onContextMenu,
  onSelectTask,
  refreshNonce,
}: DirectiveFolderProps) {
  const selectedTaskIdForFolder =
    selection && selection.directiveId === directive.id
      ? selection.taskId
      : null;
  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<Contract[] | 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 listContracts(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: Contract[] = [];
    const shipped: Contract[] = [];
    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;

  // Drag-to-reorder state — only active contracts (draft/queued/active) are
  // reorderable. The drag id lives in the folder so the drop target can read
  // it from props. `dragOverId` powers the visual indicator on hover.
  const [dragId, setDragId] = useState<string | null>(null);
  const [dragOverId, setDragOverId] = useState<string | null>(null);

  const handleReorder = useCallback(
    async (draggedId: string, targetDoc: Contract) => {
      setDragId(null);
      setDragOverId(null);
      // No-op if dropping on self.
      if (draggedId === targetDoc.id) return;
      try {
        await reorderDirectiveContract(draggedId, targetDoc.position);
        await refresh();
      } catch (e) {
        // Fail open — sidebar will refresh on next refreshNonce bump.
        // eslint-disable-next-line no-console
        console.error("Reorder failed", e);
      }
    },
    [refresh],
  );

  return (
    <div className="select-none">
      {/* Directive folder header. Status is shown as a colored dot on the
          RIGHT (per the user's spec — flat list, no per-status grouping).
          Right-click opens the context menu (start / pause / archive /
          delete / create-PR / update-PR / etc.). */}
      <button
        type="button"
        onClick={() => {
          onToggle();
          onHeaderClick();
        }}
        onContextMenu={(e) => onContextMenu(e, directive)}
        title={`${directive.title} — ${directive.status}`}
        className="w-full flex items-center gap-1.5 pl-3 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="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"
          />
        )}
        {/* Status indicator on the RIGHT side of the row. */}
        <span
          className={`inline-block w-2 h-2 rounded-full shrink-0 ${dotColor}`}
          aria-label={`status: ${directive.status}`}
          title={`status: ${directive.status}`}
        />
      </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>

              {/* + New ephemeral task — sibling affordance for spawning a
                  one-off task under this directive that's NOT part of the
                  DAG. Useful for sidebar scratch work, debugging, etc. */}
              <button
                type="button"
                onClick={() => onCreateEphemeralTask(directive)}
                className="w-full flex items-center gap-1.5 pl-14 pr-3 py-1 font-mono text-[11px] text-[#c084fc] hover:bg-[rgba(192,132,252,0.06)]"
                title="Spawn a one-off ephemeral task under this directive"
              >
                <span className="text-[12px] leading-none">+</span>
                <span>New ephemeral task</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)}
                    draggable
                    onDragStart={() => setDragId(doc.id)}
                    onDragEnd={() => {
                      setDragId(null);
                      setDragOverId(null);
                    }}
                    onDragOver={() => {
                      if (dragId && dragId !== doc.id) setDragOverId(doc.id);
                    }}
                    onDragLeave={() => {
                      if (dragOverId === doc.id) setDragOverId(null);
                    }}
                    onDrop={() => {
                      if (dragId) void handleReorder(dragId, doc);
                    }}
                    dragOver={dragOverId === doc.id}
                  />
                  <DocumentTasksFolder
                    documentId={doc.id}
                    directiveId={directive.id}
                    depth="normal"
                    defaultOpen={doc.status === "active"}
                    refreshNonce={refreshNonce}
                    selectedTaskId={selectedTaskIdForFolder}
                    onSelectTask={onSelectTask}
                  />
                </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}
                          directiveId={directive.id}
                          depth="deep"
                          defaultOpen={false}
                          refreshNonce={refreshNonce}
                          selectedTaskId={selectedTaskIdForFolder}
                          onSelectTask={onSelectTask}
                        />
                      </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: Contract;
  directive: DirectiveSummary;
  selected: boolean;
  onSelect: () => void;
  indent?: "normal" | "deep";
  // ----- Drag-to-reorder props (optional — only wired by the active list) ---
  /** Whether this row participates in HTML5 drag (active docs only). */
  draggable?: boolean;
  onDragStart?: () => void;
  onDragEnd?: () => void;
  onDragOver?: () => void;
  onDragLeave?: () => void;
  onDrop?: () => void;
  /** True while a drag is hovering over this row — drives the drop indicator. */
  dragOver?: boolean;
}

function DocumentRow({
  doc,
  directive,
  selected,
  onSelect,
  indent = "normal",
  draggable = false,
  onDragStart,
  onDragEnd,
  onDragOver,
  onDragLeave,
  onDrop,
  dragOver = false,
}: 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`;

  // Drop-indicator: a top border accent on the hovered target row.
  const dropAccent = dragOver
    ? "border-t-2 border-t-emerald-400"
    : "border-t-2 border-t-transparent";

  return (
    <button
      type="button"
      onClick={onSelect}
      title={name}
      draggable={draggable}
      onDragStart={(e) => {
        if (!draggable) return;
        // Required by Firefox to actually start the drag.
        e.dataTransfer.effectAllowed = "move";
        e.dataTransfer.setData("text/plain", doc.id);
        onDragStart?.();
      }}
      onDragEnd={onDragEnd}
      onDragOver={(e) => {
        if (!draggable) return;
        e.preventDefault();
        e.dataTransfer.dropEffect = "move";
        onDragOver?.();
      }}
      onDragLeave={onDragLeave}
      onDrop={(e) => {
        if (!draggable) return;
        e.preventDefault();
        onDrop?.();
      }}
      className={`w-full text-left flex items-center gap-1.5 ${padLeft} pr-3 py-1 font-mono text-[11px] transition-colors ${dropAccent} ${
        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;
  /** Parent directive id — needed so a clicked task row can navigate to
   *  /directives/<directiveId>?task=<taskId>. */
  directiveId: 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;
  /** Currently-selected task id (drives row highlight). */
  selectedTaskId: string | null;
  /** Click handler for step/task rows — navigates to the live transcript. */
  onSelectTask: (directiveId: string, taskId: string) => void;
}

function DocumentTasksFolder({
  documentId,
  directiveId,
  depth,
  defaultOpen,
  refreshNonce,
  selectedTaskId,
  onSelectTask,
}: DocumentTasksFolderProps) {
  const [open, setOpen] = useState(defaultOpen);
  const [data, setData] = useState<ContractTasksResponse | 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 listContractTasks(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}
              directiveId={directiveId}
              selected={!!selectedTaskId && step.taskId === selectedTaskId}
              padLeft={rowPadLeft}
              onSelect={onSelectTask}
            />
          ))}
          {data?.tasks.map((task) => (
            <TaskRow
              key={`task-${task.id}`}
              task={task}
              directiveId={directiveId}
              selected={task.id === selectedTaskId}
              padLeft={rowPadLeft}
              onSelect={onSelectTask}
            />
          ))}
        </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;
  directiveId: string;
  selected: boolean;
  padLeft: string;
  onSelect: (directiveId: string, taskId: string) => void;
}

function StepRow({
  step,
  directiveId,
  selected,
  padLeft,
  onSelect,
}: StepRowProps) {
  const dot = STEP_STATUS_DOT[step.status] ?? "bg-[#556677]";
  // Steps without an underlying task can't be opened — the executor
  // hasn't started yet so there's no transcript to show. Render them
  // disabled so the user can see them in the list but knows they're
  // inert. Same for steps stuck in pending/skipped.
  const taskId = step.taskId;
  const clickable = !!taskId;
  return (
    <button
      type="button"
      disabled={!clickable}
      onClick={() => clickable && onSelect(directiveId, taskId!)}
      title={
        clickable
          ? `${step.name} (${step.status})`
          : `${step.name} — no task spawned yet (${step.status})`
      }
      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]"
          : clickable
          ? "text-[#9bc3ff] hover:bg-[rgba(117,170,252,0.06)] border-l-2 border-transparent"
          : "text-[#556677] border-l-2 border-transparent cursor-not-allowed"
      }`}
    >
      <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>
    </button>
  );
}

interface TaskRowProps {
  task: Task;
  directiveId: string;
  selected: boolean;
  padLeft: string;
  onSelect: (directiveId: string, taskId: string) => void;
}

function TaskRow({
  task,
  directiveId,
  selected,
  padLeft,
  onSelect,
}: 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 (
    <button
      type="button"
      onClick={() => onSelect(directiveId, task.id)}
      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] 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={task.status}
      />
      <span className="truncate flex-1">{task.name}</span>
      <span className="text-[9px] uppercase tracking-wide text-[#556677]">
        {isSup ? "sup" : "task"}
      </span>
    </button>
  );
}

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

interface SidebarProps {
  directives: DirectiveSummary[];
  loading: boolean;
  selection: SidebarSelection | null;
  onSelectDocument: (directiveId: string, doc: Contract) => void;
  onSelectDirective: (directiveId: string) => void;
  onCreateDocument: (directive: DirectiveSummary) => Promise<void>;
  onCreateContract: () => void;
  onCreateEphemeralTask: (directive: DirectiveSummary) => void;
  onContextMenu: (e: React.MouseEvent, directive: DirectiveSummary) => void;
  onSelectTask: (directiveId: string, taskId: string) => void;
  refreshNonce: number;
}

function DocumentSidebar({
  directives,
  loading,
  selection,
  onSelectDocument,
  onSelectDirective,
  onCreateDocument,
  onCreateContract,
  onCreateEphemeralTask,
  onContextMenu,
  onSelectTask,
  refreshNonce,
}: SidebarProps) {
  // Flat sort: active first, then idle, paused, draft, inactive, archived.
  // Status is surfaced as a colored dot on the RIGHT of each contract row
  // (see DirectiveFolder header) — the user explicitly asked NOT to nest
  // contracts inside per-status folders.
  const sortedDirectives: DirectiveSummary[] = useMemo(() => {
    const order: Record<DirectiveStatus, number> = {
      active: 0,
      paused: 1,
      idle: 2,
      draft: 3,
      inactive: 4,
      archived: 5,
    };
    return [...directives].sort((a, b) => {
      const oa = order[a.status] ?? 99;
      const ob = order[b.status] ?? 99;
      if (oa !== ob) return oa - ob;
      return a.title.localeCompare(b.title, undefined, { sensitivity: "base" });
    });
  }, [directives]);

  // 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 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 gap-2 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">
          Contracts
        </span>
        <div className="flex items-center gap-2">
          <span className="text-[10px] font-mono text-[#556677]">
            {directives.length}
          </span>
          <button
            type="button"
            onClick={onCreateContract}
            className="text-[11px] font-mono text-emerald-300 hover:text-white border border-emerald-700/60 hover:border-emerald-400 rounded px-1.5 py-0.5 leading-none"
            title="Create a new contract (directive)"
          >
            + New
          </button>
        </div>
      </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 — flat list, status is a colored dot on the right of each row. */}
      <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 contracts yet
          </div>
        ) : (
          <div className="py-0.5">
            {sortedDirectives.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}
                onCreateEphemeralTask={onCreateEphemeralTask}
                onContextMenu={onContextMenu}
                onSelectTask={onSelectTask}
                refreshNonce={refreshNonce}
              />
            ))}
          </div>
        )}
      </div>
    </div>
  );
}

// =============================================================================
// Contract header — breadcrumb + status badge + lifecycle action buttons +
// merge mode radio. Renders above the spec editor in the document path.
//
// Action visibility is status-driven:
//   * draft     → Lock & Start
//   * queued    → Unlock (back to draft); shows "queued" pill
//   * active    → Pause, Complete, Unlock; shows "active" + pulsing dot
//   * shipped   → reopen via spec edit (no buttons here; backend reactivates)
//   * archived  → no buttons
//
// Merge mode (shared / own_pr) is editable while the contract is in
// `draft` or `queued` — once active, the queue scheduler has already
// claimed the slot, so flipping the toggle would silently change a
// running flow's branch target. Locked rows show the value as readonly.
// =============================================================================

interface ContractHeaderProps {
  directive: { id: string; title: string; orchestratorTaskId: string | null };
  doc: Contract;
  docTitle: string;
  /** Called with the server's response after any status / merge-mode
   *  transition so the parent can refresh the editor + sidebar. */
  onContractChanged: (updated: Contract) => void;
}

function ContractHeader({
  directive,
  doc,
  docTitle,
  onContractChanged,
}: ContractHeaderProps) {
  const [busy, setBusy] = useState<null | "start" | "pause" | "complete" | "unlock" | "merge_mode">(
    null,
  );
  const [error, setError] = useState<string | null>(null);

  const wrap = useCallback(
    async (tag: typeof busy, op: () => Promise<Contract>) => {
      try {
        setBusy(tag);
        setError(null);
        const updated = await op();
        onContractChanged(updated);
      } catch (e) {
        setError(e instanceof Error ? e.message : "Unknown error");
      } finally {
        setBusy(null);
      }
    },
    [onContractChanged],
  );

  const onStart = useCallback(
    () => wrap("start", () => startDirectiveContract(doc.id)),
    [doc.id, wrap],
  );
  const onPause = useCallback(
    () => wrap("pause", () => pauseDirectiveContract(doc.id)),
    [doc.id, wrap],
  );
  const onComplete = useCallback(
    () => wrap("complete", () => completeDirectiveContract(doc.id)),
    [doc.id, wrap],
  );
  const onUnlock = useCallback(
    () => wrap("unlock", () => unlockDirectiveContract(doc.id)),
    [doc.id, wrap],
  );
  const onMergeMode = useCallback(
    (mode: ContractMergeMode) =>
      wrap("merge_mode", () => updateContract(doc.id, { mergeMode: mode })),
    [doc.id, wrap],
  );

  const editableMergeMode = doc.status === "draft" || doc.status === "queued";

  return (
    <div className="px-6 py-3 border-b border-dashed border-[rgba(117,170,252,0.2)] flex flex-col gap-2">
      {/* Row 1: breadcrumb + status pill + orchestrator indicator */}
      <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>
        <ContractStatusPill status={doc.status} />
        {!!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>

      {/* Row 2: action buttons (status-driven) + merge mode + error */}
      <div className="flex items-center gap-2 text-[11px] font-mono">
        {doc.status === "draft" && (
          <ContractActionButton onClick={onStart} disabled={busy !== null} variant="primary">
            {busy === "start" ? "Starting…" : "Lock & Start"}
          </ContractActionButton>
        )}
        {doc.status === "queued" && (
          <ContractActionButton onClick={onUnlock} disabled={busy !== null}>
            {busy === "unlock" ? "Unlocking…" : "Unlock"}
          </ContractActionButton>
        )}
        {doc.status === "active" && (
          <>
            <ContractActionButton onClick={onPause} disabled={busy !== null}>
              {busy === "pause" ? "Pausing…" : "Pause"}
            </ContractActionButton>
            <ContractActionButton onClick={onComplete} disabled={busy !== null} variant="primary">
              {busy === "complete" ? "Completing…" : "Mark complete"}
            </ContractActionButton>
            <ContractActionButton onClick={onUnlock} disabled={busy !== null}>
              {busy === "unlock" ? "Unlocking…" : "Unlock"}
            </ContractActionButton>
          </>
        )}

        {/* Merge mode radios — visible always, editable only in draft/queued */}
        <div className="ml-auto flex items-center gap-2 text-[#7788aa]">
          <span className="uppercase tracking-wide">merge:</span>
          <MergeModeRadio
            value={doc.mergeMode}
            onChange={onMergeMode}
            disabled={!editableMergeMode || busy !== null}
          />
        </div>
      </div>

      {error && (
        <div className="text-[10px] font-mono text-red-400">{error}</div>
      )}
    </div>
  );
}

function ContractStatusPill({ status }: { status: ContractStatus }) {
  const styles: Record<ContractStatus, { label: string; cls: string }> = {
    draft: { label: "draft", cls: "text-[#556677]" },
    queued: { label: "queued", cls: "text-amber-400" },
    active: { label: "active", cls: "text-green-400" },
    shipped: { label: "shipped", cls: "text-[#75aafc]" },
    archived: { label: "archived", cls: "text-[#7788aa]" },
  };
  const s = styles[status];
  return <span className={`ml-2 normal-case ${s.cls}`}>{s.label}</span>;
}

function ContractActionButton({
  children,
  onClick,
  disabled,
  variant,
}: {
  children: React.ReactNode;
  onClick: () => void;
  disabled?: boolean;
  variant?: "primary";
}) {
  const base =
    "px-2 py-1 border border-[rgba(117,170,252,0.3)] rounded text-[10px] uppercase tracking-wide transition-colors";
  const colors =
    variant === "primary"
      ? "text-green-300 hover:bg-[rgba(120,200,140,0.1)]"
      : "text-[#9bc3ff] hover:bg-[rgba(117,170,252,0.1)]";
  const dim = disabled ? "opacity-50 cursor-not-allowed" : "cursor-pointer";
  return (
    <button
      type="button"
      onClick={onClick}
      disabled={disabled}
      className={`${base} ${colors} ${dim}`}
    >
      {children}
    </button>
  );
}

function MergeModeRadio({
  value,
  onChange,
  disabled,
}: {
  value: ContractMergeMode;
  onChange: (mode: ContractMergeMode) => void;
  disabled?: boolean;
}) {
  const opt = (mode: ContractMergeMode, label: string) => {
    const selected = value === mode;
    const cls = selected
      ? "text-white border-[rgba(117,170,252,0.6)] bg-[rgba(117,170,252,0.1)]"
      : "text-[#7788aa] border-transparent hover:text-[#9bc3ff]";
    const dim = disabled ? "opacity-50 cursor-not-allowed" : "cursor-pointer";
    return (
      <button
        key={mode}
        type="button"
        onClick={() => !disabled && !selected && onChange(mode)}
        disabled={disabled}
        className={`px-2 py-0.5 rounded border ${cls} ${dim} text-[10px] uppercase tracking-wide`}
      >
        {label}
      </button>
    );
  };
  return (
    <div className="flex items-center gap-1">
      {opt("shared", "shared")}
      {opt("own_pr", "own pr")}
    </div>
  );
}

// =============================================================================
// Editor shell — wraps DocumentEditor and handles the "no document selected"
// and loading states. Two modes:
//   1) documentId selected → fetch the Contract and edit doc.body via
//      updateContract (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 updateContract (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<Contract | 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);
    getContract(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 updateContract(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>
    );
  }

  // --- Task path: task row clicked in the sidebar ------------------------
  // Renders the live transcript via DocumentTaskStream. Selection wins over
  // the document path when both are somehow present (defensive).
  if (selection?.taskId) {
    const taskId = selection.taskId;
    // Resolve a human label for the task: orchestrator/completion are
    // labelled by role; step tasks borrow the step name; everything else
    // is an ephemeral and just shows the task id slice. Look-up uses the
    // already-fetched directive (with steps).
    const stepWithTask = directive.steps.find((s) => s.taskId === taskId);
    const label =
      taskId === directive.orchestratorTaskId
        ? "orchestrator"
        : taskId === directive.completionTaskId
        ? "completion"
        : stepWithTask?.name ?? taskId.slice(0, 8);
    const isStepBound =
      taskId === directive.orchestratorTaskId ||
      taskId === directive.completionTaskId ||
      !!stepWithTask;
    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>
            <span>/</span>
            <span className="text-white">{label}</span>
          </div>
        </div>
        <DocumentTaskStream
          taskId={taskId}
          label={label}
          ephemeral={!isStepBound}
        />
      </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>
      );
    }

    // The contract title is the filename label; the contract body is the
    // editor body. DocumentEditor takes these directly (no more synthesis
    // hack) — `directive` is still passed for orchestrator state and the
    // embedded steps panel via StepsBlockContextProvider.
    const docTitle = `${fileLabel(doc, directive)}.md`;

    return (
      <div className="flex-1 flex flex-col h-full overflow-hidden">
        <ContractHeader
          directive={directive}
          doc={doc}
          docTitle={docTitle}
          onContractChanged={(updated) => {
            setDoc(updated);
            onDocumentChanged();
          }}
        />

        <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={directive}
          documentId={doc.id}
          title={docTitle}
          body={doc.body}
          onUpdateBody={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;
    listContracts(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: Contract) => {
      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 createContract(directive.id, {
        title: "",
        body: "",
      });
      bumpRefresh();
      // Navigate to the new doc so it's selected immediately.
      navigate(`/directives/${directive.id}?document=${created.id}`);
    },
    [bumpRefresh, navigate],
  );

  // Click on a task or step row → open the live transcript pane via
  // ?task=<id>. EditorShell switches to DocumentTaskStream when this is set.
  const handleSelectTask = useCallback(
    (directiveId: string, taskId: string) => {
      navigate(`/directives/${directiveId}?task=${taskId}`);
    },
    [navigate],
  );

  // Modal state for the two new creation surfaces in the sidebar:
  //   * + New contract  → opens NewContractModal, calls useDirectives.create
  //   * + New ephemeral task (per directive) → opens NewEphemeralTaskModal
  const { create: createDirective } = useDirectives();
  const [showNewContract, setShowNewContract] = useState(false);
  const [newEphemeralFor, setNewEphemeralFor] = useState<DirectiveSummary | null>(null);

  const handleSubmitNewContract = useCallback(
    async (title: string, goal: string, repositoryUrl: string) => {
      const d = await createDirective({
        title,
        goal,
        repositoryUrl: repositoryUrl.length > 0 ? repositoryUrl : undefined,
      });
      setShowNewContract(false);
      navigate(`/directives/${d.id}`);
    },
    [createDirective, navigate],
  );

  const handleSubmitNewEphemeral = useCallback(
    async (name: string, plan: string) => {
      if (!newEphemeralFor) return;
      const task = await createDirectiveTask(newEphemeralFor.id, { name, plan });
      const target = newEphemeralFor.id;
      setNewEphemeralFor(null);
      bumpRefresh();
      navigate(`/directives/${target}?task=${task.id}`);
    },
    [newEphemeralFor, bumpRefresh, navigate],
  );

  // Right-click context menu state. Right-clicking any directive header
  // opens the menu; menu actions (start/pause/archive/delete/PR/etc.) hit
  // the directives API and trigger a sidebar refresh on success.
  const { refresh: refreshDirectiveList } = useDirectives();
  const [contextMenu, setContextMenu] = useState<{
    x: number;
    y: number;
    directive: DirectiveSummary;
  } | null>(null);

  const handleContextMenu = useCallback(
    (e: React.MouseEvent, directive: DirectiveSummary) => {
      e.preventDefault();
      e.stopPropagation();
      setContextMenu({ x: e.clientX, y: e.clientY, directive });
    },
    [],
  );

  const closeContextMenu = useCallback(() => setContextMenu(null), []);

  const runAction = useCallback(
    async (action: () => Promise<unknown>, errMsg: string) => {
      try {
        await action();
        await refreshDirectiveList();
        bumpRefresh();
      } catch (err) {
        // eslint-disable-next-line no-console
        console.error(`[makima] ${errMsg}`, err);
        alert(
          err instanceof Error ? `${errMsg}: ${err.message}` : errMsg,
        );
      }
    },
    [refreshDirectiveList, bumpRefresh],
  );

  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}
            onCreateContract={() => setShowNewContract(true)}
            onCreateEphemeralTask={(d) => setNewEphemeralFor(d)}
            onContextMenu={handleContextMenu}
            onSelectTask={handleSelectTask}
            refreshNonce={refreshNonce}
          />
        </div>

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

      {showNewContract && (
        <NewContractModal
          onClose={() => setShowNewContract(false)}
          onSubmit={handleSubmitNewContract}
        />
      )}
      {newEphemeralFor && (
        <NewEphemeralTaskModal
          directive={newEphemeralFor}
          onClose={() => setNewEphemeralFor(null)}
          onSubmit={handleSubmitNewEphemeral}
        />
      )}

      {contextMenu && (
        <DirectiveContextMenu
          x={contextMenu.x}
          y={contextMenu.y}
          directive={contextMenu.directive}
          onClose={closeContextMenu}
          onStart={() =>
            runAction(
              () => startDirective(contextMenu.directive.id),
              "Failed to start contract",
            )
          }
          onPause={() =>
            runAction(
              () => pauseDirective(contextMenu.directive.id),
              "Failed to pause contract",
            )
          }
          onArchive={() =>
            runAction(
              () =>
                updateDirective(contextMenu.directive.id, {
                  status: "archived",
                }),
              "Failed to archive contract",
            )
          }
          onDelete={async () => {
            if (
              !window.confirm(
                `Delete "${contextMenu.directive.title}"? This cannot be undone.`,
              )
            ) {
              return;
            }
            await runAction(
              () => deleteDirective(contextMenu.directive.id),
              "Failed to delete contract",
            );
            // If the deleted contract was selected, clear the URL.
            if (selection?.directiveId === contextMenu.directive.id) {
              navigate("/directives");
            }
          }}
          onGoToPR={() => {
            if (contextMenu.directive.prUrl) {
              window.open(contextMenu.directive.prUrl, "_blank", "noreferrer");
            }
          }}
          onCreatePR={() =>
            runAction(
              () => createDirectivePR(contextMenu.directive.id),
              contextMenu.directive.prUrl
                ? "Failed to update PR"
                : "Failed to create PR",
            )
          }
          onAdvance={() =>
            runAction(
              () => advanceDirective(contextMenu.directive.id),
              "Failed to advance DAG",
            )
          }
          onCleanup={() =>
            runAction(
              () => cleanupDirective(contextMenu.directive.id),
              "Failed to clean up contract",
            )
          }
          onPickUpOrders={() =>
            runAction(
              () => pickUpOrders(contextMenu.directive.id),
              "Failed to pick up orders",
            )
          }
        />
      )}
    </div>
  );
}

/**
 * Modal for creating a new directive (= "contract" in the doc-mode UI).
 * Title + goal are required; repository_url is optional. On submit calls
 * useDirectives.create and navigates the user into the new directive.
 */
function NewContractModal({
  onClose,
  onSubmit,
}: {
  onClose: () => void;
  onSubmit: (title: string, goal: string, repositoryUrl: string) => Promise<void>;
}) {
  const [title, setTitle] = useState("");
  const [goal, setGoal] = useState("");
  const [repo, setRepo] = useState("");
  const [submitting, setSubmitting] = useState(false);
  const [err, setErr] = useState<string | null>(null);
  const titleRef = useRef<HTMLInputElement>(null);

  useEffect(() => {
    titleRef.current?.focus();
    const onKey = (e: KeyboardEvent) => {
      if (e.key === "Escape") onClose();
    };
    document.addEventListener("keydown", onKey);
    return () => document.removeEventListener("keydown", onKey);
  }, [onClose]);

  const submit = async (e: React.FormEvent) => {
    e.preventDefault();
    const t = title.trim();
    const g = goal.trim();
    if (!t || !g || submitting) return;
    setSubmitting(true);
    setErr(null);
    try {
      await onSubmit(t, g, repo.trim());
    } catch (caught) {
      setErr(caught instanceof Error ? caught.message : String(caught));
    } finally {
      setSubmitting(false);
    }
  };

  return (
    <div
      className="fixed inset-0 z-50 flex items-center justify-center bg-black/60"
      onClick={onClose}
    >
      <form
        onSubmit={submit}
        onClick={(e) => e.stopPropagation()}
        className="w-[520px] max-w-[90vw] bg-[#0a1628] border border-[rgba(117,170,252,0.4)] shadow-xl flex flex-col"
      >
        <div className="px-4 py-3 border-b border-dashed border-[rgba(117,170,252,0.25)]">
          <p className="text-[10px] font-mono text-[#556677] uppercase tracking-wide">
            New contract
          </p>
        </div>
        <div className="px-4 py-4 space-y-3">
          <div className="space-y-1">
            <label className="text-[10px] font-mono text-[#7788aa] uppercase tracking-wide">
              Title
            </label>
            <input
              ref={titleRef}
              type="text"
              value={title}
              onChange={(e) => setTitle(e.target.value)}
              placeholder="e.g. Migrate auth to Supabase"
              className="w-full bg-transparent border border-[rgba(117,170,252,0.2)] focus:border-[#75aafc] outline-none px-3 py-2 text-[13px] text-[#dbe7ff] placeholder-[#445566]"
            />
          </div>
          <div className="space-y-1">
            <label className="text-[10px] font-mono text-[#7788aa] uppercase tracking-wide">
              Goal
            </label>
            <textarea
              value={goal}
              onChange={(e) => setGoal(e.target.value)}
              onKeyDown={(e) => {
                if ((e.metaKey || e.ctrlKey) && e.key === "Enter") {
                  void submit(e as unknown as React.FormEvent);
                }
              }}
              rows={4}
              placeholder="Describe what the contract should achieve"
              className="w-full bg-transparent border border-[rgba(117,170,252,0.2)] focus:border-[#75aafc] outline-none px-3 py-2 text-[13px] text-[#dbe7ff] placeholder-[#445566] resize-none"
            />
          </div>
          <div className="space-y-1">
            <label className="text-[10px] font-mono text-[#7788aa] uppercase tracking-wide">
              Repository URL (optional)
            </label>
            <input
              type="text"
              value={repo}
              onChange={(e) => setRepo(e.target.value)}
              placeholder="e.g. https://github.com/owner/repo"
              className="w-full bg-transparent border border-[rgba(117,170,252,0.2)] focus:border-[#75aafc] outline-none px-3 py-2 text-[13px] text-[#dbe7ff] placeholder-[#445566]"
            />
          </div>
          {err && (
            <p className="text-[11px] font-mono text-red-400">{err}</p>
          )}
        </div>
        <div className="px-4 py-3 border-t border-dashed border-[rgba(117,170,252,0.25)] flex items-center justify-end gap-2">
          <button
            type="button"
            onClick={onClose}
            className="px-3 py-1.5 text-[11px] font-mono uppercase tracking-wide text-[#7788aa] border border-[#2a3a5a] hover:text-white"
          >
            Cancel
          </button>
          <button
            type="submit"
            disabled={!title.trim() || !goal.trim() || submitting}
            className="px-3 py-1.5 text-[11px] font-mono uppercase tracking-wide text-emerald-300 border border-emerald-700/60 hover:border-emerald-400 disabled:opacity-40 disabled:cursor-not-allowed"
          >
            {submitting ? "Creating…" : "Create contract"}
          </button>
        </div>
      </form>
    </div>
  );
}

/**
 * Modal for spawning an ephemeral task under a directive. Mirrors the
 * existing right-click "+ New task" flow.
 */
function NewEphemeralTaskModal({
  directive,
  onClose,
  onSubmit,
}: {
  directive: DirectiveSummary;
  onClose: () => void;
  onSubmit: (name: string, plan: string) => Promise<void>;
}) {
  const [name, setName] = useState("");
  const [plan, setPlan] = useState("");
  const [submitting, setSubmitting] = useState(false);
  const [err, setErr] = useState<string | null>(null);
  const nameRef = useRef<HTMLInputElement>(null);

  useEffect(() => {
    nameRef.current?.focus();
    const onKey = (e: KeyboardEvent) => {
      if (e.key === "Escape") onClose();
    };
    document.addEventListener("keydown", onKey);
    return () => document.removeEventListener("keydown", onKey);
  }, [onClose]);

  const submit = async (e: React.FormEvent) => {
    e.preventDefault();
    const n = name.trim();
    const p = plan.trim();
    if (!n || !p || submitting) return;
    setSubmitting(true);
    setErr(null);
    try {
      await onSubmit(n, p);
    } catch (caught) {
      setErr(caught instanceof Error ? caught.message : String(caught));
    } finally {
      setSubmitting(false);
    }
  };

  return (
    <div
      className="fixed inset-0 z-50 flex items-center justify-center bg-black/60"
      onClick={onClose}
    >
      <form
        onSubmit={submit}
        onClick={(e) => e.stopPropagation()}
        className="w-[480px] max-w-[90vw] bg-[#0a1628] border border-[rgba(117,170,252,0.4)] shadow-xl flex flex-col"
      >
        <div className="px-4 py-3 border-b border-dashed border-[rgba(117,170,252,0.25)]">
          <p className="text-[10px] font-mono text-[#556677] uppercase tracking-wide">
            New ephemeral task in
          </p>
          <p className="text-[12px] font-mono text-white truncate">
            {directive.title}
          </p>
        </div>
        <div className="px-4 py-4 space-y-3">
          <div className="space-y-1">
            <label className="text-[10px] font-mono text-[#7788aa] uppercase tracking-wide">
              Name
            </label>
            <input
              ref={nameRef}
              type="text"
              value={name}
              onChange={(e) => setName(e.target.value)}
              placeholder="e.g. Investigate flaky test"
              className="w-full bg-transparent border border-[rgba(117,170,252,0.2)] focus:border-[#75aafc] outline-none px-3 py-2 text-[13px] text-[#dbe7ff] placeholder-[#445566]"
            />
          </div>
          <div className="space-y-1">
            <label className="text-[10px] font-mono text-[#7788aa] uppercase tracking-wide">
              Plan / instructions
            </label>
            <textarea
              value={plan}
              onChange={(e) => setPlan(e.target.value)}
              onKeyDown={(e) => {
                if ((e.metaKey || e.ctrlKey) && e.key === "Enter") {
                  void submit(e as unknown as React.FormEvent);
                }
              }}
              rows={5}
              placeholder="What should the task do?"
              className="w-full bg-transparent border border-[rgba(117,170,252,0.2)] focus:border-[#75aafc] outline-none px-3 py-2 text-[13px] text-[#dbe7ff] placeholder-[#445566] resize-none"
            />
          </div>
          {err && (
            <p className="text-[11px] font-mono text-red-400">{err}</p>
          )}
        </div>
        <div className="px-4 py-3 border-t border-dashed border-[rgba(117,170,252,0.25)] flex items-center justify-end gap-2">
          <button
            type="button"
            onClick={onClose}
            className="px-3 py-1.5 text-[11px] font-mono uppercase tracking-wide text-[#7788aa] border border-[#2a3a5a] hover:text-white"
          >
            Cancel
          </button>
          <button
            type="submit"
            disabled={!name.trim() || !plan.trim() || submitting}
            className="px-3 py-1.5 text-[11px] font-mono uppercase tracking-wide text-[#c084fc] border border-[#c084fc]/40 hover:border-[#c084fc] disabled:opacity-40 disabled:cursor-not-allowed"
          >
            {submitting ? "Spawning…" : "Spawn task"}
          </button>
        </div>
      </form>
    </div>
  );
}