diff --git a/apps/web/app/(app)/[workspaceSlug]/plans/[planSlug]/[epicSlug]/[taskSlug]/page.tsx b/apps/web/app/(app)/[workspaceSlug]/plans/[planSlug]/[epicSlug]/[taskSlug]/page.tsx new file mode 100644 index 0000000..ef86805 --- /dev/null +++ b/apps/web/app/(app)/[workspaceSlug]/plans/[planSlug]/[epicSlug]/[taskSlug]/page.tsx @@ -0,0 +1,243 @@ +"use client"; + +import * as React from "react"; +import Link from "next/link"; +import { useParams } from "next/navigation"; +import { ChevronRight, FileText } from "lucide-react"; +import type { inferRouterOutputs } from "@trpc/server"; + +import { Badge } from "@/components/ui/badge"; +import { Skeleton } from "@/components/ui/skeleton"; +import { WorkflowPromptSection } from "@/components/backlog/workflow-prompt-section"; +import { api } from "@/lib/trpc"; +import { cn } from "@/lib/utils"; +import type { AppRouter } from "@/server/root"; + +/** + * Task detail page rendered from URL slugs that mirror the `plans/` + * directory layout. There's no plans-tree browser yet, so this is the + * smallest surface that lets an operator land directly on a single task + * (e.g. from a `/settings/runs` row, or by typing the URL) and edit its + * workflow prompt. + * + * Path: //plans/// + * + * 404 here means "no such task in this workspace at that path." We surface + * the error inline rather than calling `notFound()` so the path components + * stay visible in the breadcrumb — easier to copy-edit a typo. + */ + +const STATUS_BADGE: Record = { + draft: { + label: "Draft", + className: + "border-transparent bg-muted text-muted-foreground hover:bg-muted/80", + }, + ready: { + label: "Ready", + className: + "border-transparent bg-sky-500/15 text-sky-700 dark:text-sky-300", + }, + in_progress: { + label: "In progress", + className: + "border-transparent bg-blue-500/15 text-blue-700 dark:text-blue-300", + }, + blocked: { + label: "Blocked", + className: + "border-transparent bg-amber-500/15 text-amber-700 dark:text-amber-300", + }, + done: { + label: "Done", + className: + "border-transparent bg-emerald-500/15 text-emerald-700 dark:text-emerald-300", + }, + cancelled: { + label: "Cancelled", + className: + "border-transparent bg-red-500/15 text-red-700 dark:text-red-300", + }, +}; + +function StatusBadge({ status }: { status: string | null | undefined }) { + const entry = status && STATUS_BADGE[status]; + if (!entry) { + return ( + + {status ?? "unknown"} + + ); + } + return ( + {entry.label} + ); +} + +export default function TaskDetailPage() { + const params = useParams(); + const workspaceSlug = params?.workspaceSlug as string | undefined; + const planSlug = params?.planSlug as string | undefined; + const epicSlug = params?.epicSlug as string | undefined; + const taskSlug = params?.taskSlug as string | undefined; + + const ready = Boolean(workspaceSlug && planSlug && epicSlug && taskSlug); + + const detailQuery = api.backlog.getTaskByPath.useQuery( + { + workspace: workspaceSlug ?? "", + planSlug: planSlug ?? "", + epicSlug: epicSlug ?? "", + taskSlug: taskSlug ?? "", + }, + { enabled: ready }, + ); + + if (!ready) { + return ( +
+ Missing path segments. +
+ ); + } + + return ( +
+ + + {detailQuery.isLoading ? ( +
+ + + +
+ ) : detailQuery.error ? ( +
+ {detailQuery.error.message} +
+ ) : detailQuery.data ? ( + + ) : null} +
+ ); +} + +function Breadcrumb({ + workspaceSlug, + planSlug, + epicSlug, + taskSlug, + planTitle, + epicTitle, + taskTitle, +}: { + workspaceSlug: string; + planSlug: string; + epicSlug: string; + taskSlug: string; + planTitle: string | null; + epicTitle: string | null; + taskTitle: string | null; +}) { + // There isn't a plans browser route yet, so the only meaningful link + // is back up to the workspace root. Plan/epic crumbs render as text + // until those landing pages exist. + return ( + + ); +} + +type DetailData = inferRouterOutputs["backlog"]["getTaskByPath"]; + +function TaskBody({ + workspaceSlug, + data, +}: { + workspaceSlug: string; + data: DetailData; +}) { + const canEdit = data.callerRole === "owner" || data.callerRole === "admin"; + + return ( + <> +
+
+ +
+
+
+

+ {data.task.title || "Untitled task"} +

+ + {data.task.priority ? ( + + {data.task.priority} + + ) : null} +
+

+ {data.task.repoPath} +

+
+
+ +
+ + +
+

Body

+

+ Markdown imported from {data.task.repoPath}. Edit the file in your repo and re-run the importer to update. +

+ {data.task.bodyMarkdown.trim() ? ( +
+              {data.task.bodyMarkdown}
+            
+ ) : ( +
+ No body content. +
+ )} +
+
+ + ); +} diff --git a/apps/web/app/(app)/[workspaceSlug]/settings/runs/page.tsx b/apps/web/app/(app)/[workspaceSlug]/settings/runs/page.tsx index f23b2f2..fe4620f 100644 --- a/apps/web/app/(app)/[workspaceSlug]/settings/runs/page.tsx +++ b/apps/web/app/(app)/[workspaceSlug]/settings/runs/page.tsx @@ -1,6 +1,7 @@ "use client"; import * as React from "react"; +import Link from "next/link"; import { useParams } from "next/navigation"; import { Activity, @@ -99,6 +100,35 @@ function describeOutcome( return { label: outcome ?? "open", key: key as keyof typeof OUTCOME_CHIP }; } +/** + * Compose a URL to the task detail page when we have the full path + * triple. Returns null when: + * - the backlog item is a plan or epic (no detail page exists yet), + * - any path segment is missing (a row joined against a deleted item + * or one with the importer mid-flight), + * - or the workspace slug isn't known yet (initial render). + * + * The runs page renders the title unlinked when this returns null. + */ +function buildTaskHref(args: { + workspaceSlug: string | undefined; + taskKind: string | null; + planSlug: string | null; + epicSlug: string | null; + taskSlug: string | null; +}): string | null { + if ( + args.taskKind !== "task" || + !args.workspaceSlug || + !args.planSlug || + !args.epicSlug || + !args.taskSlug + ) { + return null; + } + return `/${args.workspaceSlug}/plans/${args.planSlug}/${args.epicSlug}/${args.taskSlug}`; +} + /** * Read `client` / `model` out of a run's metadata blob. The column is * JSONB so we're treating it as unstructured here — short-circuit on @@ -231,6 +261,13 @@ export default function RunsPage() { const agent = describeAgent(row.metadata); const isOpen = expanded === row.id; const hasDetail = Boolean(row.notes || row.error); + const taskHref = buildTaskHref({ + workspaceSlug, + taskKind: row.taskKind, + planSlug: row.taskPlanSlug, + epicSlug: row.taskEpicSlug, + taskSlug: row.taskSlug, + }); return ( - {row.taskTitle ?? ( + {row.taskTitle ? ( + taskHref ? ( + e.stopPropagation()} + className="hover:underline focus-visible:underline focus-visible:outline-none" + > + {row.taskTitle} + + ) : ( + row.taskTitle + ) + ) : ( {row.backlogItemId.slice(0, 8)} diff --git a/apps/web/components/backlog/workflow-prompt-section.tsx b/apps/web/components/backlog/workflow-prompt-section.tsx new file mode 100644 index 0000000..82487c5 --- /dev/null +++ b/apps/web/components/backlog/workflow-prompt-section.tsx @@ -0,0 +1,267 @@ +"use client"; + +import * as React from "react"; +import { Loader2, Sparkles } from "lucide-react"; + +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Skeleton } from "@/components/ui/skeleton"; +import { api } from "@/lib/trpc"; +import { cn } from "@/lib/utils"; + +/** + * Editor for a backlog item's `workflow_prompt` override. + * + * Three visible parts: + * 1. Source badge — where the effective prompt actually comes from + * (the task itself, an inherited epic/plan, or the built-in default). + * 2. Effective prompt preview — read-only, what an agent would receive + * from `claim_task` right now. + * 3. Override textarea — the editable knob. Empty/whitespace clears the + * override and reverts to inheritance. + * + * Edit affordances (Save / Clear) are hidden entirely when the caller + * isn't an owner/admin so the click can't fail. The server still enforces + * the same gate on `backlog.updateWorkflowPrompt` — UI hiding is a + * convenience, not a security boundary. + */ + +const MAX_PROMPT_LENGTH = 20_000; + +type Source = "task" | "epic" | "plan" | "default"; + +type SourceContext = { + /** Title to render inside the source badge (e.g. epic title). */ + taskTitle: string; + epicTitle: string | null; + planTitle: string | null; +}; + +function describeSource(source: Source, ctx: SourceContext): string { + switch (source) { + case "task": + return "From this task"; + case "epic": + return ctx.epicTitle + ? `Inherited from epic: ${ctx.epicTitle}` + : "Inherited from epic"; + case "plan": + return ctx.planTitle + ? `Inherited from plan: ${ctx.planTitle}` + : "Inherited from plan"; + case "default": + return "Built-in default"; + } +} + +function badgeVariantForSource( + source: Source, +): "default" | "secondary" | "muted" { + if (source === "task") return "default"; + if (source === "default") return "muted"; + return "secondary"; +} + +export function WorkflowPromptSection({ + workspace, + backlogItemId, + canEdit, + sourceContext, +}: { + /** Workspace UUID or slug — passed to every tRPC call. */ + workspace: string; + backlogItemId: string; + canEdit: boolean; + sourceContext: SourceContext; +}) { + const utils = api.useUtils(); + const promptQuery = api.backlog.getWorkflowPrompt.useQuery( + { workspace, backlogItemId }, + { enabled: Boolean(workspace && backlogItemId) }, + ); + + // Draft is initialized from the server's ownOverride and kept locally + // until save/clear. We track the last loaded value so re-renders that + // come from a successful invalidation don't stomp on unrelated edits + // the user might have made in the textarea between request and refetch. + const [draft, setDraft] = React.useState(""); + const [lastLoaded, setLastLoaded] = React.useState(null); + React.useEffect(() => { + const next = promptQuery.data?.ownOverride ?? ""; + if (promptQuery.data && next !== lastLoaded) { + setDraft(next); + setLastLoaded(next); + } + }, [promptQuery.data, lastLoaded]); + + const updateMut = api.backlog.updateWorkflowPrompt.useMutation({ + onSuccess: async () => { + // Re-fetch the resolved prompt + source. Don't optimistically + // mutate — the source badge can flip in non-obvious ways + // (e.g. clearing a task override might re-expose an epic or plan + // inheritance rather than the default), so it's safer to round-trip. + await utils.backlog.getWorkflowPrompt.invalidate({ + workspace, + backlogItemId, + }); + }, + }); + + if (promptQuery.isLoading) { + return ( +
+ + + +
+ ); + } + + if (promptQuery.error) { + return ( +
+ Couldn't load workflow prompt: {promptQuery.error.message} +
+ ); + } + + const data = promptQuery.data; + if (!data) return null; + + const source = data.source; + const ownOverride = data.ownOverride ?? ""; + const hasOverride = Boolean(ownOverride.trim()); + const dirty = draft !== ownOverride; + const tooLong = draft.length > MAX_PROMPT_LENGTH; + + return ( +
+
+
+ +
+
+

Workflow prompt

+

+ What an agent receives when it claims this task via MCP. +

+
+ + {describeSource(source, sourceContext)} + +
+ +
+
+ Effective prompt +
+
+          {data.effectivePrompt}
+        
+
+ +
+
+ + {hasOverride ? ( + + Currently overriding inherited prompt + + ) : ( + + Empty = inherit + + )} +
+