summaryrefslogtreecommitdiff
path: root/makima/frontend/src/routes
diff options
context:
space:
mode:
Diffstat (limited to 'makima/frontend/src/routes')
-rw-r--r--makima/frontend/src/routes/document-directives.tsx466
-rw-r--r--makima/frontend/src/routes/tmp.tsx10
2 files changed, 303 insertions, 173 deletions
diff --git a/makima/frontend/src/routes/document-directives.tsx b/makima/frontend/src/routes/document-directives.tsx
index 479dcd8..801e397 100644
--- a/makima/frontend/src/routes/document-directives.tsx
+++ b/makima/frontend/src/routes/document-directives.tsx
@@ -20,8 +20,8 @@ import {
listDirectiveContractTasks as listContractTasks,
startDirectiveContract,
pauseDirectiveContract,
- completeDirectiveContract,
unlockDirectiveContract,
+ reopenDirectiveContract,
reorderDirectiveContract,
createDirectiveTask,
startDirective,
@@ -32,9 +32,11 @@ import {
advanceDirective,
cleanupDirective,
pickUpOrders,
+ stopTask,
+ skipDirectiveStep,
} from "../lib/api";
-import { DirectiveContextMenu } from "../components/directives/DirectiveContextMenu";
-import { DocumentTaskStream } from "../components/directives/DocumentTaskStream";
+import { SidebarContextMenu, type ContextMenuItem } from "../components/SidebarContextMenu";
+import { TaskPage } from "../components/directives/TaskPage";
// 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.
@@ -182,11 +184,12 @@ interface DirectiveFolderProps {
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;
+ /** Right-click handler — opens the generic context menu with items
+ * built for the given entity type. */
+ onContextMenuDirective: (e: React.MouseEvent, directive: DirectiveSummary) => void;
+ onContextMenuContract: (e: React.MouseEvent, contract: Contract) => void;
+ onContextMenuStep: (e: React.MouseEvent, step: DirectiveStep, directiveId: string) => void;
+ onContextMenuTask: (e: React.MouseEvent, task: Task, directiveId: string) => void;
/** Click handler for task/step rows — navigates to the live transcript. */
onSelectTask: (directiveId: string, taskId: string) => void;
/**
@@ -204,9 +207,10 @@ function DirectiveFolder({
onHeaderClick,
selection,
onSelectDocument,
- onCreateDocument,
- onCreateEphemeralTask,
- onContextMenu,
+ onContextMenuDirective,
+ onContextMenuContract,
+ onContextMenuStep,
+ onContextMenuTask,
onSelectTask,
refreshNonce,
}: DirectiveFolderProps) {
@@ -226,9 +230,6 @@ function DirectiveFolder({
// 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);
@@ -271,18 +272,6 @@ function DirectiveFolder({
// 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);
- 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
@@ -318,16 +307,29 @@ function DirectiveFolder({
{/* 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"
+ delete / create-PR / update-PR / etc.).
+
+ Row is a div with onClick (not a <button>) so we can nest a
+ real `+` button inside that surfaces on hover for quick
+ contract / ephemeral-task creation without leaving the file
+ tree. */}
+ <div
+ role="button"
+ tabIndex={0}
onClick={() => {
onToggle();
onHeaderClick();
}}
- onContextMenu={(e) => onContextMenu(e, directive)}
+ onKeyDown={(e) => {
+ if (e.key === "Enter" || e.key === " ") {
+ e.preventDefault();
+ onToggle();
+ onHeaderClick();
+ }
+ }}
+ onContextMenu={(e) => onContextMenuDirective(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)]"
+ className="group 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)] cursor-pointer"
>
<Caret open={open} />
<FolderIcon open={open} />
@@ -337,6 +339,23 @@ function DirectiveFolder({
: directive.id.slice(0, 8)}
/
</span>
+ {/* Hover-only quick-add affordance — replaces the old inline
+ "+ New document" / "+ New ephemeral task" buttons that used
+ to sit inside the folder body. Builds the same items array
+ the directive context menu would, so right-click and `+`
+ stay in sync. */}
+ <button
+ type="button"
+ onClick={(e) => {
+ e.stopPropagation();
+ onContextMenuDirective(e, directive);
+ }}
+ className="opacity-0 group-hover:opacity-100 text-[12px] leading-none text-emerald-300 hover:text-white px-1"
+ title="New contract / ephemeral task"
+ aria-label="New contract or ephemeral task"
+ >
+ +
+ </button>
{orchestratorRunning && (
<span
className="inline-block w-1.5 h-1.5 rounded-full shrink-0 bg-yellow-400 animate-pulse"
@@ -350,7 +369,7 @@ function DirectiveFolder({
aria-label={`status: ${directive.status}`}
title={`status: ${directive.status}`}
/>
- </button>
+ </div>
{/* Folder body — rendered only when open */}
{open && (
@@ -369,36 +388,16 @@ function DirectiveFolder({
{/* 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>
+ {/* "New contract" / "New ephemeral task" used to be rendered
+ here as inline buttons. They're now reachable via the
+ directive folder's right-click context menu and the `+`
+ hover-button on the directive header (see DirectiveFolder
+ row above). Keeping the file tree free of action rows
+ makes the hierarchy easier to scan. */}
{activeDocs.length === 0 && !docsLoading && (
<div className="pl-14 pr-3 py-1 font-mono text-[10px] text-[#556677] italic">
- no active documents
+ no contracts yet — right-click to add
</div>
)}
@@ -413,6 +412,7 @@ function DirectiveFolder({
directive={directive}
selected={doc.id === selectedDocumentId}
onSelect={() => onSelectDocument(directive.id, doc)}
+ onContextMenu={onContextMenuContract}
draggable
onDragStart={() => setDragId(doc.id)}
onDragEnd={() => {
@@ -438,6 +438,8 @@ function DirectiveFolder({
refreshNonce={refreshNonce}
selectedTaskId={selectedTaskIdForFolder}
onSelectTask={onSelectTask}
+ onContextMenuStep={onContextMenuStep}
+ onContextMenuTask={onContextMenuTask}
contractLabel={fileLabel(doc, directive)}
multipleContracts={multipleContracts}
/>
@@ -474,6 +476,7 @@ function DirectiveFolder({
directive={directive}
selected={doc.id === selectedDocumentId}
onSelect={() => onSelectDocument(directive.id, doc)}
+ onContextMenu={onContextMenuContract}
indent="deep"
/>
<DocumentTasksFolder
@@ -484,6 +487,8 @@ function DirectiveFolder({
refreshNonce={refreshNonce}
selectedTaskId={selectedTaskIdForFolder}
onSelectTask={onSelectTask}
+ onContextMenuStep={onContextMenuStep}
+ onContextMenuTask={onContextMenuTask}
contractLabel={fileLabel(doc, directive)}
multipleContracts={multipleContracts}
/>
@@ -509,6 +514,7 @@ interface DocumentRowProps {
directive: DirectiveSummary;
selected: boolean;
onSelect: () => void;
+ onContextMenu: (e: React.MouseEvent, contract: Contract) => 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). */
@@ -527,6 +533,7 @@ function DocumentRow({
directive,
selected,
onSelect,
+ onContextMenu,
indent = "normal",
draggable = false,
onDragStart,
@@ -549,6 +556,7 @@ function DocumentRow({
<button
type="button"
onClick={onSelect}
+ onContextMenu={(e) => onContextMenu(e, doc)}
title={name}
draggable={draggable}
onDragStart={(e) => {
@@ -636,6 +644,8 @@ interface DocumentTasksFolderProps {
selectedTaskId: string | null;
/** Click handler for step/task rows — navigates to the live transcript. */
onSelectTask: (directiveId: string, taskId: string) => void;
+ onContextMenuStep: (e: React.MouseEvent, step: DirectiveStep, directiveId: string) => void;
+ onContextMenuTask: (e: React.MouseEvent, task: Task, directiveId: string) => void;
/** Human-readable contract label (already resolved via fileLabel). Used to
* disambiguate multiple tasks/ folders under the same directive. */
contractLabel: string;
@@ -653,6 +663,8 @@ function DocumentTasksFolder({
refreshNonce,
selectedTaskId,
onSelectTask,
+ onContextMenuStep,
+ onContextMenuTask,
contractLabel,
multipleContracts,
}: DocumentTasksFolderProps) {
@@ -688,7 +700,20 @@ function DocumentTasksFolder({
void refresh();
}, [open, refresh, refreshNonce]);
- const total = (data?.steps.length ?? 0) + (data?.tasks.length ?? 0);
+ // De-duplicate: a step that has spawned a task appears in `steps[]`
+ // with step.taskId === task.id, and the same task ALSO appears in
+ // `tasks[]`. Rendering both produces a double-row + double-highlight
+ // when the user clicks. Keep the step row (it carries the step name,
+ // which is the meaningful label) and drop the matching task row.
+ const stepTaskIds = useMemo(
+ () => new Set((data?.steps ?? []).map((s) => s.taskId).filter((id): id is string => !!id)),
+ [data],
+ );
+ const ephemeralTasks = useMemo(
+ () => (data?.tasks ?? []).filter((t) => !stepTaskIds.has(t.id)),
+ [data, stepTaskIds],
+ );
+ const total = (data?.steps.length ?? 0) + ephemeralTasks.length;
// 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
@@ -742,9 +767,10 @@ function DocumentTasksFolder({
selected={!!selectedTaskId && step.taskId === selectedTaskId}
padLeft={rowPadLeft}
onSelect={onSelectTask}
+ onContextMenu={onContextMenuStep}
/>
))}
- {data?.tasks.map((task) => (
+ {ephemeralTasks.map((task) => (
<TaskRow
key={`task-${task.id}`}
task={task}
@@ -752,6 +778,7 @@ function DocumentTasksFolder({
selected={task.id === selectedTaskId}
padLeft={rowPadLeft}
onSelect={onSelectTask}
+ onContextMenu={onContextMenuTask}
/>
))}
</div>
@@ -790,6 +817,7 @@ interface StepRowProps {
selected: boolean;
padLeft: string;
onSelect: (directiveId: string, taskId: string) => void;
+ onContextMenu: (e: React.MouseEvent, step: DirectiveStep, directiveId: string) => void;
}
function StepRow({
@@ -798,6 +826,7 @@ function StepRow({
selected,
padLeft,
onSelect,
+ onContextMenu,
}: StepRowProps) {
const dot = STEP_STATUS_DOT[step.status] ?? "bg-[#556677]";
// Steps without an underlying task can't be opened — the executor
@@ -811,6 +840,7 @@ function StepRow({
type="button"
disabled={!clickable}
onClick={() => clickable && onSelect(directiveId, taskId!)}
+ onContextMenu={(e) => onContextMenu(e, step, directiveId)}
title={
clickable
? `${step.name} (${step.status})`
@@ -844,6 +874,7 @@ interface TaskRowProps {
selected: boolean;
padLeft: string;
onSelect: (directiveId: string, taskId: string) => void;
+ onContextMenu: (e: React.MouseEvent, task: Task, directiveId: string) => void;
}
function TaskRow({
@@ -852,6 +883,7 @@ function TaskRow({
selected,
padLeft,
onSelect,
+ onContextMenu,
}: TaskRowProps) {
const dot = TASK_STATUS_DOT[task.status] ?? "bg-[#556677]";
// Supervisor tasks get a small "sup" tag so the user can spot
@@ -861,6 +893,7 @@ function TaskRow({
<button
type="button"
onClick={() => onSelect(directiveId, task.id)}
+ onContextMenu={(e) => onContextMenu(e, task, directiveId)}
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
@@ -892,10 +925,14 @@ interface SidebarProps {
selection: SidebarSelection | null;
onSelectDocument: (directiveId: string, doc: Contract) => void;
onSelectDirective: (directiveId: string) => void;
- onCreateDocument: (directive: DirectiveSummary) => Promise<void>;
+ /** Top-level "Contracts" header `+ New` button — opens the
+ * NewContractModal to create a brand-new directive (along with its
+ * first contract). */
onCreateContract: () => void;
- onCreateEphemeralTask: (directive: DirectiveSummary) => void;
- onContextMenu: (e: React.MouseEvent, directive: DirectiveSummary) => void;
+ onContextMenuDirective: (e: React.MouseEvent, directive: DirectiveSummary) => void;
+ onContextMenuContract: (e: React.MouseEvent, contract: Contract) => void;
+ onContextMenuStep: (e: React.MouseEvent, step: DirectiveStep, directiveId: string) => void;
+ onContextMenuTask: (e: React.MouseEvent, task: Task, directiveId: string) => void;
onSelectTask: (directiveId: string, taskId: string) => void;
refreshNonce: number;
}
@@ -906,10 +943,11 @@ function DocumentSidebar({
selection,
onSelectDocument,
onSelectDirective,
- onCreateDocument,
onCreateContract,
- onCreateEphemeralTask,
- onContextMenu,
+ onContextMenuDirective,
+ onContextMenuContract,
+ onContextMenuStep,
+ onContextMenuTask,
onSelectTask,
refreshNonce,
}: SidebarProps) {
@@ -997,9 +1035,10 @@ function DocumentSidebar({
onHeaderClick={() => onSelectDirective(d.id)}
selection={selection}
onSelectDocument={onSelectDocument}
- onCreateDocument={onCreateDocument}
- onCreateEphemeralTask={onCreateEphemeralTask}
- onContextMenu={onContextMenu}
+ onContextMenuDirective={onContextMenuDirective}
+ onContextMenuContract={onContextMenuContract}
+ onContextMenuStep={onContextMenuStep}
+ onContextMenuTask={onContextMenuTask}
onSelectTask={onSelectTask}
refreshNonce={refreshNonce}
/>
@@ -1043,7 +1082,7 @@ function ContractHeader({
docTitle,
onContractChanged,
}: ContractHeaderProps) {
- const [busy, setBusy] = useState<null | "start" | "pause" | "complete" | "unlock" | "merge_mode">(
+ const [busy, setBusy] = useState<null | "start" | "pause" | "unlock" | "reopen" | "merge_mode">(
null,
);
const [error, setError] = useState<string | null>(null);
@@ -1072,14 +1111,14 @@ function ContractHeader({
() => 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 onReopen = useCallback(
+ () => wrap("reopen", () => reopenDirectiveContract(doc.id)),
+ [doc.id, wrap],
+ );
const onMergeMode = useCallback(
(mode: ContractMergeMode) =>
wrap("merge_mode", () => updateContract(doc.id, { mergeMode: mode })),
@@ -1127,14 +1166,20 @@ function ContractHeader({
<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>
+ {/* No "Mark complete" — the contract auto-ships when the
+ orchestrator raises a PR (server-side; see
+ update_directive's PR-detection branch in handlers/
+ directives.rs). */}
</>
)}
+ {doc.status === "shipped" && (
+ <ContractActionButton onClick={onReopen} disabled={busy !== null} variant="primary">
+ {busy === "reopen" ? "Reopening…" : "Reopen for amendment"}
+ </ContractActionButton>
+ )}
{/* Merge mode radios — visible always, editable only in draft/queued */}
<div className="ml-auto flex items-center gap-2 text-[#7788aa]">
@@ -1352,7 +1397,7 @@ function EditorShell({
}
// --- Task path: task row clicked in the sidebar ------------------------
- // Renders the live transcript via DocumentTaskStream. Selection wins over
+ // Renders the live transcript via TaskPage. Selection wins over
// the document path when both are somehow present (defensive).
if (selection?.taskId) {
const taskId = selection.taskId;
@@ -1386,7 +1431,7 @@ function EditorShell({
<span className="text-white">{label}</span>
</div>
</div>
- <DocumentTaskStream
+ <TaskPage
taskId={taskId}
label={label}
ephemeral={!isStepBound}
@@ -1617,7 +1662,7 @@ export default function DocumentDirectivesPage() {
);
// Click on a task or step row → open the live transcript pane via
- // ?task=<id>. EditorShell switches to DocumentTaskStream when this is set.
+ // ?task=<id>. EditorShell switches to TaskPage when this is set.
const handleSelectTask = useCallback(
(directiveId: string, taskId: string) => {
navigate(`/directives/${directiveId}?task=${taskId}`);
@@ -1657,25 +1702,18 @@ export default function DocumentDirectivesPage() {
[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.
+ // Right-click context menu — generic. The state holds whichever items
+ // array the entity's builder produced; the SidebarContextMenu just
+ // renders them. Each entity type has its own builder (directive,
+ // contract, step, task) hung off the page so all the action callbacks
+ // (start/pause/archive/delete/etc.) live in one place.
const { refresh: refreshDirectiveList } = useDirectives();
const [contextMenu, setContextMenu] = useState<{
x: number;
y: number;
- directive: DirectiveSummary;
+ items: ContextMenuItem[];
} | 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(
@@ -1695,6 +1733,166 @@ export default function DocumentDirectivesPage() {
[refreshDirectiveList, bumpRefresh],
);
+ const openMenu = useCallback(
+ (e: React.MouseEvent, items: ContextMenuItem[]) => {
+ e.preventDefault();
+ e.stopPropagation();
+ setContextMenu({ x: e.clientX, y: e.clientY, items });
+ },
+ [],
+ );
+
+ // ---- Per-entity menu builders. Each returns its items array; the
+ // handler wraps them with openMenu so each row can fire its own
+ // right-click with one line of wiring. ----
+ const handleContextMenuDirective = useCallback(
+ (e: React.MouseEvent, d: DirectiveSummary) => {
+ const items: ContextMenuItem[] = [
+ {
+ label: "+ New contract here",
+ onClick: () => {
+ void handleCreateDocument(d);
+ },
+ },
+ { label: "+ New ephemeral task", onClick: () => setNewEphemeralFor(d) },
+ { label: "", separator: true },
+ {
+ label: "Start",
+ onClick: () => runAction(() => startDirective(d.id), "Failed to start directive"),
+ disabled: d.status === "active" || d.status === "archived",
+ },
+ {
+ label: "Pause",
+ onClick: () => runAction(() => pauseDirective(d.id), "Failed to pause directive"),
+ disabled: d.status !== "active",
+ },
+ { label: "", separator: true },
+ {
+ label: d.prUrl ? "Update PR" : "Create PR",
+ onClick: () =>
+ runAction(
+ () => createDirectivePR(d.id),
+ d.prUrl ? "Failed to update PR" : "Failed to create PR",
+ ),
+ },
+ ...(d.prUrl
+ ? [{
+ label: "Go to PR",
+ onClick: () => window.open(d.prUrl!, "_blank", "noreferrer"),
+ } as ContextMenuItem]
+ : []),
+ {
+ label: "Advance DAG",
+ onClick: () => runAction(() => advanceDirective(d.id), "Failed to advance DAG"),
+ },
+ {
+ label: "Cleanup merged steps",
+ onClick: () => runAction(() => cleanupDirective(d.id), "Failed to clean up"),
+ },
+ {
+ label: "Pick up orders",
+ onClick: () => runAction(() => pickUpOrders(d.id), "Failed to pick up orders"),
+ },
+ { label: "", separator: true },
+ {
+ label: "Archive",
+ danger: true,
+ onClick: () =>
+ runAction(
+ () => updateDirective(d.id, { status: "archived" }),
+ "Failed to archive",
+ ),
+ disabled: d.status === "archived",
+ },
+ {
+ label: "Delete",
+ danger: true,
+ onClick: async () => {
+ if (!window.confirm(`Delete "${d.title}"? This cannot be undone.`)) return;
+ await runAction(() => deleteDirective(d.id), "Failed to delete");
+ if (selection?.directiveId === d.id) navigate("/directives");
+ },
+ },
+ ];
+ openMenu(e, items);
+ },
+ [openMenu, runAction, navigate, selection],
+ );
+
+ const handleContextMenuContract = useCallback(
+ (e: React.MouseEvent, c: Contract) => {
+ const items: ContextMenuItem[] = [
+ {
+ label: "Lock & Start",
+ onClick: () =>
+ runAction(() => startDirectiveContract(c.id), "Failed to start contract"),
+ disabled: c.status !== "draft",
+ },
+ {
+ label: "Pause",
+ onClick: () =>
+ runAction(() => pauseDirectiveContract(c.id), "Failed to pause contract"),
+ disabled: c.status !== "active",
+ },
+ {
+ label: "Unlock",
+ onClick: () =>
+ runAction(() => unlockDirectiveContract(c.id), "Failed to unlock contract"),
+ disabled: c.status !== "active" && c.status !== "queued",
+ },
+ {
+ label: "Reopen for amendment",
+ onClick: () =>
+ runAction(() => reopenDirectiveContract(c.id), "Failed to reopen contract"),
+ disabled: c.status !== "shipped",
+ },
+ ];
+ openMenu(e, items);
+ },
+ [openMenu, runAction],
+ );
+
+ const handleContextMenuStep = useCallback(
+ (e: React.MouseEvent, step: DirectiveStep, directiveId: string) => {
+ const items: ContextMenuItem[] = [
+ {
+ label: "Stop task",
+ onClick: () =>
+ step.taskId
+ ? runAction(() => stopTask(step.taskId!), "Failed to stop task")
+ : undefined,
+ disabled: !step.taskId || step.status !== "running",
+ },
+ {
+ label: "Skip step",
+ danger: true,
+ onClick: () =>
+ runAction(
+ () => skipDirectiveStep(directiveId, step.id),
+ "Failed to skip step",
+ ),
+ disabled: step.status === "completed" || step.status === "skipped",
+ },
+ ];
+ openMenu(e, items);
+ },
+ [openMenu, runAction],
+ );
+
+ const handleContextMenuTask = useCallback(
+ (e: React.MouseEvent, task: Task, _directiveId: string) => {
+ const items: ContextMenuItem[] = [
+ {
+ label: "Stop task",
+ onClick: () => runAction(() => stopTask(task.id), "Failed to stop task"),
+ disabled: task.status === "done" || task.status === "failed" || task.status === "merged",
+ },
+ ];
+ openMenu(e, items);
+ },
+ [openMenu, runAction],
+ );
+
if (authLoading) {
return (
<div className="relative z-10 min-h-screen flex flex-col bg-[#0a1628]">
@@ -1725,10 +1923,11 @@ export default function DocumentDirectivesPage() {
selection={selection}
onSelectDocument={handleSelectDocument}
onSelectDirective={handleSelectDirective}
- onCreateDocument={handleCreateDocument}
onCreateContract={() => setShowNewContract(true)}
- onCreateEphemeralTask={(d) => setNewEphemeralFor(d)}
- onContextMenu={handleContextMenu}
+ onContextMenuDirective={handleContextMenuDirective}
+ onContextMenuContract={handleContextMenuContract}
+ onContextMenuStep={handleContextMenuStep}
+ onContextMenuTask={handleContextMenuTask}
onSelectTask={handleSelectTask}
refreshNonce={refreshNonce}
/>
@@ -1758,80 +1957,11 @@ export default function DocumentDirectivesPage() {
)}
{contextMenu && (
- <DirectiveContextMenu
+ <SidebarContextMenu
x={contextMenu.x}
y={contextMenu.y}
- directive={contextMenu.directive}
+ items={contextMenu.items}
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>
diff --git a/makima/frontend/src/routes/tmp.tsx b/makima/frontend/src/routes/tmp.tsx
index c0c7365..5ac7233 100644
--- a/makima/frontend/src/routes/tmp.tsx
+++ b/makima/frontend/src/routes/tmp.tsx
@@ -1,14 +1,14 @@
/**
* Standalone task page for orphan tasks (`/tmp/:taskId`). These are tasks
* with no directive attachment — the document-mode sidebar surfaces them
- * under the `tmp/` pseudo-folder. We render `DocumentTaskStream` directly
- * without the directive sidebar selection, framed by the masthead so users
- * still have global navigation.
+ * under the `tmp/` pseudo-folder. We render `TaskPage` directly without
+ * the directive sidebar selection, framed by the masthead so users still
+ * have global navigation.
*/
import { useEffect, useState } from "react";
import { useNavigate, useParams } from "react-router";
import { Masthead } from "../components/Masthead";
-import { DocumentTaskStream } from "../components/directives/DocumentTaskStream";
+import { TaskPage } from "../components/directives/TaskPage";
import { useAuth } from "../contexts/AuthContext";
import { getTask, type Task } from "../lib/api";
@@ -82,7 +82,7 @@ export default function TmpTaskPage() {
<p className="text-red-400 font-mono text-xs">{error}</p>
</div>
) : taskId ? (
- <DocumentTaskStream taskId={taskId} label={task?.name ?? taskId.slice(0, 8)} />
+ <TaskPage taskId={taskId} label={task?.name ?? taskId.slice(0, 8)} />
) : null}
</main>
</div>