468 lines
16 KiB
TypeScript
468 lines
16 KiB
TypeScript
"use client";
|
|
|
|
import Link from "next/link";
|
|
import { useEffect, useState } from "react";
|
|
import { ExternalLink } from "lucide-react";
|
|
|
|
import {
|
|
useListAgentsApiV1AgentsGet,
|
|
type listAgentsApiV1AgentsGetResponse,
|
|
} from "@/api/generated/agents/agents";
|
|
import {
|
|
useListBoardsApiV1BoardsGet,
|
|
type listBoardsApiV1BoardsGetResponse,
|
|
} from "@/api/generated/boards/boards";
|
|
import { ApiError } from "@/api/mutator";
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogDescription,
|
|
DialogFooter,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
} from "@/components/ui/dialog";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Textarea } from "@/components/ui/textarea";
|
|
import type { ForgejoIssue, AssignIssueAgentResponse } from "@/lib/api-forgejo";
|
|
import {
|
|
assignIssueToAgent,
|
|
getLinkedBoardsForRepository,
|
|
linkBoardForgejoRepository,
|
|
} from "@/lib/api-forgejo";
|
|
|
|
type LinkedBoard = { id: string; name: string };
|
|
|
|
type AssignIssueAgentDialogProps = {
|
|
issue: ForgejoIssue | null;
|
|
repositoryName: string;
|
|
open: boolean;
|
|
onOpenChange: (open: boolean) => void;
|
|
onSuccess: (result: AssignIssueAgentResponse) => void;
|
|
};
|
|
|
|
function buildDefaultAgentPrompt(issue: ForgejoIssue, repositoryName: string) {
|
|
const issueBody = (issue.body ?? issue.body_preview ?? "").trim();
|
|
const lines = [
|
|
`Work on ${repositoryName}#${issue.forgejo_issue_number}: ${issue.title}`,
|
|
"",
|
|
"Use this Forgejo issue as the source of truth.",
|
|
`Issue URL: ${issue.html_url}`,
|
|
];
|
|
|
|
if (issueBody) {
|
|
lines.push("", "Issue details:", issueBody);
|
|
}
|
|
|
|
lines.push(
|
|
"",
|
|
"Expected approach:",
|
|
"- Read the relevant Pipeline code before editing.",
|
|
"- Keep the work scoped to this issue.",
|
|
"- Run targeted validation and report what passed.",
|
|
"- Post progress, blockers, and handoff notes back to the Pipeline task.",
|
|
);
|
|
|
|
return lines.join("\n");
|
|
}
|
|
|
|
export function AssignIssueAgentDialog({
|
|
issue,
|
|
repositoryName,
|
|
open,
|
|
onOpenChange,
|
|
onSuccess,
|
|
}: AssignIssueAgentDialogProps) {
|
|
const [boardId, setBoardId] = useState("");
|
|
const [agentId, setAgentId] = useState("");
|
|
const [priority, setPriority] = useState("medium");
|
|
const [instructions, setInstructions] = useState("");
|
|
const [startImmediately, setStartImmediately] = useState(true);
|
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [linkedBoards, setLinkedBoards] = useState<LinkedBoard[]>([]);
|
|
const [boardsLoading, setBoardsLoading] = useState(false);
|
|
const [linkedBoardsLoaded, setLinkedBoardsLoaded] = useState(false);
|
|
const [linkBoardId, setLinkBoardId] = useState("");
|
|
const [isLinkingRepository, setIsLinkingRepository] = useState(false);
|
|
|
|
// Fetch only boards linked to this issue's repository — prevents picking a
|
|
// board that the backend will reject with "not linked to any board".
|
|
useEffect(() => {
|
|
if (!open || !issue) return;
|
|
let cancelled = false;
|
|
setBoardsLoading(true);
|
|
setLinkedBoardsLoaded(false);
|
|
setLinkedBoards([]);
|
|
setBoardId("");
|
|
getLinkedBoardsForRepository(issue.repository_id)
|
|
.then((boards) => {
|
|
if (cancelled) return;
|
|
setLinkedBoards(boards);
|
|
if (boards.length === 1) setBoardId(boards[0].id);
|
|
})
|
|
.catch(() => {
|
|
if (!cancelled) setLinkedBoards([]);
|
|
})
|
|
.finally(() => {
|
|
if (!cancelled) {
|
|
setBoardsLoading(false);
|
|
setLinkedBoardsLoaded(true);
|
|
}
|
|
});
|
|
return () => { cancelled = true; };
|
|
}, [open, issue]);
|
|
|
|
const agentsQuery = useListAgentsApiV1AgentsGet<
|
|
listAgentsApiV1AgentsGetResponse,
|
|
ApiError
|
|
>(
|
|
{ board_id: boardId || undefined, limit: 200 },
|
|
{ query: { enabled: open && !!boardId, refetchOnMount: "always" } },
|
|
);
|
|
|
|
const boardsQuery = useListBoardsApiV1BoardsGet<
|
|
listBoardsApiV1BoardsGetResponse,
|
|
ApiError
|
|
>(
|
|
{ limit: 200 },
|
|
{
|
|
query: {
|
|
enabled:
|
|
open &&
|
|
linkedBoardsLoaded &&
|
|
!boardsLoading &&
|
|
linkedBoards.length === 0,
|
|
refetchOnMount: "always",
|
|
},
|
|
},
|
|
);
|
|
|
|
const agents =
|
|
agentsQuery.data?.status === 200
|
|
? (agentsQuery.data.data.items ?? [])
|
|
: [];
|
|
const allBoards =
|
|
boardsQuery.data?.status === 200
|
|
? (boardsQuery.data.data.items ?? [])
|
|
: [];
|
|
|
|
useEffect(() => {
|
|
if (!open) {
|
|
setBoardId("");
|
|
setAgentId("");
|
|
setPriority("medium");
|
|
setInstructions("");
|
|
setStartImmediately(true);
|
|
setError(null);
|
|
setLinkedBoardsLoaded(false);
|
|
setLinkBoardId("");
|
|
setIsLinkingRepository(false);
|
|
}
|
|
}, [open]);
|
|
|
|
useEffect(() => {
|
|
if (!open || !issue) return;
|
|
setInstructions(buildDefaultAgentPrompt(issue, repositoryName));
|
|
}, [open, issue, repositoryName]);
|
|
|
|
useEffect(() => {
|
|
setAgentId("");
|
|
}, [boardId]);
|
|
|
|
if (!issue) return null;
|
|
|
|
const noLinkedBoards =
|
|
linkedBoardsLoaded && !boardsLoading && linkedBoards.length === 0;
|
|
const prompt = instructions.trim();
|
|
|
|
const handleSubmit = async () => {
|
|
if (!boardId || !agentId || !prompt) return;
|
|
setIsSubmitting(true);
|
|
setError(null);
|
|
try {
|
|
const result = await assignIssueToAgent(issue.id, {
|
|
board_id: boardId,
|
|
assigned_agent_id: agentId,
|
|
priority,
|
|
start_immediately: startImmediately,
|
|
status: startImmediately ? "in_progress" : "inbox",
|
|
instructions: prompt,
|
|
});
|
|
onSuccess(result);
|
|
onOpenChange(false);
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : "Failed to assign agent");
|
|
} finally {
|
|
setIsSubmitting(false);
|
|
}
|
|
};
|
|
|
|
const handleLinkRepositoryToBoard = async () => {
|
|
if (!issue || !linkBoardId) return;
|
|
setIsLinkingRepository(true);
|
|
setError(null);
|
|
|
|
try {
|
|
await linkBoardForgejoRepository(linkBoardId, issue.repository_id);
|
|
const linkedBoard = allBoards.find((board) => board.id === linkBoardId);
|
|
const nextBoard = {
|
|
id: linkBoardId,
|
|
name: linkedBoard?.name ?? "Selected board",
|
|
};
|
|
setLinkedBoards([nextBoard]);
|
|
setBoardId(nextBoard.id);
|
|
setLinkBoardId("");
|
|
} catch (err) {
|
|
setError(
|
|
err instanceof Error
|
|
? err.message
|
|
: "Failed to link repository to board",
|
|
);
|
|
} finally {
|
|
setIsLinkingRepository(false);
|
|
}
|
|
};
|
|
|
|
const toggleSwitch = (
|
|
value: boolean,
|
|
setter: (v: boolean) => void,
|
|
disabled: boolean,
|
|
) => (
|
|
<button
|
|
type="button"
|
|
role="switch"
|
|
aria-checked={value}
|
|
onClick={() => !disabled && setter(!value)}
|
|
disabled={disabled}
|
|
className={`mt-0.5 inline-flex h-6 w-11 shrink-0 items-center rounded-full border transition ${
|
|
value
|
|
? "border-emerald-600 bg-emerald-600"
|
|
: "border-[color:var(--border)] bg-[color:var(--surface-muted)]"
|
|
} ${disabled ? "cursor-not-allowed opacity-60" : "cursor-pointer"}`}
|
|
>
|
|
<span
|
|
className={`inline-block h-5 w-5 rounded-full bg-[color:var(--surface)] shadow-sm transition ${
|
|
value ? "translate-x-5" : "translate-x-0.5"
|
|
}`}
|
|
/>
|
|
</button>
|
|
);
|
|
|
|
return (
|
|
<Dialog
|
|
open={open}
|
|
onOpenChange={(next) => {
|
|
if (!isSubmitting) onOpenChange(next);
|
|
}}
|
|
>
|
|
<DialogContent className="max-w-lg">
|
|
<DialogHeader>
|
|
<DialogTitle>Assign to agent</DialogTitle>
|
|
<DialogDescription>
|
|
Create a Pipeline task for{" "}
|
|
<span className="font-mono font-semibold text-strong">
|
|
{repositoryName}#{issue.forgejo_issue_number}
|
|
</span>{" "}
|
|
and assign an agent to work on it.
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
|
|
<div className="space-y-4">
|
|
<div className="rounded-xl border border-[color:var(--border)] bg-[color:var(--surface-muted)] px-3 py-2">
|
|
<p className="truncate text-sm font-medium text-strong">
|
|
{issue.title}
|
|
</p>
|
|
</div>
|
|
|
|
{noLinkedBoards ? (
|
|
<div className="rounded-xl border border-[color:rgba(234,179,8,0.4)] bg-[color:rgba(234,179,8,0.08)] p-4 text-sm">
|
|
<p className="font-semibold text-[color:var(--warning,#ca8a04)]">
|
|
Repository not linked to any board
|
|
</p>
|
|
<p className="mt-1 text-muted">
|
|
Before you can assign an agent, link{" "}
|
|
<span className="font-medium text-strong">{repositoryName}</span>{" "}
|
|
to the board that should own this task. Open the target board and
|
|
use its Git Project repositories panel, or manage tracked
|
|
repositories first.
|
|
</p>
|
|
<div className="mt-4 space-y-2">
|
|
<label className="text-sm font-medium text-strong">
|
|
Link to board <span className="text-[color:var(--danger)]">*</span>
|
|
</label>
|
|
<select
|
|
value={linkBoardId}
|
|
onChange={(e) => setLinkBoardId(e.target.value)}
|
|
disabled={boardsQuery.isLoading || isLinkingRepository}
|
|
className="w-full rounded-xl border border-[color:var(--border)] bg-[color:var(--surface)] px-3 py-2 text-sm text-strong focus:outline-none focus:ring-2 focus:ring-[color:var(--accent)]"
|
|
>
|
|
<option value="">
|
|
{boardsQuery.isLoading ? "Loading boards..." : "Select a board..."}
|
|
</option>
|
|
{allBoards.map((board) => (
|
|
<option key={board.id} value={board.id}>
|
|
{board.name}
|
|
</option>
|
|
))}
|
|
</select>
|
|
{boardsQuery.isError ? (
|
|
<p className="text-xs text-[color:var(--danger)]">
|
|
Unable to load boards. You can still manage repositories
|
|
from Git Projects.
|
|
</p>
|
|
) : null}
|
|
</div>
|
|
<div className="mt-3 flex flex-wrap gap-2">
|
|
<Button
|
|
size="sm"
|
|
onClick={handleLinkRepositoryToBoard}
|
|
disabled={
|
|
!linkBoardId || boardsQuery.isLoading || isLinkingRepository
|
|
}
|
|
>
|
|
{isLinkingRepository ? "Linking..." : "Link and continue"}
|
|
</Button>
|
|
<Link
|
|
href="/git-projects/repositories"
|
|
className="inline-flex h-9 items-center gap-2 rounded-xl border border-[color:var(--border)] bg-[color:var(--surface)] px-3 text-xs font-semibold text-strong transition hover:border-[color:var(--accent)] hover:text-[color:var(--accent)]"
|
|
>
|
|
Manage Git repositories
|
|
<ExternalLink className="h-3.5 w-3.5" />
|
|
</Link>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<>
|
|
<div className="space-y-1.5">
|
|
<label className="text-sm font-medium text-strong">
|
|
Board <span className="text-[color:var(--danger)]">*</span>
|
|
</label>
|
|
<select
|
|
value={boardId}
|
|
onChange={(e) => setBoardId(e.target.value)}
|
|
disabled={isSubmitting || boardsLoading}
|
|
className="w-full rounded-xl border border-[color:var(--border)] bg-[color:var(--surface)] px-3 py-2 text-sm text-strong focus:outline-none focus:ring-2 focus:ring-[color:var(--accent)]"
|
|
>
|
|
{boardsLoading ? (
|
|
<option>Loading boards…</option>
|
|
) : (
|
|
<>
|
|
{linkedBoards.length > 1 && (
|
|
<option value="">Select a board…</option>
|
|
)}
|
|
{linkedBoards.map((b) => (
|
|
<option key={b.id} value={b.id}>
|
|
{b.name}
|
|
</option>
|
|
))}
|
|
</>
|
|
)}
|
|
</select>
|
|
</div>
|
|
|
|
<div className="space-y-1.5">
|
|
<label className="text-sm font-medium text-strong">
|
|
Agent <span className="text-[color:var(--danger)]">*</span>
|
|
</label>
|
|
<select
|
|
value={agentId}
|
|
onChange={(e) => setAgentId(e.target.value)}
|
|
disabled={isSubmitting || !boardId || agentsQuery.isLoading}
|
|
className="w-full rounded-xl border border-[color:var(--border)] bg-[color:var(--surface)] px-3 py-2 text-sm text-strong focus:outline-none focus:ring-2 focus:ring-[color:var(--accent)]"
|
|
>
|
|
<option value="">Select an agent…</option>
|
|
{agentsQuery.isLoading ? (
|
|
<option disabled>Loading agents…</option>
|
|
) : (
|
|
agents.map((a) => (
|
|
<option key={a.id} value={a.id}>
|
|
{a.name}
|
|
{a.is_board_lead ? " (lead)" : ""}
|
|
</option>
|
|
))
|
|
)}
|
|
</select>
|
|
{boardId && !agentsQuery.isLoading && agents.length === 0 ? (
|
|
<p className="text-xs text-muted">
|
|
No agents are available on this board yet.
|
|
</p>
|
|
) : null}
|
|
</div>
|
|
|
|
<div className="space-y-1.5">
|
|
<label className="text-sm font-medium text-strong">Priority</label>
|
|
<select
|
|
value={priority}
|
|
onChange={(e) => setPriority(e.target.value)}
|
|
disabled={isSubmitting}
|
|
className="w-full rounded-xl border border-[color:var(--border)] bg-[color:var(--surface)] px-3 py-2 text-sm text-strong focus:outline-none focus:ring-2 focus:ring-[color:var(--accent)]"
|
|
>
|
|
<option value="low">Low</option>
|
|
<option value="medium">Medium</option>
|
|
<option value="high">High</option>
|
|
<option value="critical">Critical</option>
|
|
</select>
|
|
</div>
|
|
|
|
<div className="space-y-1.5">
|
|
<label className="text-sm font-medium text-strong">
|
|
Agent prompt <span className="text-[color:var(--danger)]">*</span>
|
|
</label>
|
|
<Textarea
|
|
value={instructions}
|
|
onChange={(e) => setInstructions(e.target.value)}
|
|
disabled={isSubmitting}
|
|
rows={9}
|
|
placeholder="Prompt the agent with the goal, context, and expected validation…"
|
|
className="resize-y text-sm"
|
|
/>
|
|
<p className="text-xs text-muted">
|
|
This prompt is copied into the Pipeline task and sent with the
|
|
assignment notification.
|
|
</p>
|
|
</div>
|
|
|
|
<div className="flex items-center gap-3">
|
|
{toggleSwitch(startImmediately, setStartImmediately, isSubmitting)}
|
|
<span className="space-y-0.5">
|
|
<span className="block text-sm font-medium text-strong">
|
|
Start immediately
|
|
</span>
|
|
<span className="block text-xs text-muted">
|
|
Set task to <span className="font-mono">in_progress</span>{" "}
|
|
when assigned.
|
|
</span>
|
|
</span>
|
|
</div>
|
|
</>
|
|
)}
|
|
</div>
|
|
|
|
{error ? (
|
|
<div className="rounded-lg border border-[color:rgba(248,113,113,0.35)] bg-[color:rgba(248,113,113,0.08)] p-3 text-xs text-[color:var(--danger)]">
|
|
{error}
|
|
</div>
|
|
) : null}
|
|
|
|
<DialogFooter>
|
|
<Button
|
|
variant="outline"
|
|
onClick={() => onOpenChange(false)}
|
|
disabled={isSubmitting}
|
|
>
|
|
Cancel
|
|
</Button>
|
|
{!noLinkedBoards && (
|
|
<Button
|
|
onClick={handleSubmit}
|
|
disabled={isSubmitting || !boardId || !agentId || !prompt}
|
|
>
|
|
{isSubmitting ? "Assigning…" : "Assign Agent"}
|
|
</Button>
|
|
)}
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
);
|
|
}
|