feat(ui): assign agent

This commit is contained in:
null 2026-05-22 01:48:06 -05:00
parent 5f8078399c
commit d7b3c08d06
3 changed files with 343 additions and 1 deletions

View File

@ -0,0 +1,287 @@
"use client";
import { useEffect, useState } from "react";
import {
useListBoardsApiV1BoardsGet,
type listBoardsApiV1BoardsGetResponse,
} from "@/api/generated/boards/boards";
import {
useListAgentsApiV1AgentsGet,
type listAgentsApiV1AgentsGetResponse,
} from "@/api/generated/agents/agents";
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 } from "@/lib/api-forgejo";
type AssignIssueAgentDialogProps = {
issue: ForgejoIssue | null;
repositoryName: string;
open: boolean;
onOpenChange: (open: boolean) => void;
onSuccess: (result: AssignIssueAgentResponse) => void;
};
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 boardsQuery = useListBoardsApiV1BoardsGet<
listBoardsApiV1BoardsGetResponse,
ApiError
>(undefined, { query: { enabled: open, refetchOnMount: "always" } });
const agentsQuery = useListAgentsApiV1AgentsGet<
listAgentsApiV1AgentsGetResponse,
ApiError
>(
{ board_id: boardId || undefined, limit: 200 },
{ query: { enabled: open && !!boardId, refetchOnMount: "always" } },
);
const boards =
boardsQuery.data?.status === 200
? (boardsQuery.data.data.items ?? [])
: [];
const agents =
agentsQuery.data?.status === 200
? (agentsQuery.data.data.items ?? [])
: [];
useEffect(() => {
if (!open) {
setBoardId("");
setAgentId("");
setPriority("medium");
setInstructions("");
setStartImmediately(true);
setError(null);
}
}, [open]);
useEffect(() => {
if (boards.length > 0 && !boardId) {
setBoardId(boards[0].id);
}
}, [boards, boardId]);
useEffect(() => {
setAgentId("");
}, [boardId]);
if (!issue) return null;
const handleSubmit = async () => {
if (!boardId) return;
setIsSubmitting(true);
setError(null);
try {
const result = await assignIssueToAgent(issue.id, {
board_id: boardId,
assigned_agent_id: agentId || undefined,
priority,
start_immediately: startImmediately,
status: startImmediately && agentId ? "in_progress" : "inbox",
instructions: instructions.trim() || undefined,
});
onSuccess(result);
onOpenChange(false);
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to assign agent");
} finally {
setIsSubmitting(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>
<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 || boardsQuery.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)]"
>
{boardsQuery.isLoading ? (
<option>Loading boards</option>
) : boards.length === 0 ? (
<option>No boards available</option>
) : (
boards.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-xs font-normal text-muted">
(optional leaves task unassigned if blank)
</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="">Unassigned</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>
</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">
Instructions{" "}
<span className="text-xs font-normal text-muted">(optional)</span>
</label>
<Textarea
value={instructions}
onChange={(e) => setInstructions(e.target.value)}
disabled={isSubmitting}
rows={3}
placeholder="Additional context or instructions for the agent…"
className="resize-none text-sm"
/>
</div>
{agentId ? (
<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>{" "}
on assign.
</span>
</span>
</div>
) : null}
</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>
<Button
onClick={handleSubmit}
disabled={isSubmitting || !boardId}
>
{isSubmitting ? "Assigning…" : agentId ? "Assign Agent" : "Create Task"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@ -2,7 +2,7 @@
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { ExternalLink, Loader2, MessageSquarePlus, Pencil, XCircle } from "lucide-react"; import { ExternalLink, Loader2, MessageSquarePlus, Pencil, UserPlus, XCircle } from "lucide-react";
import { import {
Dialog, Dialog,
@ -20,6 +20,7 @@ import {
type ForgejoIssue, type ForgejoIssue,
type ForgejoIssueDetail, type ForgejoIssueDetail,
} from "@/lib/api-forgejo"; } from "@/lib/api-forgejo";
import { AssignIssueAgentDialog } from "@/components/git/AssignIssueAgentDialog";
import { CloseForgejoIssueDialog } from "@/components/git/CloseForgejoIssueDialog"; import { CloseForgejoIssueDialog } from "@/components/git/CloseForgejoIssueDialog";
import { EditForgejoIssueDialog } from "@/components/git/EditForgejoIssueDialog"; import { EditForgejoIssueDialog } from "@/components/git/EditForgejoIssueDialog";
import { PostForgejoCommentDialog } from "@/components/git/PostForgejoCommentDialog"; import { PostForgejoCommentDialog } from "@/components/git/PostForgejoCommentDialog";
@ -70,6 +71,7 @@ export function ForgejoIssueDetailDialog({
const [isCommentDialogOpen, setIsCommentDialogOpen] = useState(false); const [isCommentDialogOpen, setIsCommentDialogOpen] = useState(false);
const [isEditDialogOpen, setIsEditDialogOpen] = useState(false); const [isEditDialogOpen, setIsEditDialogOpen] = useState(false);
const [isCloseIssueDialogOpen, setIsCloseIssueDialogOpen] = useState(false); const [isCloseIssueDialogOpen, setIsCloseIssueDialogOpen] = useState(false);
const [isAssignDialogOpen, setIsAssignDialogOpen] = useState(false);
const loadDetail = (id: string) => { const loadDetail = (id: string) => {
let cancelled = false; let cancelled = false;
@ -202,6 +204,15 @@ export function ForgejoIssueDetailDialog({
<MessageSquarePlus className="h-3.5 w-3.5" /> <MessageSquarePlus className="h-3.5 w-3.5" />
Comment Comment
</Button> </Button>
<Button
variant="outline"
size="sm"
className="h-9 gap-2 rounded-xl px-3 text-xs font-semibold"
onClick={() => setIsAssignDialogOpen(true)}
>
<UserPlus className="h-3.5 w-3.5" />
Assign Agent
</Button>
<a <a
href={active.html_url} href={active.html_url}
target="_blank" target="_blank"
@ -385,6 +396,17 @@ export function ForgejoIssueDetailDialog({
</DialogContent> </DialogContent>
</Dialog> </Dialog>
<AssignIssueAgentDialog
issue={issue}
repositoryName={repositoryName}
open={isAssignDialogOpen}
onOpenChange={setIsAssignDialogOpen}
onSuccess={() => {
setIsAssignDialogOpen(false);
onRefresh?.();
}}
/>
<CloseForgejoIssueDialog <CloseForgejoIssueDialog
issue={issue} issue={issue}
repositoryName={repositoryName} repositoryName={repositoryName}

View File

@ -429,6 +429,39 @@ export interface CreateIssueResponse {
repository_id: string; repository_id: string;
} }
export interface AssignIssueAgentRequest {
board_id?: string;
assigned_agent_id?: string;
priority?: string;
status?: "inbox" | "in_progress";
start_immediately?: boolean;
instructions?: string;
}
export interface AssignIssueAgentResponse {
success: boolean;
created: boolean;
issue_id: string;
task_id: string;
board_id: string;
assigned_agent_id: string | null;
status: string;
title: string;
}
export async function assignIssueToAgent(
issueId: string,
data: AssignIssueAgentRequest,
): Promise<AssignIssueAgentResponse> {
return fetchJson<AssignIssueAgentResponse>(
`/api/v1/forgejo/issues/${issueId}/task`,
{
method: "POST",
body: JSON.stringify(data),
},
);
}
export async function createForgejoIssue( export async function createForgejoIssue(
data: CreateIssueRequest, data: CreateIssueRequest,
): Promise<CreateIssueResponse> { ): Promise<CreateIssueResponse> {