ubiquitous-invention/apps/web/components/backlog/workflow-prompt-section.tsx
Randall Stillwell 6581f6a482 wip: agent-coordination iteration (runs+backlog+plans UI + 2 task plans + db-drift SQL)
In-flight work from 2026-06-05 that was sitting uncommitted on
feat/agent-pipeline-bridge for 10 days. Moved here to a clean branch
off main so the bridge branch can stay at its committed state (the
DEFERRED Phase 2a snapshot, see stwl-labs/ubiquitous-invention#3).

Touched areas:

- apps/web/app/(app)/[workspaceSlug]/settings/runs/page.tsx — runs page UI iteration
- apps/web/server/routers/{backlog,runs}.ts — router updates
- apps/web/app/(app)/[workspaceSlug]/plans/[planSlug]/[epicSlug]/[taskSlug]/page.tsx — new deep-link route
- apps/web/components/backlog/workflow-prompt-section.tsx — new component
- config/CursorSync.md — coordination doc tweak
- docs/operations/README.md + 2026-06-05-db-drift-reconcile-oauth-tables.sql — operations ops note + reconcile script
- plans/Plan-agent-coordination/Epic-task-as-runnable-unit/Task-{workflow-prompt-task-detail-ui,deep-link-runs-to-task-detail}.md — 2 task specs

This is a working-tree snapshot, not a finished PR. Rebase, split, or
amend as needed when picking it back up.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-15 15:35:39 -05:00

267 lines
8.7 KiB
TypeScript

"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<string>("");
const [lastLoaded, setLastLoaded] = React.useState<string | null>(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 (
<section className="space-y-3 rounded-lg border bg-card p-4">
<Skeleton className="h-5 w-40" />
<Skeleton className="h-24 w-full" />
<Skeleton className="h-32 w-full" />
</section>
);
}
if (promptQuery.error) {
return (
<section className="rounded-lg border border-destructive/40 bg-destructive/5 p-4 text-sm text-destructive">
Couldn&apos;t load workflow prompt: {promptQuery.error.message}
</section>
);
}
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 (
<section className="space-y-4 rounded-lg border bg-card p-4">
<header className="flex flex-wrap items-center gap-3">
<div className="flex h-8 w-8 items-center justify-center rounded-md bg-primary/10">
<Sparkles className="size-4 text-primary" />
</div>
<div className="flex-1">
<h2 className="text-sm font-semibold">Workflow prompt</h2>
<p className="text-xs text-muted-foreground">
What an agent receives when it claims this task via MCP.
</p>
</div>
<Badge
variant={badgeVariantForSource(source)}
className="font-normal"
title={`source=${source}`}
>
{describeSource(source, sourceContext)}
</Badge>
</header>
<div>
<div className="mb-1 text-[10px] uppercase tracking-wide text-muted-foreground">
Effective prompt
</div>
<pre
className={cn(
"whitespace-pre-wrap rounded-md border bg-muted/40 px-3 py-2 font-mono text-xs leading-relaxed",
"text-foreground",
)}
>
{data.effectivePrompt}
</pre>
</div>
<div>
<div className="mb-1 flex items-center justify-between">
<label
htmlFor="workflow-prompt-override"
className="text-[10px] uppercase tracking-wide text-muted-foreground"
>
Override for this task
</label>
{hasOverride ? (
<span className="text-[10px] text-muted-foreground">
Currently overriding inherited prompt
</span>
) : (
<span className="text-[10px] text-muted-foreground">
Empty = inherit
</span>
)}
</div>
<textarea
id="workflow-prompt-override"
value={draft}
onChange={(e) => setDraft(e.target.value)}
disabled={!canEdit || updateMut.isPending}
rows={8}
maxLength={MAX_PROMPT_LENGTH + 100}
placeholder={
canEdit
? "Leave empty to inherit from the epic, plan, or built-in default."
: "Only owners and admins can edit this prompt."
}
className={cn(
"block w-full resize-y rounded-md border border-input bg-background px-3 py-2 font-mono text-xs leading-relaxed",
"ring-offset-background placeholder:text-muted-foreground",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
"disabled:cursor-not-allowed disabled:opacity-60",
)}
/>
<div className="mt-1 flex items-center justify-between text-[10px] text-muted-foreground">
<span className={tooLong ? "text-destructive" : undefined}>
{draft.length.toLocaleString()} / {MAX_PROMPT_LENGTH.toLocaleString()}
</span>
{updateMut.error ? (
<span className="text-destructive">
{updateMut.error.message}
</span>
) : null}
</div>
</div>
{canEdit ? (
<div className="flex items-center justify-end gap-2">
{hasOverride ? (
<Button
type="button"
variant="outline"
size="sm"
disabled={updateMut.isPending}
onClick={() => {
updateMut.mutate({
workspace,
backlogItemId,
workflowPrompt: null,
});
setDraft("");
}}
>
{updateMut.isPending && updateMut.variables?.workflowPrompt === null ? (
<Loader2 className="size-3 animate-spin" />
) : (
"Clear override"
)}
</Button>
) : null}
<Button
type="button"
size="sm"
disabled={!dirty || tooLong || updateMut.isPending}
onClick={() =>
updateMut.mutate({
workspace,
backlogItemId,
workflowPrompt: draft.trim() ? draft : null,
})
}
>
{updateMut.isPending && updateMut.variables?.workflowPrompt !== null ? (
<Loader2 className="size-3 animate-spin" />
) : (
"Save"
)}
</Button>
</div>
) : null}
</section>
);
}