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

                                                                          


                                                                     
                                                                                
                                                                         





                                                                                 










                                                                             








                                                                        

  






















































                                                                                                                                                                                                                                            

































                                                                                                            

















                                                                                        

















                                                                                














                                                                                  


                                 

                                            

 

                                                                            




                                                                            






                          

                         





                                            



                                                                        












                                                                                        



                                                                        








                                                                                                                                          

                                                                            






                                                                               























                                                                                                                              
































































                                                                                                                                                





             



































                                                                                                















































                                                                             
     












                                                                                      














                                                                               













                                                                                
       

                   























                                                                             












                                                                                                                         
                                                                                     















                                                                                                  







                                               

                                                                      

              












                                                                                
                                

                         
                          

 






                      






































                                                                                   









                                                                         







                                                                                                              













                                                                                                                          







                                                                                                    





















                                                         











                                                                                  

                                                            







                                                                 















                                                    










                                                                             



                                                         







                                                                           
                                                                                                                                             


                                   

                                 


              
                                                   

                                 
                                         

                                               
                                   




             
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useNavigate, useParams, useSearchParams } from "react-router";
import { Masthead } from "../components/Masthead";
import { useDirective, useDirectives } from "../hooks/useDirectives";
import { useAuth } from "../contexts/AuthContext";
import { useSupervisorQuestions } from "../contexts/SupervisorQuestionsContext";
import { DocumentEditor } from "../components/directives/DocumentEditor";
import { DocumentTaskStream } from "../components/directives/DocumentTaskStream";
import type {
  DirectiveStatus,
  DirectiveSummary,
  DirectiveWithSteps,
} from "../lib/api";

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

// Per-task dot color for the sidebar entries inside a directive folder.
// Matches the StepsBlockNode palette.
const STEP_STATUS_DOT: Record<string, string> = {
  pending: "bg-[#556677]",
  ready: "bg-[#9bc3ff]",
  running: "bg-yellow-400",
  done: "bg-green-400",
  failed: "bg-red-400",
  skipped: "bg-[#3a4a6a]",
};

// =============================================================================
// 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>
  );
}

/** Terminal/prompt icon for orchestrator and step tasks. */
function TaskIcon() {
  return (
    <svg
      viewBox="0 0 16 16"
      width={12}
      height={12}
      className="shrink-0"
      aria-hidden
    >
      <rect x="1.5" y="3" width="13" height="10" rx="1" fill="none" stroke="#9bc3ff" strokeWidth="1" />
      <path d="M3.5 6l2 2-2 2 M7 10h4" stroke="#9bc3ff" strokeWidth="1" fill="none" strokeLinecap="round" />
    </svg>
  );
}

/** PR-bracket icon for the completion task. */
function CompletionIcon() {
  return (
    <svg
      viewBox="0 0 16 16"
      width={12}
      height={12}
      className="shrink-0"
      aria-hidden
    >
      <circle cx="4" cy="4" r="1.4" fill="none" stroke="#9bc3ff" strokeWidth="1" />
      <circle cx="4" cy="12" r="1.4" fill="none" stroke="#9bc3ff" strokeWidth="1" />
      <circle cx="12" cy="12" r="1.4" fill="none" stroke="#9bc3ff" strokeWidth="1" />
      <path d="M4 5.4v5.2 M4 12h6.6 M12 4l0 6.6" stroke="#9bc3ff" strokeWidth="1" fill="none" />
    </svg>
  );
}

function PinIcon() {
  return (
    <svg
      viewBox="0 0 16 16"
      width={10}
      height={10}
      className="shrink-0"
      aria-hidden
    >
      <path
        d="M8 1.5l1.6 3.6 3.9.4-2.95 2.7.85 3.9L8 10.2 4.6 12.1l.85-3.9L2.5 5.5l3.9-.4z"
        fill="#75aafc"
        opacity="0.7"
      />
    </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>
  );
}

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

function slugify(title: string, fallback: string): string {
  const slug = title
    .trim()
    .replace(/\s+/g, "-")
    .replace(/[^a-zA-Z0-9._-]/g, "")
    .toLowerCase();
  return slug.length > 0 ? slug : fallback;
}

interface SidebarSelection {
  directiveId: string;
  /** null = the directive's document; otherwise a task id (orchestrator/step). */
  taskId: string | null;
}

interface SidebarProps {
  directives: DirectiveSummary[];
  loading: boolean;
  selection: SidebarSelection | null;
  onSelect: (sel: SidebarSelection) => void;
}

/**
 * Per-directive folder. Renders the directive as a collapsible folder whose
 * body is the pinned document entry (always first) followed by a `tasks/`
 * subfolder containing the orchestrator, completion, and step tasks.
 *
 * Status dot lives on the right side only (single-side, per the v2 design).
 * If a directive or task has a pending user question, its icon glows.
 */
function DirectiveFolder({
  directive,
  open,
  onToggle,
  selection,
  onSelect,
  pendingTaskIds,
  hasPendingForDirective,
}: {
  directive: DirectiveSummary;
  open: boolean;
  onToggle: () => void;
  selection: SidebarSelection | null;
  onSelect: (sel: SidebarSelection) => void;
  /** Set of task ids that currently have pending user questions. */
  pendingTaskIds: Set<string>;
  /** Whether any pending question is associated with this directive. */
  hasPendingForDirective: boolean;
}) {
  const dotColor = STATUS_DOT[directive.status] ?? STATUS_DOT.draft;
  const fileName = `${slugify(directive.title, directive.id.slice(0, 8))}.md`;

  // Lazy fetch full directive (with steps) only when folder is open.
  const { directive: detailed } = useDirective(open ? directive.id : undefined);

  const docSelected =
    selection?.directiveId === directive.id && selection.taskId === null;

  // Collect the tasks to surface in the folder body.
  const tasks = useMemo(() => collectTasks(detailed, directive), [detailed, directive]);

  const orchestratorRunning = !!directive.orchestratorTaskId;
  // Tasks subfolder open state — independent of the directive folder.
  const [tasksOpen, setTasksOpen] = useState<boolean>(true);

  return (
    <div className="select-none">
      <button
        type="button"
        onClick={onToggle}
        title={directive.title}
        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.05)]"
      >
        <Caret open={open} />
        <FolderIcon open={open} />
        <span className="truncate flex-1 text-left">{directive.title}</span>
        {/* Status dot — RIGHT side only. Glows when this directive has a
            pending user question, or pulses when the orchestrator is live. */}
        <StatusDot
          color={dotColor}
          live={orchestratorRunning}
          glow={hasPendingForDirective}
          status={directive.status}
        />
      </button>

      {open && (
        <ul className="py-0.5">
          {/* Pinned document entry — always at the top of the folder. */}
          <li>
            <button
              type="button"
              onClick={() =>
                onSelect({ directiveId: directive.id, taskId: null })
              }
              className={`w-full text-left flex items-center gap-1.5 pl-8 pr-3 py-1 font-mono text-[11px] transition-colors ${
                docSelected
                  ? "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"
              }`}
            >
              <PinIcon />
              <FileIcon />
              <span className="truncate flex-1">{fileName}</span>
            </button>
          </li>

          {/* tasks/ subfolder — collapsible, contains orchestrator/completion/steps. */}
          <li>
            <button
              type="button"
              onClick={() => setTasksOpen((p) => !p)}
              className="w-full flex items-center gap-1.5 pl-8 pr-3 py-1 font-mono text-[11px] text-[#9bc3ff] hover:bg-[rgba(117,170,252,0.05)]"
            >
              <Caret open={tasksOpen} />
              <FolderIcon open={tasksOpen} />
              <span className="truncate flex-1 text-left">tasks/</span>
              {tasks.length > 0 && (
                <span className="text-[10px] text-[#556677]">{tasks.length}</span>
              )}
            </button>

            {tasksOpen && (
              <ul className="py-0.5">
                {tasks.length === 0 ? (
                  <li className="pl-14 pr-3 py-1 font-mono text-[10px] text-[#556677]">
                    No tasks yet
                  </li>
                ) : (
                  tasks.map((t) => {
                    const isSelected =
                      selection?.directiveId === directive.id &&
                      selection?.taskId === t.taskId;
                    const tdot = STEP_STATUS_DOT[t.status] ?? STEP_STATUS_DOT.pending;
                    const live =
                      t.status === "running" || t.kind === "orchestrator-active";
                    const glow = pendingTaskIds.has(t.taskId);
                    const Icon =
                      t.kind === "completion" ? CompletionIcon : TaskIcon;
                    return (
                      <li key={t.taskId}>
                        <button
                          type="button"
                          onClick={() =>
                            onSelect({
                              directiveId: directive.id,
                              taskId: t.taskId,
                            })
                          }
                          title={t.label}
                          className={`w-full text-left flex items-center gap-1.5 pl-14 pr-3 py-1 font-mono text-[11px] transition-colors ${
                            isSelected
                              ? "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"
                          }`}
                        >
                          <Icon />
                          <span className="truncate flex-1">{t.label}</span>
                          <StatusDot
                            color={tdot}
                            live={live}
                            glow={glow}
                            status={t.status}
                          />
                        </button>
                      </li>
                    );
                  })
                )}
              </ul>
            )}
          </li>
        </ul>
      )}
    </div>
  );
}

/**
 * Right-side status indicator. Composes the colored status dot with optional
 * "live" pulse (orchestrator running) and "glow" attention ring (pending user
 * question waiting on a response).
 */
function StatusDot({
  color,
  live,
  glow,
  status,
}: {
  color: string;
  live: boolean;
  glow: boolean;
  status: string;
}) {
  // The glow is a soft amber ring pulsed via box-shadow. Keep it subtle so it
  // doesn't fight the live pulse for attention when both are present.
  const ring = glow
    ? "shadow-[0_0_0_2px_rgba(251,191,36,0.45),0_0_8px_2px_rgba(251,191,36,0.55)] animate-pulse"
    : "";
  const livePulse = live && !glow ? "animate-pulse" : "";
  const title = glow
    ? `${status} — needs response`
    : live
    ? `${status} — running`
    : `status: ${status}`;
  return (
    <span
      className={`inline-block w-2 h-2 rounded-full shrink-0 ${color} ${ring} ${livePulse}`}
      aria-label={title}
      title={title}
    />
  );
}

interface FolderTaskRow {
  taskId: string;
  label: string;
  status: string;
  kind: "orchestrator-active" | "completion" | "step";
}

function collectTasks(
  detailed: DirectiveWithSteps | null,
  summary: DirectiveSummary,
): FolderTaskRow[] {
  const rows: FolderTaskRow[] = [];

  // Orchestrator (planner) — surfaces only while it's actively running so
  // the folder is not flooded with stale orchestrator entries.
  const orchestratorId =
    detailed?.orchestratorTaskId ?? summary.orchestratorTaskId ?? null;
  if (orchestratorId) {
    rows.push({
      taskId: orchestratorId,
      label: "orchestrator",
      status: "running",
      kind: "orchestrator-active",
    });
  }

  // Completion (PR creation) task.
  const completionId =
    detailed?.completionTaskId ?? summary.completionTaskId ?? null;
  if (completionId) {
    rows.push({
      taskId: completionId,
      label: "completion",
      status: "running",
      kind: "completion",
    });
  }

  // Step tasks — only steps that have actually been started have a taskId.
  if (detailed) {
    for (const step of detailed.steps) {
      if (!step.taskId) continue;
      rows.push({
        taskId: step.taskId,
        label: step.name,
        status: step.status,
        kind: "step",
      });
    }
  }

  return rows;
}

interface SidebarProps {
  directives: DirectiveSummary[];
  loading: boolean;
  selection: SidebarSelection | null;
  onSelect: (sel: SidebarSelection) => void;
}

function DocumentSidebar({ directives, loading, selection, onSelect }: SidebarProps) {
  // Pending user questions — drives the "glow" attention ring. We split into
  // two indices so the directive folder header glows whenever ANY of its
  // tasks has a pending question, while individual task rows glow only for
  // their own question.
  const { pendingQuestions } = useSupervisorQuestions();
  const { directivesWithPending, tasksWithPending } = useMemo(() => {
    const dirs = new Set<string>();
    const tasks = new Set<string>();
    for (const q of pendingQuestions) {
      if (q.directiveId) dirs.add(q.directiveId);
      if (q.taskId) tasks.add(q.taskId);
    }
    return { directivesWithPending: dirs, tasksWithPending: tasks };
  }, [pendingQuestions]);

  // Sort active first, then idle, then paused, then archived.
  const sorted = useMemo(() => {
    const order: Record<DirectiveStatus, number> = {
      active: 0,
      paused: 1,
      idle: 2,
      draft: 3,
      archived: 4,
    };
    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]);

  // Track which directive folders are open. The currently selected directive
  // is forced open so deep links land on something visible.
  const [openIds, setOpenIds] = useState<Set<string>>(new Set());
  const lastSelectedRef = useRef<string | null>(null);
  useEffect(() => {
    if (selection && selection.directiveId !== lastSelectedRef.current) {
      lastSelectedRef.current = selection.directiveId;
      setOpenIds((prev) => {
        if (prev.has(selection.directiveId)) return prev;
        const next = new Set(prev);
        next.add(selection.directiveId);
        return next;
      });
    }
  }, [selection]);

  const toggleOpen = useCallback((id: string) => {
    setOpenIds((prev) => {
      const next = new Set(prev);
      if (next.has(id)) next.delete(id);
      else next.add(id);
      return next;
    });
  }, []);

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

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

      {/* Body */}
      <div className="flex-1 overflow-y-auto pb-4">
        {loading && directives.length === 0 ? (
          <div className="px-3 py-6 text-center text-[#556677] font-mono text-[11px]">
            Loading...
          </div>
        ) : directives.length === 0 ? (
          <div className="px-3 py-6 text-center text-[#556677] font-mono text-[11px]">
            No directives yet
          </div>
        ) : (
          sorted.map((d) => (
            <DirectiveFolder
              key={d.id}
              directive={d}
              open={openIds.has(d.id)}
              onToggle={() => toggleOpen(d.id)}
              selection={selection}
              onSelect={onSelect}
              pendingTaskIds={tasksWithPending}
              hasPendingForDirective={directivesWithPending.has(d.id)}
            />
          ))
        )}
      </div>
    </div>
  );
}

// =============================================================================
// Editor shell — wraps DocumentEditor and handles the "no document selected"
// and loading states.
// =============================================================================

interface EditorShellProps {
  selectedId: string | undefined;
  selectedTaskId: string | null;
  hasDirectives: boolean;
  listLoading: boolean;
  onClearTask: () => void;
}

function EditorShell({
  selectedId,
  selectedTaskId,
  hasDirectives,
  listLoading,
  onClearTask,
}: EditorShellProps) {
  const {
    directive,
    loading,
    updateGoal,
    cleanup,
    createPR,
    pickUpOrders,
  } = useDirective(selectedId);

  if (!selectedId) {
    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 documents yet — create one from the legacy UI"}
        </p>
      </div>
    );
  }

  if (loading && !directive) {
    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 (!directive) {
    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>
    );
  }

  // Resolve the label for the breadcrumb when a task is selected.
  const taskLabel = selectedTaskId
    ? selectedTaskId === directive.orchestratorTaskId
      ? "orchestrator"
      : selectedTaskId === directive.completionTaskId
      ? "completion"
      : directive.steps.find((s) => s.taskId === selectedTaskId)?.name ??
        selectedTaskId.slice(0, 8)
    : null;

  return (
    <div className="flex-1 flex flex-col h-full overflow-hidden">
      {/* Document header — breadcrumb-like, mirrors a code editor's tab bar */}
      <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.id.slice(0, 8)}</span>
          {selectedTaskId && (
            <>
              <span>/</span>
              <span className="text-[#9bc3ff]">{taskLabel}</span>
              <button
                type="button"
                onClick={onClearTask}
                className="ml-2 px-1.5 py-0.5 text-[#7788aa] hover:text-white border border-[#2a3a5a] rounded normal-case"
              >
                back to document
              </button>
            </>
          )}
          {!selectedTaskId && !!directive.orchestratorTaskId && (
            <span className="ml-2 inline-flex items-center gap-1 text-yellow-400">
              <span className="inline-block w-1.5 h-1.5 rounded-full bg-yellow-400 animate-pulse" />
              orchestrator running
            </span>
          )}
        </div>
      </div>

      {selectedTaskId ? (
        <DocumentTaskStream
          taskId={selectedTaskId}
          label={taskLabel ?? selectedTaskId.slice(0, 8)}
        />
      ) : (
        <DocumentEditor
          directive={directive}
          onUpdateGoal={async (goal) => {
            await updateGoal(goal);
          }}
          onCleanup={async () => {
            await cleanup();
          }}
          onCreatePR={async () => {
            await createPR();
          }}
          onPickUpOrders={async () => {
            await pickUpOrders();
          }}
        />
      )}
    </div>
  );
}

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

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

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

  const onSelect = useCallback(
    (sel: SidebarSelection) => {
      const next = `/directives/${sel.directiveId}${
        sel.taskId ? `?task=${sel.taskId}` : ""
      }`;
      navigate(next);
    },
    [navigate],
  );

  const onClearTask = useCallback(() => {
    const next = new URLSearchParams(searchParams);
    next.delete("task");
    setSearchParams(next, { replace: true });
  }, [searchParams, setSearchParams]);

  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>
    );
  }

  const selection: SidebarSelection | null = selectedId
    ? { directiveId: selectedId, taskId: selectedTaskId }
    : null;

  return (
    <div className="relative z-10 min-h-screen flex flex-col bg-[#0a1628]">
      <Masthead showNav />
      <main
        className="flex-1 flex overflow-hidden"
        style={{ height: "calc(100vh - 80px)" }}
      >
        {/* Left: file-tree sidebar */}
        <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}
            onSelect={onSelect}
          />
        </div>

        {/* Right: Lexical editor / task stream */}
        <EditorShell
          selectedId={selectedId}
          selectedTaskId={selectedTaskId}
          hasDirectives={directives.length > 0}
          listLoading={listLoading}
          onClearTask={onClearTask}
        />
      </main>
    </div>
  );
}