summaryrefslogblamecommitdiff
path: root/makima/frontend/src/routes/document-directives.tsx
blob: 42e6a6972f376cf4567482d12560f473be370253 (plain) (tree)









































































































































































































































































































































































































                                                                                                                                                                                                                                            
import { useEffect, useMemo, useState } from "react";
import { useNavigate, useParams } 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, DirectiveStatus } 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]",
};

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

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

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

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

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

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

interface SidebarProps {
  directives: DirectiveSummary[];
  loading: boolean;
  selectedId: string | null;
  onSelect: (id: string) => void;
}

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

  // Default-collapsed state per folder. Archived is collapsed by default
  // (it's history); the other two are open so users see their work.
  const [openGroups, setOpenGroups] = useState<Record<SidebarGroup, boolean>>({
    active: true,
    idle: true,
    archived: false,
  });

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

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

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

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

                {/* Files inside the group */}
                {open && (
                  <ul className="py-0.5">
                    {list.map((d) => {
                      const isSelected = d.id === selectedId;
                      const dot = STATUS_DOT[d.status] ?? STATUS_DOT.draft;
                      const slug = d.title
                        .trim()
                        .replace(/\s+/g, "-")
                        .replace(/[^a-zA-Z0-9._-]/g, "")
                        .toLowerCase();
                      const fileName =
                        slug.length > 0 ? `${slug}.md` : `${d.id.slice(0, 8)}.md`;
                      const orchestratorRunning = !!d.orchestratorTaskId;
                      return (
                        <li key={d.id}>
                          <button
                            type="button"
                            onClick={() => onSelect(d.id)}
                            title={d.title}
                            className={`w-full text-left flex items-center gap-1.5 pl-9 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"
                            }`}
                          >
                            <FileIcon />
                            <span
                              className={`inline-block w-1.5 h-1.5 rounded-full shrink-0 ${dot}`}
                              aria-hidden
                            />
                            <span className="truncate flex-1">{fileName}</span>
                            {orchestratorRunning && (
                              <span
                                className="inline-block w-1.5 h-1.5 rounded-full shrink-0 bg-yellow-400 animate-pulse"
                                title="Orchestrator running"
                                aria-label="Orchestrator running"
                              />
                            )}
                          </button>
                        </li>
                      );
                    })}
                  </ul>
                )}
              </div>
            );
          })
        )}
      </div>
    </div>
  );
}

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

interface EditorShellProps {
  selectedId: string | undefined;
  hasDirectives: boolean;
  listLoading: boolean;
}

function EditorShell({ selectedId, hasDirectives, listLoading }: 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>
    );
  }

  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>
          {!!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>

      {/* Lexical editor body */}
      <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 { directives, loading: listLoading } = useDirectives();

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

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

  return (
    <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-[240px] 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}
            selectedId={selectedId ?? null}
            onSelect={(id) => navigate(`/directives/${id}`)}
          />
        </div>

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