summaryrefslogtreecommitdiff
path: root/makima/frontend/src
diff options
context:
space:
mode:
Diffstat (limited to 'makima/frontend/src')
-rw-r--r--makima/frontend/src/components/directives/DocumentEditor.tsx282
-rw-r--r--makima/frontend/src/routes/document-directives.tsx54
2 files changed, 282 insertions, 54 deletions
diff --git a/makima/frontend/src/components/directives/DocumentEditor.tsx b/makima/frontend/src/components/directives/DocumentEditor.tsx
index 661665c..0d6a391 100644
--- a/makima/frontend/src/components/directives/DocumentEditor.tsx
+++ b/makima/frontend/src/components/directives/DocumentEditor.tsx
@@ -69,8 +69,6 @@ import {
*/
const COUNTDOWN_FRESH_MS = 60_000;
const COUNTDOWN_RUNNING_MS = 10_000;
-/** The countdown bar only appears once we're inside this many ms from firing. */
-const BAR_VISIBLE_MS = 10_000;
const SAVED_TOAST_MS = 1200;
/**
* Drafts are written synchronously to localStorage on every keystroke. We used
@@ -722,9 +720,66 @@ function EditorContextMenu({
// Countdown bar
// =============================================================================
+/**
+ * Compact circular countdown indicator. The ring fills (clockwise from 12
+ * o'clock) as `progress` approaches 1. Uses `currentColor` so the parent's
+ * text tone drives both the rest of the row and the ring.
+ */
+function CircularTimer({
+ progress,
+ size = 14,
+ stroke = 2,
+ className,
+ title,
+}: {
+ progress: number;
+ size?: number;
+ stroke?: number;
+ className?: string;
+ title?: string;
+}) {
+ const r = (size - stroke) / 2;
+ const c = 2 * Math.PI * r;
+ const dash = Math.max(0, Math.min(1, progress)) * c;
+ return (
+ <svg
+ width={size}
+ height={size}
+ viewBox={`0 0 ${size} ${size}`}
+ className={className}
+ aria-hidden
+ >
+ {title ? <title>{title}</title> : null}
+ <circle
+ cx={size / 2}
+ cy={size / 2}
+ r={r}
+ fill="none"
+ stroke="currentColor"
+ strokeOpacity={0.25}
+ strokeWidth={stroke}
+ />
+ <circle
+ cx={size / 2}
+ cy={size / 2}
+ r={r}
+ fill="none"
+ stroke="currentColor"
+ strokeWidth={stroke}
+ strokeDasharray={`${dash} ${c}`}
+ strokeLinecap="round"
+ transform={`rotate(-90 ${size / 2} ${size / 2})`}
+ />
+ </svg>
+ );
+}
+
interface SaveCountdownBarProps {
state: "idle" | "dirty" | "pending" | "saving" | "saved" | "error";
remainingMs: number;
+ /** Total countdown window (varies by orchestrator state). Used to compute
+ * the circular timer's fill ratio. */
+ countdownMs: number;
liveStart: boolean;
orchestratorRunning: boolean;
draftSavedAt: number | null;
@@ -736,6 +791,7 @@ interface SaveCountdownBarProps {
function SaveCountdownBar({
state,
remainingMs,
+ countdownMs,
liveStart,
orchestratorRunning,
draftSavedAt,
@@ -747,46 +803,65 @@ function SaveCountdownBar({
// observe save state at all times — and to have a "Save now" button they
// can hit without waiting for the countdown.
- let label: string;
- let progressPct = 0;
let tone = "border-[rgba(117,170,252,0.3)] text-[#9bc3ff]";
+ if (state === "saving" || state === "saved") {
+ tone = "border-emerald-700 text-emerald-300";
+ } else if (state === "error") {
+ tone = "border-red-700 text-red-300";
+ } else if (state === "idle") {
+ tone = "border-[rgba(117,170,252,0.2)] text-[#7788aa]";
+ }
+ // Left-side primary indicator. For `pending` and `saving`, the circular
+ // timer is the visual; the text next to it is short and neutral. For
+ // every other state, we render plain text only.
+ let primary: React.ReactNode;
if (state === "pending") {
+ const ringProgress =
+ countdownMs > 0
+ ? Math.max(0, Math.min(1, 1 - remainingMs / countdownMs))
+ : 0;
const seconds = Math.max(0, Math.ceil(remainingMs / 1000));
- // Show ticking countdown in the last 10s, otherwise a quieter label.
- if (remainingMs <= BAR_VISIBLE_MS) {
+ const tipBase = orchestratorRunning
+ ? `Replans in ${seconds}s — Esc/Undo cancels`
+ : `Auto-saves in ${seconds}s — Esc/Undo cancels`;
+ const ringLabel = orchestratorRunning ? "Replan" : "Auto-save";
+ primary = (
+ <span
+ className="flex items-center gap-1.5 text-[10px] font-mono flex-1 min-w-0 truncate"
+ title={tipBase}
+ >
+ <CircularTimer
+ progress={ringProgress}
+ className="shrink-0"
+ title={tipBase}
+ />
+ <span className="truncate">{ringLabel}</span>
+ </span>
+ );
+ } else if (state === "saving") {
+ primary = (
+ <span className="flex items-center gap-1.5 text-[10px] font-mono flex-1 min-w-0 truncate">
+ <CircularTimer progress={1} className="shrink-0 animate-pulse" />
+ <span className="truncate">Saving…</span>
+ </span>
+ );
+ } else {
+ let label: string;
+ if (state === "dirty") {
label = orchestratorRunning
- ? `Replanning in ${seconds}s — Esc/Undo cancels.`
- : `Saving in ${seconds}s — Esc/Undo cancels.`;
- progressPct = Math.max(
- 0,
- Math.min(100, (1 - remainingMs / BAR_VISIBLE_MS) * 100),
- );
+ ? "Unsaved changes — saving will replan the contract."
+ : "Unsaved changes.";
+ } else if (state === "saved") {
+ label = "Saved";
+ } else if (state === "error") {
+ label = "Save failed — try again.";
} else {
- label = "Unsaved changes — auto-save soon.";
- progressPct = 0;
+ label = "Up to date.";
}
- } else if (state === "dirty") {
- label = orchestratorRunning
- ? "Unsaved changes — saving will replan the contract."
- : "Unsaved changes.";
- progressPct = 0;
- } else if (state === "saving") {
- label = "Saving…";
- progressPct = 100;
- tone = "border-emerald-700 text-emerald-300";
- } else if (state === "saved") {
- label = "Saved";
- progressPct = 100;
- tone = "border-emerald-700 text-emerald-300";
- } else if (state === "error") {
- label = "Save failed — try again.";
- progressPct = 100;
- tone = "border-red-700 text-red-300";
- } else {
- label = "Up to date.";
- progressPct = 0;
- tone = "border-[rgba(117,170,252,0.2)] text-[#7788aa]";
+ primary = (
+ <span className="text-[10px] font-mono flex-1 truncate">{label}</span>
+ );
}
// Right-side "Draft saved Xs ago" stamp — re-renders on a 1Hz ticker so
@@ -801,14 +876,8 @@ function SaveCountdownBar({
className={`shrink-0 border-t border-dashed ${tone} bg-[#0a1628]`}
data-makima-countdown={state}
>
- <div className="h-0.5 bg-[#10203a]">
- <div
- className="h-full bg-[#75aafc] transition-[width] duration-100 ease-linear"
- style={{ width: `${progressPct}%` }}
- />
- </div>
<div className="flex items-center gap-3 px-4 py-1.5">
- <span className="text-[10px] font-mono flex-1 truncate">{label}</span>
+ {primary}
{draftLabel && (
<span
@@ -877,6 +946,19 @@ export interface DocumentEditorProps {
onCleanup: () => Promise<void> | void;
onCreatePR: () => Promise<void> | void;
onPickUpOrders: () => Promise<unknown> | unknown;
+ /**
+ * Whether the body content is currently editable. Locked (non-`draft`)
+ * contracts pass `false`, which puts Lexical into read-only mode, disables
+ * the autosave state machine, and surfaces the "Unlock to edit" affordance.
+ */
+ editable: boolean;
+ /**
+ * Invoked when the user clicks the inline "Unlock" affordance shown above
+ * the body for non-`draft` contracts. The caller is expected to flip the
+ * contract status back to `draft` (typically via `unlockDirectiveContract`)
+ * and refresh the document so this component receives `editable=true`.
+ */
+ onRequestUnlock: () => void | Promise<void>;
}
type SaveState = "idle" | "dirty" | "pending" | "saving" | "saved" | "error";
@@ -890,8 +972,14 @@ export function DocumentEditor({
onCleanup,
onCreatePR,
onPickUpOrders,
+ editable,
+ onRequestUnlock,
}: DocumentEditorProps) {
// ---- Lexical config ----------------------------------------------------
+ // NOTE: `editable` is only read by Lexical at composer mount time. Since
+ // we re-key the composer by `documentId` (not status), we ALSO mirror the
+ // `editable` prop into the live editor via <EditorEditableSync> below on
+ // every change.
const initialConfig = useMemo(
() => ({
// Re-key the composer when the directive id changes so we get a clean
@@ -904,8 +992,9 @@ export function DocumentEditor({
},
nodes: [HeadingNode, ListNode, ListItemNode, StepsBlockNode],
theme: editorTheme,
- editable: true,
+ editable,
}),
+ // eslint-disable-next-line react-hooks/exhaustive-deps
[documentId],
);
@@ -1148,6 +1237,12 @@ export function DocumentEditor({
const handleGoalChange = useCallback(
(goal: string) => {
+ // When the body is locked (contract is not in `draft`), Lexical's
+ // setEditable(false) blocks user input, but the OnChangePlugin can
+ // still fire for programmatic seeds. Ignore everything in that case
+ // so we don't kick the autosave state machine.
+ if (!editable) return;
+
pendingGoalRef.current = goal;
// 1. Always persist work-in-progress to localStorage IMMEDIATELY so
@@ -1185,9 +1280,21 @@ export function DocumentEditor({
setSaveState("dirty");
}
},
- [body, documentId, liveStart, saveState, startOrExtendCountdown],
+ [body, documentId, editable, liveStart, saveState, startOrExtendCountdown],
);
+ // When the body becomes locked (contract status leaves `draft`), tear
+ // down any in-flight autosave: timers off, state back to idle. The
+ // existing per-keystroke localStorage draft remains untouched — it'll
+ // re-hydrate the editor if/when the user unlocks again.
+ useEffect(() => {
+ if (!editable) {
+ cancelTimers();
+ setSaveState("idle");
+ setRemainingMs(countdownMs);
+ }
+ }, [editable, countdownMs]);
+
// ---- Right-click context menu -----------------------------------------
const [menu, setMenu] = useState<{ x: number; y: number } | null>(null);
@@ -1196,6 +1303,35 @@ export function DocumentEditor({
setMenu({ x: e.clientX, y: e.clientY });
}, []);
+ // ---- Unlock affordance state -----------------------------------------
+ // When the contract is locked (non-`draft`), the body shows an inline
+ // banner with an Unlock button that calls back into the parent. We
+ // surface any error from the unlock API inline rather than throwing —
+ // mirrors the inline error pattern in ContractHeader.
+ const [unlockBusy, setUnlockBusy] = useState(false);
+ const [unlockError, setUnlockError] = useState<string | null>(null);
+
+ // Clear any stale unlock error once the body becomes editable again.
+ useEffect(() => {
+ if (editable) {
+ setUnlockError(null);
+ setUnlockBusy(false);
+ }
+ }, [editable]);
+
+ const handleUnlockClick = useCallback(async () => {
+ if (unlockBusy) return;
+ setUnlockBusy(true);
+ setUnlockError(null);
+ try {
+ await onRequestUnlock();
+ } catch (e) {
+ setUnlockError(e instanceof Error ? e.message : "Unlock failed");
+ } finally {
+ setUnlockBusy(false);
+ }
+ }, [onRequestUnlock, unlockBusy]);
+
// ---- Render ------------------------------------------------------------
return (
<div className="flex flex-col h-full overflow-hidden">
@@ -1203,12 +1339,19 @@ export function DocumentEditor({
<LexicalComposer key={documentId} initialConfig={initialConfig}>
{/* Capture the editor ref via a tiny inline plugin */}
<EditorRefCapture editorRef={editorRef} />
+ {/* Keep Lexical's editable flag in sync with the parent prop. */}
+ <EditorEditableSync editable={editable} />
<SeedContentPlugin
documentId={documentId}
title={title}
body={body}
onDraftRestored={(draft) => {
pendingGoalRef.current = draft;
+ // Don't kick the autosave state machine if the body is
+ // currently locked — the lock-watching useEffect would just
+ // tear it back down on the next render, and we don't want
+ // a flash of "pending" state under a locked banner.
+ if (!editable) return;
if (liveStart) {
startOrExtendCountdown();
} else {
@@ -1228,10 +1371,33 @@ export function DocumentEditor({
<FloatingFormatToolbar />
<div
- className="flex-1 overflow-auto"
+ className={`flex-1 overflow-auto ${!editable ? "bg-[rgba(120,80,0,0.04)]" : ""}`}
onContextMenu={handleContextMenu}
>
<div className="max-w-3xl mx-auto px-8 py-10">
+ {!editable && (
+ <div
+ className="mb-4 flex items-center gap-3 border border-amber-700/60 bg-amber-900/10 rounded px-3 py-2 text-[11px] font-mono text-amber-300"
+ data-makima-locked-banner
+ >
+ <span className="flex-1">
+ Locked — unlock to edit.
+ </span>
+ {unlockError && (
+ <span className="text-red-400" title={unlockError}>
+ {unlockError}
+ </span>
+ )}
+ <button
+ type="button"
+ onClick={() => void handleUnlockClick()}
+ disabled={unlockBusy}
+ className="px-2 py-0.5 border border-amber-700/60 rounded text-amber-200 hover:text-white hover:bg-amber-900/30 disabled:opacity-50 disabled:cursor-not-allowed uppercase tracking-wide"
+ >
+ {unlockBusy ? "Unlocking…" : "Unlock"}
+ </button>
+ </div>
+ )}
<RichTextPlugin
contentEditable={
<ContentEditable
@@ -1241,7 +1407,16 @@ export function DocumentEditor({
Describe the contract's goal…
</div>
}
- className="outline-none font-mono text-[13px] leading-relaxed text-[#dbe7ff] [&_.makima-doc-h1]:text-[24px] [&_.makima-doc-h1]:font-medium [&_.makima-doc-h1]:text-white [&_.makima-doc-h1]:mb-3 [&_.makima-doc-h1]:tracking-tight [&_.makima-doc-paragraph]:my-2 [&_.makima-doc-paragraph]:text-[13px] [&_.makima-doc-paragraph]:text-[#c0d0e0] relative"
+ // When locked, dim the text and switch the caret to the
+ // default arrow so the body visually reads as read-only.
+ // Lexical's setEditable(false) already blocks input; this
+ // is the cosmetic layer.
+ style={!editable ? { cursor: "default" } : undefined}
+ className={`outline-none font-mono text-[13px] leading-relaxed [&_.makima-doc-h1]:text-[24px] [&_.makima-doc-h1]:font-medium [&_.makima-doc-h1]:mb-3 [&_.makima-doc-h1]:tracking-tight [&_.makima-doc-paragraph]:my-2 [&_.makima-doc-paragraph]:text-[13px] relative ${
+ editable
+ ? "text-[#dbe7ff] [&_.makima-doc-h1]:text-white [&_.makima-doc-paragraph]:text-[#c0d0e0]"
+ : "text-[#7a8aa0] [&_.makima-doc-h1]:text-[#a8b8d0] [&_.makima-doc-paragraph]:text-[#7a8aa0] select-text"
+ }`}
/>
}
ErrorBoundary={LexicalErrorBoundary}
@@ -1254,6 +1429,7 @@ export function DocumentEditor({
<SaveCountdownBar
state={saveState}
remainingMs={remainingMs}
+ countdownMs={countdownMs}
liveStart={liveStart}
orchestratorRunning={orchestratorRunning}
draftSavedAt={draftSavedAt}
@@ -1303,6 +1479,20 @@ function EditorRefCapture({
return null;
}
+/**
+ * Mirrors the `editable` prop onto the live Lexical editor instance. Lexical
+ * only reads `editable` from the initial config at mount; this plugin keeps
+ * the runtime state in sync as the parent's contract status flips between
+ * `draft` (editable) and the locked statuses (read-only).
+ */
+function EditorEditableSync({ editable }: { editable: boolean }) {
+ const [editor] = useLexicalComposerContext();
+ useEffect(() => {
+ editor.setEditable(editable);
+ }, [editor, editable]);
+ return null;
+}
+
// Re-export the steps-block helpers so consumers can include the node class
// in their own initial configs if needed.
export { $createStepsBlockNode, $isStepsBlockNode, StepsBlockNode };
diff --git a/makima/frontend/src/routes/document-directives.tsx b/makima/frontend/src/routes/document-directives.tsx
index 06e427a..479dcd8 100644
--- a/makima/frontend/src/routes/document-directives.tsx
+++ b/makima/frontend/src/routes/document-directives.tsx
@@ -266,6 +266,11 @@ function DirectiveFolder({
return { activeDocs: active, shippedDocs: shipped };
}, [docs]);
+ // When a directive owns more than one contract, the two `tasks/` folders
+ // would otherwise be ambiguous. We pass this down to DocumentTasksFolder
+ // so it can rename itself to `tasks - <contract name>/` for clarity.
+ const multipleContracts = activeDocs.length + shippedDocs.length > 1;
+
const handleCreate = useCallback(async () => {
if (creating) return;
setCreating(true);
@@ -433,6 +438,8 @@ function DirectiveFolder({
refreshNonce={refreshNonce}
selectedTaskId={selectedTaskIdForFolder}
onSelectTask={onSelectTask}
+ contractLabel={fileLabel(doc, directive)}
+ multipleContracts={multipleContracts}
/>
</div>
))}
@@ -477,6 +484,8 @@ function DirectiveFolder({
refreshNonce={refreshNonce}
selectedTaskId={selectedTaskIdForFolder}
onSelectTask={onSelectTask}
+ contractLabel={fileLabel(doc, directive)}
+ multipleContracts={multipleContracts}
/>
</div>
))}
@@ -627,6 +636,13 @@ interface DocumentTasksFolderProps {
selectedTaskId: string | null;
/** Click handler for step/task rows — navigates to the live transcript. */
onSelectTask: (directiveId: string, taskId: string) => void;
+ /** Human-readable contract label (already resolved via fileLabel). Used to
+ * disambiguate multiple tasks/ folders under the same directive. */
+ contractLabel: string;
+ /** True when the parent directive owns more than one contract — drives the
+ * `tasks - <contractLabel>/` rename so the two sibling tasks/ folders are
+ * distinguishable. Single-contract directives keep the plain `tasks/`. */
+ multipleContracts: boolean;
}
function DocumentTasksFolder({
@@ -637,6 +653,8 @@ function DocumentTasksFolder({
refreshNonce,
selectedTaskId,
onSelectTask,
+ contractLabel,
+ multipleContracts,
}: DocumentTasksFolderProps) {
const [open, setOpen] = useState(defaultOpen);
const [data, setData] = useState<ContractTasksResponse | null>(null);
@@ -672,24 +690,29 @@ function DocumentTasksFolder({
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;
- }
+ // Folder always renders (even when empty) so the user can click into a
+ // fresh contract's tasks/ folder and see it stay visible. The empty state
+ // shows a muted "no tasks yet" placeholder inside the open body — same
+ // visual weight as the existing "Loading tasks…" / error placeholders.
+ //
+ // When the parent directive owns multiple contracts, both tasks/ folders
+ // are disambiguated as `tasks - <contractLabel>/` so the user can tell
+ // them apart. Single-contract directives keep the plain `tasks/` label.
+ const headerLabel = multipleContracts
+ ? `tasks - ${contractLabel}/`
+ : "tasks/";
return (
<div>
<button
type="button"
onClick={() => setOpen((v) => !v)}
+ title={headerLabel}
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>
+ <span className="truncate text-left">{headerLabel}</span>
{total > 0 && (
<span className="ml-auto text-[10px] text-[#556677]">{total}</span>
)}
@@ -706,6 +729,11 @@ function DocumentTasksFolder({
{error}
</div>
)}
+ {data && total === 0 && !loading && !error && (
+ <div className={`${rowPadLeft} pr-3 py-1 font-mono text-[10px] text-[#556677] italic`}>
+ no tasks yet
+ </div>
+ )}
{data?.steps.map((step) => (
<StepRow
key={`step-${step.id}`}
@@ -1428,6 +1456,16 @@ function EditorShell({
onPickUpOrders={async () => {
await pickUpOrders();
}}
+ // Locked-and-started (`active`), `queued`, `shipped`, and
+ // `archived` contracts must be unlocked before edits are
+ // accepted. Only `draft` is freely editable; everything else
+ // shows the in-editor Unlock affordance.
+ editable={doc.status === "draft"}
+ onRequestUnlock={async () => {
+ const updated = await unlockDirectiveContract(doc.id);
+ setDoc(updated);
+ onDocumentChanged();
+ }}
/>
</div>
);