268 lines
8.7 KiB
TypeScript
268 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'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>
|
||
|
|
);
|
||
|
|
}
|