summaryrefslogtreecommitdiff
path: root/makima/frontend/src/routes/document-directives.tsx
diff options
context:
space:
mode:
Diffstat (limited to 'makima/frontend/src/routes/document-directives.tsx')
-rw-r--r--makima/frontend/src/routes/document-directives.tsx555
1 files changed, 528 insertions, 27 deletions
diff --git a/makima/frontend/src/routes/document-directives.tsx b/makima/frontend/src/routes/document-directives.tsx
index ffd2a8b..7b0a89b 100644
--- a/makima/frontend/src/routes/document-directives.tsx
+++ b/makima/frontend/src/routes/document-directives.tsx
@@ -23,12 +23,16 @@ import {
cleanupDirective,
pickUpOrders,
sendTaskMessage,
+ listDirectiveEphemeralTasks,
+ createDirectiveTask,
+ listOrphanTasks,
} from "../lib/api";
import type {
DirectiveStatus,
DirectiveSummary,
DirectiveWithSteps,
DirectiveRevision,
+ TaskSummary,
} from "../lib/api";
// Status dot color, matching the existing tabular UI's badge palette so the
@@ -124,6 +128,26 @@ function TaskIcon() {
);
}
+/** Asterisk-on-terminal icon for ephemeral spinoff tasks — visually
+ distinct from the plain TaskIcon used for step-spawned execution tasks
+ so users can tell at a glance which tasks are part of the DAG vs which
+ are user-spun side quests. */
+function EphemeralTaskIcon() {
+ 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="#c084fc" strokeWidth="1" />
+ <path d="M3.5 6l2 2-2 2 M7 10h4" stroke="#c084fc" strokeWidth="1" fill="none" strokeLinecap="round" />
+ <path d="M11 4l1 1m-1 0l1-1" stroke="#c084fc" strokeWidth="1" fill="none" />
+ </svg>
+ );
+}
+
/** PR-bracket icon for the completion task. */
function CompletionIcon() {
return (
@@ -160,6 +184,31 @@ function PinIcon() {
);
}
+/** Tiny chip used for the inline directive-folder hover actions. */
+function FolderActionButton({
+ children,
+ title,
+ onClick,
+}: {
+ children: React.ReactNode;
+ title: string;
+ onClick: () => void;
+}) {
+ return (
+ <button
+ type="button"
+ title={title}
+ onClick={(e) => {
+ e.stopPropagation();
+ onClick();
+ }}
+ className="w-5 h-5 flex items-center justify-center text-[10px] text-[#7788aa] hover:text-white hover:bg-[rgba(117,170,252,0.15)] rounded transition-colors"
+ >
+ {children}
+ </button>
+ );
+}
+
function Caret({ open }: { open: boolean }) {
return (
<svg
@@ -355,13 +404,6 @@ interface SidebarSelection {
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/`
@@ -380,6 +422,8 @@ function DirectiveFolder({
hasPendingForDirective,
onDirectiveContextMenu,
onTaskContextMenu,
+ onCreateTask,
+ onQuickAction,
}: {
directive: DirectiveSummary;
open: boolean;
@@ -392,6 +436,10 @@ function DirectiveFolder({
hasPendingForDirective: boolean;
onDirectiveContextMenu: (e: React.MouseEvent, d: DirectiveSummary) => void;
onTaskContextMenu: (e: React.MouseEvent, t: FolderTaskRow, directiveId: string) => void;
+ /** Open the inline "+ New task" form for this directive. */
+ onCreateTask: (d: DirectiveSummary) => void;
+ /** Trigger a quick action (start/pause/PR) on the directive. */
+ onQuickAction: (d: DirectiveSummary, action: "start" | "pause" | "pr") => void;
}) {
const dotColor = STATUS_DOT[directive.status] ?? STATUS_DOT.draft;
const fileName = `${slugify(directive.title, directive.id.slice(0, 8))}.md`;
@@ -402,8 +450,31 @@ function DirectiveFolder({
const docSelected =
selection?.directiveId === directive.id && selection.taskId === null;
+ // Ephemeral tasks attached to this directive (no directive_step_id). Fetched
+ // lazily when the folder opens; refetched whenever a poll lands on the
+ // directive's detail (poll-driven freshness).
+ const [ephemeralTasks, setEphemeralTasks] = useState<TaskSummary[]>([]);
+ useEffect(() => {
+ if (!open) return;
+ let cancelled = false;
+ listDirectiveEphemeralTasks(directive.id)
+ .then((res) => {
+ if (!cancelled) setEphemeralTasks(res.tasks);
+ })
+ .catch((err) => {
+ // eslint-disable-next-line no-console
+ console.warn("[makima] failed to load ephemeral tasks", err);
+ });
+ return () => {
+ cancelled = true;
+ };
+ }, [open, directive.id, directive.updatedAt]);
+
// Collect the tasks to surface in the folder body.
- const tasks = useMemo(() => collectTasks(detailed, directive), [detailed, directive]);
+ const tasks = useMemo(
+ () => collectTasks(detailed, directive, ephemeralTasks),
+ [detailed, directive, ephemeralTasks],
+ );
const orchestratorRunning = !!directive.orchestratorTaskId;
// Tasks subfolder open state — independent of the directive folder.
@@ -430,18 +501,76 @@ function DirectiveFolder({
};
}, [open, directive.id, directive.prUrl]);
+ // Inline action buttons on the folder header — visible on hover (and when
+ // the folder is open) so users don't have to right-click to discover the
+ // primary directive controls. Mirrors a code-editor sidebar's affordance.
+ const showStart =
+ directive.status === "draft" ||
+ directive.status === "paused" ||
+ directive.status === "idle" ||
+ directive.status === "inactive";
+ const showPause = directive.status === "active";
+
return (
- <div className="select-none">
- <button
- type="button"
- onClick={onToggle}
- onContextMenu={(e) => onDirectiveContextMenu(e, directive)}
- title={directive.title}
+ <div className="select-none group/dir">
+ <div
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)]"
+ onContextMenu={(e) => onDirectiveContextMenu(e, directive)}
>
- <Caret open={open} />
- <FolderIcon open={open} />
- <span className="truncate flex-1 text-left">{directive.title}</span>
+ <button
+ type="button"
+ onClick={onToggle}
+ title={directive.title}
+ className="flex items-center gap-1.5 flex-1 min-w-0 text-left"
+ >
+ <Caret open={open} />
+ <FolderIcon open={open} />
+ <span className="truncate flex-1">{directive.title}</span>
+ </button>
+
+ {/* Hover/open-only action chips — discoverable replacement for the
+ right-click menu. Right-click still works as a power-user fallback. */}
+ <div
+ className={`flex items-center gap-0.5 transition-opacity ${
+ open
+ ? "opacity-100"
+ : "opacity-0 group-hover/dir:opacity-100"
+ }`}
+ >
+ {showStart && (
+ <FolderActionButton
+ title="Start"
+ onClick={() => onQuickAction(directive, "start")}
+ >
+ ▶
+ </FolderActionButton>
+ )}
+ {showPause && (
+ <FolderActionButton
+ title="Pause"
+ onClick={() => onQuickAction(directive, "pause")}
+ >
+ ❚❚
+ </FolderActionButton>
+ )}
+ {directive.prUrl && (
+ <FolderActionButton
+ title="Open PR"
+ onClick={() =>
+ window.open(directive.prUrl ?? "", "_blank", "noreferrer")
+ }
+ >
+ ↗
+ </FolderActionButton>
+ )}
+ <FolderActionButton
+ title="New task"
+ onClick={() => onCreateTask(directive)}
+ >
+ +
+ </FolderActionButton>
+ </div>
+
{/* Status dot — RIGHT side only. Glows when this directive has a
pending user question, or pulses when the orchestrator is live. */}
<StatusDot
@@ -450,7 +579,7 @@ function DirectiveFolder({
glow={hasPendingForDirective}
status={directive.status}
/>
- </button>
+ </div>
{open && (
<ul className="py-0.5">
@@ -504,7 +633,11 @@ function DirectiveFolder({
t.status === "running" || t.kind === "orchestrator-active";
const glow = pendingTaskIds.has(t.taskId);
const Icon =
- t.kind === "completion" ? CompletionIcon : TaskIcon;
+ t.kind === "completion"
+ ? CompletionIcon
+ : t.kind === "ephemeral"
+ ? EphemeralTaskIcon
+ : TaskIcon;
return (
<li key={t.taskId}>
<button
@@ -753,12 +886,13 @@ interface FolderTaskRow {
stepId: string | null;
label: string;
status: string;
- kind: "orchestrator-active" | "completion" | "step";
+ kind: "orchestrator-active" | "completion" | "step" | "ephemeral";
}
function collectTasks(
detailed: DirectiveWithSteps | null,
summary: DirectiveSummary,
+ ephemeralTasks: TaskSummary[],
): FolderTaskRow[] {
const rows: FolderTaskRow[] = [];
@@ -803,6 +937,19 @@ function collectTasks(
}
}
+ // Ephemeral tasks — user-spawned spinoffs not part of the DAG. Surfaced
+ // alongside step tasks but with a different icon and the "ephemeral" kind
+ // so context menus and the merge button behave correctly.
+ for (const t of ephemeralTasks) {
+ rows.push({
+ taskId: t.id,
+ stepId: null,
+ label: t.name,
+ status: t.status,
+ kind: "ephemeral",
+ });
+ }
+
return rows;
}
@@ -813,6 +960,12 @@ interface SidebarProps {
onSelect: (sel: SidebarSelection) => void;
onDirectiveContextMenu: (e: React.MouseEvent, d: DirectiveSummary) => void;
onTaskContextMenu: (e: React.MouseEvent, t: FolderTaskRow, directiveId: string) => void;
+ /** Open the inline "+ New task" form for this directive. */
+ onCreateTask: (d: DirectiveSummary) => void;
+ /** Trigger a quick action (start/pause/PR) on the directive. */
+ onQuickAction: (d: DirectiveSummary, action: "start" | "pause" | "pr") => void;
+ /** Navigate to an orphan (no-directive) task's standalone view. */
+ onSelectOrphan: (taskId: string) => void;
}
function DocumentSidebar({
@@ -822,7 +975,33 @@ function DocumentSidebar({
onSelect,
onDirectiveContextMenu,
onTaskContextMenu,
+ onCreateTask,
+ onQuickAction,
+ onSelectOrphan,
}: SidebarProps) {
+ // Orphan tasks (no directive) — top-level "tmp/" pseudo-folder. Polled
+ // every 5s so newly-spawned standalone tasks appear without a manual
+ // refresh.
+ const [orphanTasks, setOrphanTasks] = useState<TaskSummary[]>([]);
+ useEffect(() => {
+ let cancelled = false;
+ const load = () => {
+ listOrphanTasks()
+ .then((res) => {
+ if (!cancelled) setOrphanTasks(res.tasks);
+ })
+ .catch(() => {
+ /* swallow — tmp/ is a nice-to-have, never blocking */
+ });
+ };
+ load();
+ const interval = setInterval(load, 5000);
+ return () => {
+ cancelled = true;
+ clearInterval(interval);
+ };
+ }, []);
+ const [tmpOpen, setTmpOpen] = useState<boolean>(true);
// 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
@@ -902,6 +1081,58 @@ function DocumentSidebar({
{/* Body */}
<div className="flex-1 overflow-y-auto pb-4">
+ {/* tmp/ pseudo-folder — orphan tasks (directive_id NULL). Always
+ rendered so users can create scratchpad tasks even when zero
+ directives exist; collapses to a thin header when empty. */}
+ <div className="select-none border-b border-dashed border-[rgba(117,170,252,0.1)] pb-1 mb-1">
+ <button
+ type="button"
+ onClick={() => setTmpOpen((p) => !p)}
+ 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={tmpOpen} />
+ <FolderIcon open={tmpOpen} />
+ <span className="truncate flex-1 text-left text-[#7788aa]">tmp/</span>
+ <span className="text-[10px] text-[#556677]">
+ {orphanTasks.length}
+ </span>
+ </button>
+ {tmpOpen && (
+ <ul className="py-0.5">
+ {orphanTasks.length === 0 ? (
+ <li className="pl-8 pr-3 py-1 font-mono text-[10px] text-[#556677] italic">
+ No orphan tasks
+ </li>
+ ) : (
+ orphanTasks.map((t) => {
+ const tdot =
+ STEP_STATUS_DOT[t.status] ?? STEP_STATUS_DOT.pending;
+ const live = t.status === "running";
+ return (
+ <li key={t.id}>
+ <button
+ type="button"
+ onClick={() => onSelectOrphan(t.id)}
+ title={t.name}
+ className="w-full text-left 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.06)] border-l-2 border-transparent transition-colors"
+ >
+ <TaskIcon />
+ <span className="truncate flex-1">{t.name}</span>
+ <StatusDot
+ color={tdot}
+ live={live}
+ glow={false}
+ status={t.status}
+ />
+ </button>
+ </li>
+ );
+ })
+ )}
+ </ul>
+ )}
+ </div>
+
{loading && directives.length === 0 ? (
<div className="px-3 py-6 text-center text-[#556677] font-mono text-[11px]">
Loading...
@@ -923,6 +1154,8 @@ function DocumentSidebar({
hasPendingForDirective={directivesWithPending.has(d.id)}
onDirectiveContextMenu={onDirectiveContextMenu}
onTaskContextMenu={onTaskContextMenu}
+ onCreateTask={onCreateTask}
+ onQuickAction={onQuickAction}
/>
))
)}
@@ -936,6 +1169,63 @@ function DocumentSidebar({
// and loading states.
// =============================================================================
+/**
+ * Wraps DocumentTaskStream with ephemeral-aware metadata. Determines whether
+ * the selected task is part of the directive's DAG (orchestrator/completion/
+ * steps) or an ephemeral spinoff, and looks up its current status from the
+ * ephemeral list — that decides whether the "Merge to base" affordance
+ * should appear in the stream's action header.
+ */
+function EphemeralAwareTaskStream({
+ taskId,
+ label,
+ directive,
+}: {
+ taskId: string;
+ label: string;
+ directive: DirectiveWithSteps;
+}) {
+ const isStepBound =
+ taskId === directive.orchestratorTaskId ||
+ taskId === directive.completionTaskId ||
+ directive.steps.some((s) => s.taskId === taskId);
+
+ // Status lookup for ephemeral tasks. We poll the ephemeral list lazily —
+ // this is a lightweight call and only triggers when the user is viewing a
+ // task in the editor pane.
+ const [ephemeralStatus, setEphemeralStatus] = useState<string | undefined>();
+ useEffect(() => {
+ if (isStepBound) return;
+ let cancelled = false;
+ const load = () => {
+ listDirectiveEphemeralTasks(directive.id)
+ .then((res) => {
+ if (cancelled) return;
+ const match = res.tasks.find((t) => t.id === taskId);
+ setEphemeralStatus(match?.status);
+ })
+ .catch(() => {
+ /* non-blocking */
+ });
+ };
+ load();
+ const interval = setInterval(load, 5000);
+ return () => {
+ cancelled = true;
+ clearInterval(interval);
+ };
+ }, [taskId, directive.id, isStepBound]);
+
+ return (
+ <DocumentTaskStream
+ taskId={taskId}
+ label={label}
+ ephemeral={!isStepBound}
+ status={ephemeralStatus}
+ />
+ );
+}
+
interface EditorShellProps {
selectedId: string | undefined;
selectedTaskId: string | null;
@@ -1011,6 +1301,23 @@ function EditorShell({
? "revision"
: null;
+ // "Now executing" strip — surfaces what's live when looking at the
+ // contract editor, so users don't have to scan the sidebar to find it.
+ const liveTask = (() => {
+ if (selectedTaskId) return null; // already viewing a task; strip is redundant
+ if (directive.orchestratorTaskId) {
+ return { id: directive.orchestratorTaskId, name: "orchestrator" };
+ }
+ if (directive.completionTaskId) {
+ return { id: directive.completionTaskId, name: "completion" };
+ }
+ const runningStep = directive.steps.find((s) => s.status === "running");
+ if (runningStep && runningStep.taskId) {
+ return { id: runningStep.taskId, name: runningStep.name };
+ }
+ return null;
+ })();
+
return (
<div className="flex-1 flex flex-col h-full overflow-hidden">
{/* Contract header — breadcrumb-like, mirrors a code editor's tab bar */}
@@ -1032,21 +1339,37 @@ function EditorShell({
</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>
+ {/* Now-executing strip — only when viewing the contract doc itself.
+ Click to jump into the live task transcript. */}
+ {liveTask && (
+ <button
+ type="button"
+ onClick={() =>
+ // Navigate via the search-param so EditorShell switches to the
+ // task stream for this live task.
+ (window.location.search = `?task=${liveTask.id}`)
+ }
+ className="shrink-0 flex items-center gap-2 px-6 py-1.5 bg-amber-900/15 border-b border-amber-700/40 text-amber-300 font-mono text-[11px] hover:bg-amber-900/30 transition-colors"
+ >
+ <span className="inline-block w-1.5 h-1.5 rounded-full bg-amber-400 animate-pulse" />
+ <span className="uppercase tracking-wide text-[10px]">Now executing</span>
+ <span className="text-[#dbe7ff]">{liveTask.name}</span>
+ <span className="ml-auto text-[10px] text-amber-200/70">
+ click to view transcript ↗
+ </span>
+ </button>
+ )}
+
{revisionId ? (
<RevisionViewer directiveId={directive.id} revisionId={revisionId} />
) : realTaskId ? (
- <DocumentTaskStream
+ <EphemeralAwareTaskStream
taskId={realTaskId}
label={taskLabel ?? realTaskId.slice(0, 8)}
+ directive={directive}
/>
) : (
<DocumentEditor
@@ -1135,6 +1458,62 @@ export default function DocumentDirectivesPage() {
const closeContextMenu = useCallback(() => setContextMenu(null), []);
+ // Inline "+ New task" form state. When set, we render a small modal-ish
+ // overlay anchored to the directive folder; submitting calls the
+ // ephemeral-task endpoint.
+ const [newTaskFor, setNewTaskFor] = useState<DirectiveSummary | null>(null);
+
+ const onCreateTask = useCallback((d: DirectiveSummary) => {
+ setNewTaskFor(d);
+ }, []);
+
+ const handleSubmitNewTask = useCallback(
+ async (name: string, plan: string) => {
+ if (!newTaskFor) return;
+ try {
+ const task = await createDirectiveTask(newTaskFor.id, { name, plan });
+ // Navigate the user into the freshly-spawned task's transcript.
+ navigate(`/directives/${newTaskFor.id}?task=${task.id}`);
+ setNewTaskFor(null);
+ } catch (err) {
+ // eslint-disable-next-line no-console
+ console.error("[makima] failed to create ephemeral task", err);
+ alert(
+ err instanceof Error
+ ? `Failed to create task: ${err.message}`
+ : "Failed to create task",
+ );
+ }
+ },
+ [newTaskFor, navigate],
+ );
+
+ const onQuickAction = useCallback(
+ async (d: DirectiveSummary, action: "start" | "pause" | "pr") => {
+ try {
+ if (action === "start") {
+ await startDirective(d.id);
+ } else if (action === "pause") {
+ await pauseDirective(d.id);
+ } else if (action === "pr") {
+ await createDirectivePR(d.id);
+ }
+ await refreshList();
+ } catch (err) {
+ // eslint-disable-next-line no-console
+ console.error(`[makima] quick action ${action} failed`, err);
+ }
+ },
+ [refreshList],
+ );
+
+ const onSelectOrphan = useCallback(
+ (taskId: string) => {
+ navigate(`/tmp/${taskId}`);
+ },
+ [navigate],
+ );
+
if (authLoading) {
return (
<div className="relative z-10 min-h-screen flex flex-col bg-[#0a1628]">
@@ -1166,6 +1545,9 @@ export default function DocumentDirectivesPage() {
onSelect={onSelect}
onDirectiveContextMenu={onDirectiveContextMenu}
onTaskContextMenu={onTaskContextMenu}
+ onCreateTask={onCreateTask}
+ onQuickAction={onQuickAction}
+ onSelectOrphan={onSelectOrphan}
/>
</div>
@@ -1304,6 +1686,125 @@ export default function DocumentDirectivesPage() {
}}
/>
)}
+
+ {newTaskFor && (
+ <NewTaskModal
+ directive={newTaskFor}
+ onClose={() => setNewTaskFor(null)}
+ onSubmit={handleSubmitNewTask}
+ />
+ )}
+ </div>
+ );
+}
+
+/**
+ * Inline "+ New task" form for spawning an ephemeral task under a
+ * directive. Surfaced as a centered modal, dismissible with Esc / click-out.
+ */
+function NewTaskModal({
+ directive,
+ onClose,
+ onSubmit,
+}: {
+ directive: DirectiveSummary;
+ onClose: () => void;
+ onSubmit: (name: string, plan: string) => Promise<void>;
+}) {
+ const [name, setName] = useState("");
+ const [plan, setPlan] = useState("");
+ const [submitting, setSubmitting] = useState(false);
+ const nameRef = useRef<HTMLInputElement>(null);
+
+ useEffect(() => {
+ nameRef.current?.focus();
+ const onKey = (e: KeyboardEvent) => {
+ if (e.key === "Escape") onClose();
+ };
+ document.addEventListener("keydown", onKey);
+ return () => document.removeEventListener("keydown", onKey);
+ }, [onClose]);
+
+ const submit = async (e: React.FormEvent) => {
+ e.preventDefault();
+ const trimmedName = name.trim();
+ const trimmedPlan = plan.trim();
+ if (!trimmedName || !trimmedPlan || submitting) return;
+ setSubmitting(true);
+ try {
+ await onSubmit(trimmedName, trimmedPlan);
+ } finally {
+ setSubmitting(false);
+ }
+ };
+
+ return (
+ <div
+ className="fixed inset-0 z-50 flex items-center justify-center bg-black/60"
+ onClick={onClose}
+ >
+ <form
+ onSubmit={submit}
+ onClick={(e) => e.stopPropagation()}
+ className="w-[480px] max-w-[90vw] bg-[#0a1628] border border-[rgba(117,170,252,0.4)] shadow-xl flex flex-col"
+ >
+ <div className="px-4 py-3 border-b border-dashed border-[rgba(117,170,252,0.25)]">
+ <p className="text-[10px] font-mono text-[#556677] uppercase tracking-wide">
+ New task in
+ </p>
+ <p className="text-[12px] font-mono text-white truncate">
+ {directive.title}
+ </p>
+ </div>
+ <div className="px-4 py-4 space-y-3">
+ <div className="space-y-1">
+ <label className="text-[10px] font-mono text-[#7788aa] uppercase tracking-wide">
+ Name
+ </label>
+ <input
+ ref={nameRef}
+ type="text"
+ value={name}
+ onChange={(e) => setName(e.target.value)}
+ placeholder="e.g. Investigate flaky test in auth.test.ts"
+ className="w-full bg-transparent border border-[rgba(117,170,252,0.2)] focus:border-[#75aafc] outline-none px-3 py-2 text-[13px] text-[#dbe7ff] placeholder-[#445566]"
+ />
+ </div>
+ <div className="space-y-1">
+ <label className="text-[10px] font-mono text-[#7788aa] uppercase tracking-wide">
+ Plan / instructions
+ </label>
+ <textarea
+ value={plan}
+ onChange={(e) => setPlan(e.target.value)}
+ onKeyDown={(e) => {
+ if ((e.metaKey || e.ctrlKey) && e.key === "Enter") {
+ void submit(e as unknown as React.FormEvent);
+ }
+ }}
+ rows={5}
+ placeholder="What should the task do?"
+ className="w-full bg-transparent border border-[rgba(117,170,252,0.2)] focus:border-[#75aafc] outline-none px-3 py-2 text-[13px] text-[#dbe7ff] placeholder-[#445566] resize-none"
+ />
+ </div>
+ </div>
+ <div className="px-4 py-3 border-t border-dashed border-[rgba(117,170,252,0.25)] flex items-center justify-end gap-2">
+ <button
+ type="button"
+ onClick={onClose}
+ className="px-3 py-1.5 text-[11px] font-mono uppercase tracking-wide text-[#7788aa] border border-[#2a3a5a] hover:text-white"
+ >
+ Cancel
+ </button>
+ <button
+ type="submit"
+ disabled={!name.trim() || !plan.trim() || submitting}
+ className="px-3 py-1.5 text-[11px] font-mono uppercase tracking-wide text-emerald-300 border border-emerald-700/60 hover:border-emerald-400 disabled:opacity-40 disabled:cursor-not-allowed"
+ >
+ {submitting ? "Creating…" : "Spawn task"}
+ </button>
+ </div>
+ </form>
</div>
);
}