diff options
| author | soryu <soryu@soryu.co> | 2026-04-29 01:10:11 +0100 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2026-04-29 01:10:11 +0100 |
| commit | 4b1d608b839769052634b4facc345b891d468926 (patch) | |
| tree | 1d5ff45b5b34b2e3e378a4cf69fd62ff39cf12de /makima/frontend/src/routes | |
| parent | 5bde7c2d7e099fd9c8b2615602ab1d096bd9b6be (diff) | |
| download | soryu-4b1d608b839769052634b4facc345b891d468926.tar.gz soryu-4b1d608b839769052634b4facc345b891d468926.zip | |
feat: document-mode directive UI proof of concept (Lexical) (#101)
* WIP: heartbeat checkpoint
* feat: soryu-co/soryu - makima: Backend: feature flag + goal-edit interrupt messaging
* WIP: heartbeat checkpoint
* WIP: heartbeat checkpoint
* feat: soryu-co/soryu - makima: Frontend: Lexical document editor with step blocks, context menu, countdown
Diffstat (limited to 'makima/frontend/src/routes')
| -rw-r--r-- | makima/frontend/src/routes/directives.tsx | 33 | ||||
| -rw-r--r-- | makima/frontend/src/routes/document-directives.tsx | 394 | ||||
| -rw-r--r-- | makima/frontend/src/routes/settings.tsx | 64 |
3 files changed, 491 insertions, 0 deletions
diff --git a/makima/frontend/src/routes/directives.tsx b/makima/frontend/src/routes/directives.tsx index 8de0335..895c86a 100644 --- a/makima/frontend/src/routes/directives.tsx +++ b/makima/frontend/src/routes/directives.tsx @@ -5,10 +5,43 @@ import { DirectiveList } from "../components/directives/DirectiveList"; import { DirectiveDetail } from "../components/directives/DirectiveDetail"; import { useDirectives, useDirective } from "../hooks/useDirectives"; import { useDogs } from "../hooks/useDogs"; +import { useUserSettings } from "../hooks/useUserSettings"; import { useAuth } from "../contexts/AuthContext"; +import DocumentDirectivesPage from "./document-directives"; import { getRepositorySuggestions, startDirective, pauseDirective, updateDirective, type RepositoryHistoryEntry, type DirectiveSummary } from "../lib/api"; +/** + * Top-level /directives route. Gates between the legacy tabular UI and the + * Document Mode (POC) UI based on the user's settings flag. + * + * Both code paths support /directives/:id deep links — the param is read by + * each branch independently via useParams. + */ export default function DirectivesPage() { + const { settings, loading: settingsLoading } = useUserSettings(); + + // While settings are loading for the very first time, render nothing inside + // a Masthead-wrapped shell so we don't briefly flash the legacy UI just to + // swap to document mode a moment later. + if (settingsLoading && !settings) { + 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> + ); + } + + if (settings?.documentModeEnabled) { + return <DocumentDirectivesPage />; + } + + return <LegacyDirectivesPage />; +} + +function LegacyDirectivesPage() { const { isAuthenticated, isAuthConfigured, isLoading: authLoading } = useAuth(); const navigate = useNavigate(); const { id: selectedId } = useParams<{ id: string }>(); diff --git a/makima/frontend/src/routes/document-directives.tsx b/makima/frontend/src/routes/document-directives.tsx new file mode 100644 index 0000000..42e6a69 --- /dev/null +++ b/makima/frontend/src/routes/document-directives.tsx @@ -0,0 +1,394 @@ +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> + ); +} diff --git a/makima/frontend/src/routes/settings.tsx b/makima/frontend/src/routes/settings.tsx index 73537bd..a77ad95 100644 --- a/makima/frontend/src/routes/settings.tsx +++ b/makima/frontend/src/routes/settings.tsx @@ -2,6 +2,7 @@ import { useState, useEffect, type FormEvent } from "react"; import { useAuth } from "../contexts/AuthContext"; import { useNavigate } from "react-router"; import { Masthead } from "../components/Masthead"; +import { useUserSettings } from "../hooks/useUserSettings"; import { getApiKey, createApiKey, @@ -267,6 +268,11 @@ export default function SettingsPage() { const { user, isAuthConfigured, signOut } = useAuth(); const navigate = useNavigate(); + // User settings (feature flags) state + const { settings: userSettings, loading: userSettingsLoading, update: updateUserSettings } = useUserSettings(); + const [featureFlagSaving, setFeatureFlagSaving] = useState(false); + const [featureFlagError, setFeatureFlagError] = useState<string | null>(null); + // API Key state const [apiKeyInfo, setApiKeyInfo] = useState<ApiKeyInfo | null>(null); const [newKey, setNewKey] = useState<string | null>(null); @@ -490,6 +496,21 @@ export default function SettingsPage() { } }; + // Feature flag toggle handlers + const handleToggleDocumentMode = async () => { + if (featureFlagSaving) return; + setFeatureFlagError(null); + setFeatureFlagSaving(true); + try { + const next = !(userSettings?.documentModeEnabled ?? false); + await updateUserSettings({ documentModeEnabled: next }); + } catch (err) { + setFeatureFlagError(err instanceof Error ? err.message : "Failed to update setting"); + } finally { + setFeatureFlagSaving(false); + } + }; + const passwordStrength = getPasswordStrength(passwordForm.newPassword); return ( @@ -789,6 +810,49 @@ export default function SettingsPage() { </section> )} + {/* Feature Flags (POC) */} + <section className="border border-[rgba(117,170,252,0.25)] bg-[#0d1b2d] p-4"> + <SectionHeader>Feature Flags (POC)</SectionHeader> + {featureFlagError && <ErrorAlert>{featureFlagError}</ErrorAlert>} + <div className="flex items-start gap-3"> + <button + type="button" + role="switch" + aria-checked={userSettings?.documentModeEnabled ?? false} + aria-label="Document Mode for directives" + onClick={handleToggleDocumentMode} + disabled={userSettingsLoading || featureFlagSaving} + className={`relative shrink-0 mt-0.5 w-10 h-5 border transition-colors disabled:opacity-50 disabled:cursor-not-allowed ${ + userSettings?.documentModeEnabled + ? "bg-[#3f6fb3] border-[#75aafc]" + : "bg-[#0a1628] border-[rgba(117,170,252,0.35)]" + }`} + > + <span + className={`absolute top-0.5 left-0.5 w-3.5 h-3.5 transition-transform ${ + userSettings?.documentModeEnabled + ? "translate-x-5 bg-white" + : "translate-x-0 bg-[#9bc3ff]" + }`} + /> + </button> + <div className="flex-1 min-w-0"> + <div className="font-mono text-xs text-[#9bc3ff] mb-1"> + Document Mode for directives + </div> + <p className="font-mono text-[10px] text-[#7788aa] leading-snug"> + Replaces the tabular directives UI with a Lexical-based interactive + document editor. Proof of concept; expect rough edges. + </p> + {(userSettingsLoading || featureFlagSaving) && ( + <p className="font-mono text-[10px] text-[#556677] mt-1"> + {userSettingsLoading ? "Loading..." : "Saving..."} + </p> + )} + </div> + </div> + </section> + {/* Danger Zone */} {isAuthConfigured && user && ( <section className="border border-red-900/50 bg-[#0d1b2d] p-4"> |
