mirror of
https://github.com/multica-ai/multica.git
synced 2026-06-17 03:38:32 +02:00
feat(server): trigger agent when issue moves out of backlog (#1006)
* feat(server): trigger agent when issue moves out of backlog When a member moves an agent-assigned issue from "backlog" to an active status (e.g. "todo", "in_progress"), enqueue an agent task so the agent starts working. This lets backlog act as a parking lot where issues can be assigned to agents without immediately triggering execution. Applies to both single and batch issue updates. * fix(server): treat backlog as parking lot — no trigger on create/assign Address review feedback: creating or assigning an agent to a backlog issue no longer triggers immediate execution. Only moving out of backlog to an active status triggers the agent, producing exactly one task. - shouldEnqueueAgentTask now gates on backlog status - backlog→active trigger uses isAgentAssigneeReady directly - Added TestBacklogNoTriggerOnCreate test - Updated TestBacklogToTodoTriggersAgent to assert exactly 1 task across the full create→move path (no manual cleanup) * feat(ui): show toast hint when assigning agent to backlog issue Users may not know that backlog issues won't trigger agent execution until moved to an active status. Show an actionable toast with a "Move to Todo" button when: - Assigning an agent to a backlog issue in the detail page - Creating a backlog issue with an agent assignee * feat(ui): add "Don't show again" option to backlog agent toast Users who understand the backlog parking lot behavior can dismiss the hint permanently. Uses localStorage to persist the preference. * feat(ui): replace backlog agent toast with AlertDialog Use a modal dialog instead of a toast notification so users must explicitly acknowledge the hint. The dialog offers three options: - "Move to Todo" — changes status and triggers the agent - "Keep in Backlog" — dismisses without action - "Don't show again" — persists dismissal in localStorage * fix(ui): improve backlog agent dialog * fix(ui): close create dialog behind hint, use checkbox for don't-show-again 1. Create Issue dialog now closes when the backlog agent hint appears, so only the hint dialog is visible (not stacked behind). 2. "Don't show again" is now a checkbox instead of a separate button. When checked, clicking either "Keep in Backlog" or "Move to Todo" persists the preference. * fix(ui): smooth backlog agent hint dialog * fix(test): add useUpdateIssue mock to create-issue test The test mock for @multica/core/issues/mutations was missing the useUpdateIssue export that create-issue.tsx now imports, causing CI failure.
This commit is contained in:
124
packages/views/issues/components/backlog-agent-hint-dialog.tsx
Normal file
124
packages/views/issues/components/backlog-agent-hint-dialog.tsx
Normal file
@@ -0,0 +1,124 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Archive, ArrowRight, Bot, CheckCircle2 } from "lucide-react";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogContent,
|
||||
} from "@multica/ui/components/ui/alert-dialog";
|
||||
import { Button } from "@multica/ui/components/ui/button";
|
||||
import { Checkbox } from "@multica/ui/components/ui/checkbox";
|
||||
|
||||
interface BacklogAgentHintDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onDismissPermanently: () => void;
|
||||
onMoveToTodo: () => void;
|
||||
}
|
||||
|
||||
export function BacklogAgentHintDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
onDismissPermanently,
|
||||
onMoveToTodo,
|
||||
}: BacklogAgentHintDialogProps) {
|
||||
return (
|
||||
<AlertDialog open={open} onOpenChange={onOpenChange}>
|
||||
<AlertDialogContent className="w-[calc(100vw-2rem)] !max-w-[480px] gap-0 overflow-hidden rounded-lg p-0">
|
||||
<BacklogAgentHintContent
|
||||
onKeepInBacklog={() => onOpenChange(false)}
|
||||
onDismissPermanently={onDismissPermanently}
|
||||
onMoveToTodo={onMoveToTodo}
|
||||
/>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
);
|
||||
}
|
||||
|
||||
interface BacklogAgentHintContentProps {
|
||||
onKeepInBacklog: () => void;
|
||||
onDismissPermanently: () => void;
|
||||
onMoveToTodo: () => void;
|
||||
}
|
||||
|
||||
export function BacklogAgentHintContent({
|
||||
onKeepInBacklog,
|
||||
onDismissPermanently,
|
||||
onMoveToTodo,
|
||||
}: BacklogAgentHintContentProps) {
|
||||
const [dontShowAgain, setDontShowAgain] = useState(false);
|
||||
|
||||
const handleKeepInBacklog = () => {
|
||||
if (dontShowAgain) onDismissPermanently();
|
||||
onKeepInBacklog();
|
||||
};
|
||||
|
||||
const handleMoveToTodo = () => {
|
||||
if (dontShowAgain) onDismissPermanently();
|
||||
onMoveToTodo();
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="px-5 pb-4 pt-5">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="mt-0.5 flex size-10 shrink-0 items-center justify-center rounded-lg border bg-muted text-muted-foreground">
|
||||
<Bot className="size-4" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<h2 className="text-base font-semibold">
|
||||
Agent is paused in Backlog
|
||||
</h2>
|
||||
<p className="mt-1 text-sm leading-5 text-muted-foreground">
|
||||
This issue is parked, so the assigned agent will wait. Move it to
|
||||
Todo when you want the agent to start.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 grid gap-2 rounded-lg border bg-muted/35 p-3 text-sm">
|
||||
<div className="flex items-center gap-2 text-muted-foreground">
|
||||
<Archive className="size-4 shrink-0" />
|
||||
<span className="font-medium text-foreground">Backlog</span>
|
||||
<span className="text-muted-foreground">keeps the agent paused</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-muted-foreground">
|
||||
<ArrowRight className="size-4 shrink-0" />
|
||||
<span className="font-medium text-foreground">Todo</span>
|
||||
<span className="text-muted-foreground">starts the agent</span>
|
||||
<CheckCircle2 className="ml-auto size-4 shrink-0 text-primary" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-t bg-muted/25 px-5 py-4">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<label className="flex min-w-0 cursor-pointer items-center gap-2 text-sm text-muted-foreground">
|
||||
<Checkbox
|
||||
checked={dontShowAgain}
|
||||
onCheckedChange={(next) => setDontShowAgain(next === true)}
|
||||
/>
|
||||
<span className="truncate">Don't show this again</span>
|
||||
</label>
|
||||
<div className="flex flex-col-reverse gap-2 sm:flex-row sm:justify-end">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="w-full sm:w-auto"
|
||||
onClick={handleKeepInBacklog}
|
||||
>
|
||||
Keep in Backlog
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
className="w-full sm:w-auto"
|
||||
onClick={handleMoveToTodo}
|
||||
>
|
||||
Move to Todo
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -64,6 +64,7 @@ import { ProjectPicker } from "../../projects/components/project-picker";
|
||||
import { CommentCard } from "./comment-card";
|
||||
import { CommentInput } from "./comment-input";
|
||||
import { AgentLiveCard, TaskRunHistory } from "./agent-live-card";
|
||||
import { BacklogAgentHintDialog } from "./backlog-agent-hint-dialog";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useAuthStore } from "@multica/core/auth";
|
||||
import { useWorkspaceStore } from "@multica/core/workspace";
|
||||
@@ -344,6 +345,7 @@ export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layo
|
||||
const [sidebarOpen, setSidebarOpen] = useState(defaultSidebarOpen);
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||
const [backlogHintOpen, setBacklogHintOpen] = useState(false);
|
||||
const [propertiesOpen, setPropertiesOpen] = useState(true);
|
||||
const [detailsOpen, setDetailsOpen] = useState(true);
|
||||
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
||||
@@ -444,6 +446,16 @@ export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layo
|
||||
{ id, ...updates },
|
||||
{ onError: () => toast.error("Failed to update issue") },
|
||||
);
|
||||
// Hint: assigning an agent to a backlog issue won't trigger execution
|
||||
// until the issue is moved to an active status.
|
||||
if (
|
||||
updates.assignee_type === "agent" &&
|
||||
updates.assignee_id &&
|
||||
issue.status === "backlog" &&
|
||||
localStorage.getItem("multica:backlog-agent-hint-dismissed") !== "true"
|
||||
) {
|
||||
setBacklogHintOpen(true);
|
||||
}
|
||||
},
|
||||
[issue, id, updateIssueMutation],
|
||||
);
|
||||
@@ -862,6 +874,21 @@ export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layo
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
|
||||
<BacklogAgentHintDialog
|
||||
open={backlogHintOpen}
|
||||
onOpenChange={setBacklogHintOpen}
|
||||
onDismissPermanently={() => {
|
||||
localStorage.setItem("multica:backlog-agent-hint-dismissed", "true");
|
||||
}}
|
||||
onMoveToTodo={() => {
|
||||
updateIssueMutation.mutate(
|
||||
{ id, status: "todo" },
|
||||
{ onError: () => toast.error("Failed to update status") },
|
||||
);
|
||||
setBacklogHintOpen(false);
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Set parent issue picker */}
|
||||
<IssuePickerDialog
|
||||
open={parentPickerOpen}
|
||||
|
||||
@@ -49,6 +49,7 @@ vi.mock("@multica/core/issues/stores/draft-store", () => ({
|
||||
|
||||
vi.mock("@multica/core/issues/mutations", () => ({
|
||||
useCreateIssue: () => ({ mutateAsync: mockCreateIssue }),
|
||||
useUpdateIssue: () => ({ mutate: vi.fn() }),
|
||||
}));
|
||||
|
||||
vi.mock("@multica/core/hooks/use-file-upload", () => ({
|
||||
|
||||
@@ -15,10 +15,11 @@ import { Tooltip, TooltipTrigger, TooltipContent } from "@multica/ui/components/
|
||||
import { Button } from "@multica/ui/components/ui/button";
|
||||
import { ContentEditor, type ContentEditorRef, TitleEditor, useFileDropZone, FileDropOverlay } from "../editor";
|
||||
import { StatusIcon, StatusPicker, PriorityPicker, AssigneePicker, DueDatePicker } from "../issues/components";
|
||||
import { BacklogAgentHintContent } from "../issues/components/backlog-agent-hint-dialog";
|
||||
import { ProjectPicker } from "../projects/components/project-picker";
|
||||
import { useWorkspaceStore } from "@multica/core/workspace";
|
||||
import { useIssueDraftStore } from "@multica/core/issues/stores/draft-store";
|
||||
import { useCreateIssue } from "@multica/core/issues/mutations";
|
||||
import { useCreateIssue, useUpdateIssue } from "@multica/core/issues/mutations";
|
||||
import { useFileUpload } from "@multica/core/hooks/use-file-upload";
|
||||
import { api } from "@multica/core/api";
|
||||
import { FileUploadButton } from "@multica/ui/components/common/file-upload-button";
|
||||
@@ -74,6 +75,7 @@ export function CreateIssueModal({ onClose, data }: { onClose: () => void; data?
|
||||
(data?.project_id as string) || undefined,
|
||||
);
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
const [backlogHintIssueId, setBacklogHintIssueId] = useState<string | null>(null);
|
||||
|
||||
// File upload — collect attachment IDs so we can link them after issue creation.
|
||||
const [attachmentIds, setAttachmentIds] = useState<string[]>([]);
|
||||
@@ -97,6 +99,7 @@ export function CreateIssueModal({ onClose, data }: { onClose: () => void; data?
|
||||
const updateDueDate = (v: string | null) => { setDueDate(v); setDraft({ dueDate: v }); };
|
||||
|
||||
const createIssueMutation = useCreateIssue();
|
||||
const updateIssueMutation = useUpdateIssue();
|
||||
const handleSubmit = async () => {
|
||||
if (!title.trim() || submitting) return;
|
||||
setSubmitting(true);
|
||||
@@ -114,31 +117,42 @@ export function CreateIssueModal({ onClose, data }: { onClose: () => void; data?
|
||||
project_id: projectId,
|
||||
});
|
||||
clearDraft();
|
||||
onClose();
|
||||
toast.custom((t) => (
|
||||
<div className="bg-popover text-popover-foreground border rounded-lg shadow-lg p-4 w-[360px]">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<div className="flex items-center justify-center size-5 rounded-full bg-emerald-500/15 text-emerald-500">
|
||||
<Check className="size-3" />
|
||||
const shouldShowBacklogHint =
|
||||
status === "backlog" && assigneeType === "agent" && assigneeId &&
|
||||
localStorage.getItem("multica:backlog-agent-hint-dismissed") !== "true";
|
||||
|
||||
if (shouldShowBacklogHint) {
|
||||
setBacklogHintIssueId(issue.id);
|
||||
} else {
|
||||
onClose();
|
||||
}
|
||||
|
||||
if (!shouldShowBacklogHint) {
|
||||
toast.custom((t) => (
|
||||
<div className="bg-popover text-popover-foreground border rounded-lg shadow-lg p-4 w-[360px]">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<div className="flex items-center justify-center size-5 rounded-full bg-emerald-500/15 text-emerald-500">
|
||||
<Check className="size-3" />
|
||||
</div>
|
||||
<span className="text-sm font-medium">Issue created</span>
|
||||
</div>
|
||||
<span className="text-sm font-medium">Issue created</span>
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground ml-7">
|
||||
<StatusIcon status={issue.status} className="size-3.5 shrink-0" />
|
||||
<span className="truncate">{issue.identifier} – {issue.title}</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="ml-7 mt-2 text-sm text-primary hover:underline cursor-pointer"
|
||||
onClick={() => {
|
||||
router.push(`/issues/${issue.id}`);
|
||||
toast.dismiss(t);
|
||||
}}
|
||||
>
|
||||
View issue
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground ml-7">
|
||||
<StatusIcon status={issue.status} className="size-3.5 shrink-0" />
|
||||
<span className="truncate">{issue.identifier} – {issue.title}</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="ml-7 mt-2 text-sm text-primary hover:underline cursor-pointer"
|
||||
onClick={() => {
|
||||
router.push(`/issues/${issue.id}`);
|
||||
toast.dismiss(t);
|
||||
}}
|
||||
>
|
||||
View issue
|
||||
</button>
|
||||
</div>
|
||||
), { duration: 5000 });
|
||||
), { duration: 5000 });
|
||||
}
|
||||
} catch {
|
||||
toast.error("Failed to create issue");
|
||||
} finally {
|
||||
@@ -147,145 +161,179 @@ export function CreateIssueModal({ onClose, data }: { onClose: () => void; data?
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open onOpenChange={(v) => { if (!v) onClose(); }}>
|
||||
<Dialog
|
||||
open
|
||||
onOpenChange={(v) => {
|
||||
if (!v) {
|
||||
setBacklogHintIssueId(null);
|
||||
onClose();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogContent
|
||||
finalFocus={false}
|
||||
showCloseButton={false}
|
||||
className={cn(
|
||||
"p-0 gap-0 flex flex-col overflow-hidden",
|
||||
"!top-1/2 !left-1/2 !-translate-x-1/2",
|
||||
"!transition-all !duration-300 !ease-out",
|
||||
isExpanded
|
||||
backlogHintIssueId
|
||||
? "!max-w-[480px] !w-[calc(100vw-2rem)] !h-auto !-translate-y-1/2 !transition-none !duration-0"
|
||||
: "!transition-all !duration-300 !ease-out",
|
||||
!backlogHintIssueId && isExpanded
|
||||
? "!max-w-4xl !w-full !h-5/6 !-translate-y-1/2"
|
||||
: "!max-w-2xl !w-full !h-96 !-translate-y-1/2",
|
||||
: !backlogHintIssueId
|
||||
? "!max-w-2xl !w-full !h-96 !-translate-y-1/2"
|
||||
: "",
|
||||
)}
|
||||
>
|
||||
<DialogTitle className="sr-only">New Issue</DialogTitle>
|
||||
{backlogHintIssueId ? (
|
||||
<BacklogAgentHintContent
|
||||
onKeepInBacklog={() => {
|
||||
setBacklogHintIssueId(null);
|
||||
onClose();
|
||||
}}
|
||||
onDismissPermanently={() => {
|
||||
localStorage.setItem("multica:backlog-agent-hint-dismissed", "true");
|
||||
}}
|
||||
onMoveToTodo={() => {
|
||||
updateIssueMutation.mutate(
|
||||
{ id: backlogHintIssueId, status: "todo" },
|
||||
{ onError: () => toast.error("Failed to update status") },
|
||||
);
|
||||
setBacklogHintIssueId(null);
|
||||
onClose();
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<DialogTitle className="sr-only">New Issue</DialogTitle>
|
||||
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-5 pt-3 pb-2 shrink-0">
|
||||
<div className="flex items-center gap-1.5 text-xs">
|
||||
<span className="text-muted-foreground">{workspaceName}</span>
|
||||
<ChevronRight className="size-3 text-muted-foreground/50" />
|
||||
{typeof data?.parent_issue_identifier === "string" && (
|
||||
<>
|
||||
<span className="text-muted-foreground">{data.parent_issue_identifier}</span>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-5 pt-3 pb-2 shrink-0">
|
||||
<div className="flex items-center gap-1.5 text-xs">
|
||||
<span className="text-muted-foreground">{workspaceName}</span>
|
||||
<ChevronRight className="size-3 text-muted-foreground/50" />
|
||||
</>
|
||||
)}
|
||||
<span className="font-medium">{data?.parent_issue_id ? "New sub-issue" : "New issue"}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<button
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
className="rounded-sm p-1.5 opacity-70 hover:opacity-100 hover:bg-accent/60 transition-all cursor-pointer"
|
||||
>
|
||||
{isExpanded ? <Minimize2 className="size-4" /> : <Maximize2 className="size-4" />}
|
||||
</button>
|
||||
}
|
||||
{typeof data?.parent_issue_identifier === "string" && (
|
||||
<>
|
||||
<span className="text-muted-foreground">{data.parent_issue_identifier}</span>
|
||||
<ChevronRight className="size-3 text-muted-foreground/50" />
|
||||
</>
|
||||
)}
|
||||
<span className="font-medium">{data?.parent_issue_id ? "New sub-issue" : "New issue"}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<button
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
className="rounded-sm p-1.5 opacity-70 hover:opacity-100 hover:bg-accent/60 transition-all cursor-pointer"
|
||||
>
|
||||
{isExpanded ? <Minimize2 className="size-4" /> : <Maximize2 className="size-4" />}
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
<TooltipContent side="bottom">{isExpanded ? "Collapse" : "Expand"}</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="rounded-sm p-1.5 opacity-70 hover:opacity-100 hover:bg-accent/60 transition-all cursor-pointer"
|
||||
>
|
||||
<XIcon className="size-4" />
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
<TooltipContent side="bottom">Close</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Title */}
|
||||
<div className="px-5 pb-2 shrink-0">
|
||||
<TitleEditor
|
||||
autoFocus
|
||||
defaultValue={draft.title}
|
||||
placeholder="Issue title"
|
||||
className="text-lg font-semibold"
|
||||
onChange={(v) => updateTitle(v)}
|
||||
onSubmit={handleSubmit}
|
||||
/>
|
||||
<TooltipContent side="bottom">{isExpanded ? "Collapse" : "Expand"}</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="rounded-sm p-1.5 opacity-70 hover:opacity-100 hover:bg-accent/60 transition-all cursor-pointer"
|
||||
>
|
||||
<XIcon className="size-4" />
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
|
||||
{/* Description — takes remaining space */}
|
||||
<div {...descDropZoneProps} className="relative flex-1 min-h-0 overflow-y-auto px-5">
|
||||
<ContentEditor
|
||||
ref={descEditorRef}
|
||||
defaultValue={draft.description}
|
||||
placeholder="Add description..."
|
||||
onUpdate={(md) => setDraft({ description: md })}
|
||||
onUploadFile={handleUpload}
|
||||
debounceMs={500}
|
||||
/>
|
||||
<TooltipContent side="bottom">Close</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
{descDragOver && <FileDropOverlay />}
|
||||
</div>
|
||||
|
||||
{/* Title */}
|
||||
<div className="px-5 pb-2 shrink-0">
|
||||
<TitleEditor
|
||||
autoFocus
|
||||
defaultValue={draft.title}
|
||||
placeholder="Issue title"
|
||||
className="text-lg font-semibold"
|
||||
onChange={(v) => updateTitle(v)}
|
||||
onSubmit={handleSubmit}
|
||||
/>
|
||||
</div>
|
||||
{/* Property toolbar */}
|
||||
<div className="flex items-center gap-1.5 px-4 py-2 shrink-0 flex-wrap">
|
||||
{/* Status */}
|
||||
<StatusPicker
|
||||
status={status}
|
||||
onUpdate={(u) => { if (u.status) updateStatus(u.status); }}
|
||||
triggerRender={<PillButton />}
|
||||
align="start"
|
||||
/>
|
||||
|
||||
{/* Description — takes remaining space */}
|
||||
<div {...descDropZoneProps} className="relative flex-1 min-h-0 overflow-y-auto px-5">
|
||||
<ContentEditor
|
||||
ref={descEditorRef}
|
||||
defaultValue={draft.description}
|
||||
placeholder="Add description..."
|
||||
onUpdate={(md) => setDraft({ description: md })}
|
||||
onUploadFile={handleUpload}
|
||||
debounceMs={500}
|
||||
/>
|
||||
{descDragOver && <FileDropOverlay />}
|
||||
</div>
|
||||
{/* Priority */}
|
||||
<PriorityPicker
|
||||
priority={priority}
|
||||
onUpdate={(u) => { if (u.priority) updatePriority(u.priority); }}
|
||||
triggerRender={<PillButton />}
|
||||
align="start"
|
||||
/>
|
||||
|
||||
{/* Property toolbar */}
|
||||
<div className="flex items-center gap-1.5 px-4 py-2 shrink-0 flex-wrap">
|
||||
{/* Status */}
|
||||
<StatusPicker
|
||||
status={status}
|
||||
onUpdate={(u) => { if (u.status) updateStatus(u.status); }}
|
||||
triggerRender={<PillButton />}
|
||||
align="start"
|
||||
/>
|
||||
{/* Assignee */}
|
||||
<AssigneePicker
|
||||
assigneeType={assigneeType ?? null}
|
||||
assigneeId={assigneeId ?? null}
|
||||
onUpdate={(u) => updateAssignee(
|
||||
u.assignee_type ?? undefined,
|
||||
u.assignee_id ?? undefined,
|
||||
)}
|
||||
triggerRender={<PillButton />}
|
||||
align="start"
|
||||
/>
|
||||
|
||||
{/* Priority */}
|
||||
<PriorityPicker
|
||||
priority={priority}
|
||||
onUpdate={(u) => { if (u.priority) updatePriority(u.priority); }}
|
||||
triggerRender={<PillButton />}
|
||||
align="start"
|
||||
/>
|
||||
{/* Due date */}
|
||||
<DueDatePicker
|
||||
dueDate={dueDate}
|
||||
onUpdate={(u) => updateDueDate(u.due_date ?? null)}
|
||||
triggerRender={<PillButton />}
|
||||
align="start"
|
||||
/>
|
||||
|
||||
{/* Assignee */}
|
||||
<AssigneePicker
|
||||
assigneeType={assigneeType ?? null}
|
||||
assigneeId={assigneeId ?? null}
|
||||
onUpdate={(u) => updateAssignee(
|
||||
u.assignee_type ?? undefined,
|
||||
u.assignee_id ?? undefined,
|
||||
)}
|
||||
triggerRender={<PillButton />}
|
||||
align="start"
|
||||
/>
|
||||
{/* Project */}
|
||||
<ProjectPicker
|
||||
projectId={projectId ?? null}
|
||||
onUpdate={(u) => setProjectId(u.project_id ?? undefined)}
|
||||
triggerRender={<PillButton />}
|
||||
align="start"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Due date */}
|
||||
<DueDatePicker
|
||||
dueDate={dueDate}
|
||||
onUpdate={(u) => updateDueDate(u.due_date ?? null)}
|
||||
triggerRender={<PillButton />}
|
||||
align="start"
|
||||
/>
|
||||
|
||||
{/* Project */}
|
||||
<ProjectPicker
|
||||
projectId={projectId ?? null}
|
||||
onUpdate={(u) => setProjectId(u.project_id ?? undefined)}
|
||||
triggerRender={<PillButton />}
|
||||
align="start"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex items-center justify-between px-4 py-3 border-t shrink-0">
|
||||
<FileUploadButton
|
||||
onSelect={(file) => descEditorRef.current?.uploadFile(file)}
|
||||
/>
|
||||
<Button size="sm" onClick={handleSubmit} disabled={!title.trim() || submitting}>
|
||||
{submitting ? "Creating..." : "Create Issue"}
|
||||
</Button>
|
||||
</div>
|
||||
{/* Footer */}
|
||||
<div className="flex items-center justify-between px-4 py-3 border-t shrink-0">
|
||||
<FileUploadButton
|
||||
onSelect={(file) => descEditorRef.current?.uploadFile(file)}
|
||||
/>
|
||||
<Button size="sm" onClick={handleSubmit} disabled={!title.trim() || submitting}>
|
||||
{submitting ? "Creating..." : "Create Issue"}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
|
||||
@@ -975,6 +975,115 @@ func TestResolveActor(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestBacklogNoTriggerOnCreate verifies that creating a backlog issue with an
|
||||
// agent assignee does NOT enqueue a task — backlog is a parking lot.
|
||||
func TestBacklogNoTriggerOnCreate(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
var agentID string
|
||||
err := testPool.QueryRow(ctx,
|
||||
`SELECT id FROM agent WHERE workspace_id = $1 AND name = $2`,
|
||||
testWorkspaceID, "Handler Test Agent",
|
||||
).Scan(&agentID)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to find test agent: %v", err)
|
||||
}
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req := newRequest("POST", "/api/issues?workspace_id="+testWorkspaceID, map[string]any{
|
||||
"title": "Backlog no-trigger test",
|
||||
"status": "backlog",
|
||||
"assignee_type": "agent",
|
||||
"assignee_id": agentID,
|
||||
})
|
||||
testHandler.CreateIssue(w, req)
|
||||
if w.Code != http.StatusCreated {
|
||||
t.Fatalf("CreateIssue: expected 201, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
var created IssueResponse
|
||||
json.NewDecoder(w.Body).Decode(&created)
|
||||
|
||||
var taskCount int
|
||||
err = testPool.QueryRow(ctx,
|
||||
`SELECT count(*) FROM agent_task_queue WHERE issue_id = $1`,
|
||||
created.ID,
|
||||
).Scan(&taskCount)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to count tasks: %v", err)
|
||||
}
|
||||
if taskCount != 0 {
|
||||
t.Fatalf("expected no tasks for backlog issue on creation, got %d", taskCount)
|
||||
}
|
||||
|
||||
// Cleanup
|
||||
cleanupReq := newRequest("DELETE", "/api/issues/"+created.ID, nil)
|
||||
cleanupReq = withURLParam(cleanupReq, "id", created.ID)
|
||||
testHandler.DeleteIssue(httptest.NewRecorder(), cleanupReq)
|
||||
}
|
||||
|
||||
// TestBacklogToTodoTriggersAgent verifies that moving an agent-assigned issue
|
||||
// from "backlog" to "todo" enqueues exactly one agent task (none on creation,
|
||||
// one on status transition).
|
||||
func TestBacklogToTodoTriggersAgent(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
var agentID string
|
||||
err := testPool.QueryRow(ctx,
|
||||
`SELECT id FROM agent WHERE workspace_id = $1 AND name = $2`,
|
||||
testWorkspaceID, "Handler Test Agent",
|
||||
).Scan(&agentID)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to find test agent: %v", err)
|
||||
}
|
||||
|
||||
// Create a backlog issue assigned to the agent — should NOT trigger.
|
||||
w := httptest.NewRecorder()
|
||||
req := newRequest("POST", "/api/issues?workspace_id="+testWorkspaceID, map[string]any{
|
||||
"title": "Backlog trigger test",
|
||||
"status": "backlog",
|
||||
"assignee_type": "agent",
|
||||
"assignee_id": agentID,
|
||||
})
|
||||
testHandler.CreateIssue(w, req)
|
||||
if w.Code != http.StatusCreated {
|
||||
t.Fatalf("CreateIssue: expected 201, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
var created IssueResponse
|
||||
json.NewDecoder(w.Body).Decode(&created)
|
||||
|
||||
// Move the issue from backlog to todo — should trigger.
|
||||
w = httptest.NewRecorder()
|
||||
req = newRequest("PUT", "/api/issues/"+created.ID, map[string]any{
|
||||
"status": "todo",
|
||||
})
|
||||
req = withURLParam(req, "id", created.ID)
|
||||
testHandler.UpdateIssue(w, req)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("UpdateIssue: expected 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
// Verify exactly one task was enqueued (from the status transition, not creation).
|
||||
var taskCount int
|
||||
err = testPool.QueryRow(ctx,
|
||||
`SELECT count(*) FROM agent_task_queue WHERE issue_id = $1 AND agent_id = $2 AND status = 'queued'`,
|
||||
created.ID, agentID,
|
||||
).Scan(&taskCount)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to count tasks: %v", err)
|
||||
}
|
||||
if taskCount != 1 {
|
||||
t.Fatalf("expected exactly 1 task after backlog->todo transition, got %d", taskCount)
|
||||
}
|
||||
|
||||
// Cleanup
|
||||
testPool.Exec(ctx, `DELETE FROM agent_task_queue WHERE issue_id = $1`, created.ID)
|
||||
cleanupReq := newRequest("DELETE", "/api/issues/"+created.ID, nil)
|
||||
cleanupReq = withURLParam(cleanupReq, "id", created.ID)
|
||||
testHandler.DeleteIssue(httptest.NewRecorder(), cleanupReq)
|
||||
}
|
||||
|
||||
func TestDaemonRegisterMissingWorkspaceReturns404(t *testing.T) {
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest("POST", "/api/daemon/register", bytes.NewBufferString(`{
|
||||
|
||||
@@ -917,7 +917,7 @@ func (h *Handler) CreateIssue(w http.ResponseWriter, r *http.Request) {
|
||||
slog.Info("issue created", append(logger.RequestAttrs(r), "issue_id", uuidToString(issue.ID), "title", issue.Title, "status", issue.Status, "workspace_id", workspaceID)...)
|
||||
h.publish(protocol.EventIssueCreated, workspaceID, creatorType, actualCreatorID, map[string]any{"issue": resp})
|
||||
|
||||
// Only ready issues in todo are enqueued for agents.
|
||||
// Enqueue agent task when an agent-assigned issue is created.
|
||||
if issue.AssigneeType.Valid && issue.AssigneeID.Valid {
|
||||
if h.shouldEnqueueAgentTask(r.Context(), issue) {
|
||||
h.TaskService.EnqueueTaskForIssue(r.Context(), issue)
|
||||
@@ -1112,8 +1112,7 @@ func (h *Handler) UpdateIssue(w http.ResponseWriter, r *http.Request) {
|
||||
"creator_id": uuidToString(prevIssue.CreatorID),
|
||||
})
|
||||
|
||||
// Reconcile task queue when assignee changes (not on status changes —
|
||||
// agents manage issue status themselves via the CLI).
|
||||
// Reconcile task queue when assignee changes.
|
||||
if assigneeChanged {
|
||||
h.TaskService.CancelTasksForIssue(r.Context(), issue.ID)
|
||||
|
||||
@@ -1122,6 +1121,16 @@ func (h *Handler) UpdateIssue(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
// Trigger the assigned agent when a member moves an issue out of backlog.
|
||||
// Backlog acts as a parking lot — moving to an active status signals the
|
||||
// issue is ready for work.
|
||||
if statusChanged && !assigneeChanged && actorType == "member" &&
|
||||
prevIssue.Status == "backlog" && issue.Status != "done" && issue.Status != "cancelled" {
|
||||
if h.isAgentAssigneeReady(r.Context(), issue) {
|
||||
h.TaskService.EnqueueTaskForIssue(r.Context(), issue)
|
||||
}
|
||||
}
|
||||
|
||||
// Cancel active tasks when the issue is cancelled by a user.
|
||||
// This is distinct from agent-managed status transitions — cancellation
|
||||
// is a user-initiated terminal action that should stop execution.
|
||||
@@ -1163,12 +1172,15 @@ func (h *Handler) canAssignAgent(ctx context.Context, r *http.Request, agentID,
|
||||
return false, "cannot assign to private agent"
|
||||
}
|
||||
|
||||
// shouldEnqueueAgentTask returns true when an issue assignment should trigger
|
||||
// the assigned agent. No status gate — assignment is an explicit human action,
|
||||
// so it should trigger regardless of issue status (e.g. assigning an agent to
|
||||
// a done issue to fix a discovered problem).
|
||||
// All trigger types (on_assign, on_comment, on_mention) are always enabled.
|
||||
// shouldEnqueueAgentTask returns true when an issue creation or assignment
|
||||
// should trigger the assigned agent. Backlog issues are skipped — backlog
|
||||
// acts as a parking lot where issues can be pre-assigned without immediately
|
||||
// triggering execution. Moving out of backlog is handled separately in
|
||||
// UpdateIssue.
|
||||
func (h *Handler) shouldEnqueueAgentTask(ctx context.Context, issue db.Issue) bool {
|
||||
if issue.Status == "backlog" {
|
||||
return false
|
||||
}
|
||||
return h.isAgentAssigneeReady(ctx, issue)
|
||||
}
|
||||
|
||||
@@ -1420,6 +1432,14 @@ func (h *Handler) BatchUpdateIssues(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
// Trigger agent when moving out of backlog (batch).
|
||||
if statusChanged && !assigneeChanged && actorType == "member" &&
|
||||
prevIssue.Status == "backlog" && issue.Status != "done" && issue.Status != "cancelled" {
|
||||
if h.isAgentAssigneeReady(r.Context(), issue) {
|
||||
h.TaskService.EnqueueTaskForIssue(r.Context(), issue)
|
||||
}
|
||||
}
|
||||
|
||||
// Cancel active tasks when the issue is cancelled by a user.
|
||||
if statusChanged && issue.Status == "cancelled" {
|
||||
h.TaskService.CancelTasksForIssue(r.Context(), issue.ID)
|
||||
|
||||
Reference in New Issue
Block a user