"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 )}