diff --git a/packages/views/issues/components/backlog-agent-hint-dialog.tsx b/packages/views/issues/components/backlog-agent-hint-dialog.tsx new file mode 100644 index 000000000..13cb67fef --- /dev/null +++ b/packages/views/issues/components/backlog-agent-hint-dialog.tsx @@ -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 ( + + + onOpenChange(false)} + onDismissPermanently={onDismissPermanently} + onMoveToTodo={onMoveToTodo} + /> + + + ); +} + +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 ( + <> +
+
+
+ +
+
+

+ Agent is paused in Backlog +

+

+ This issue is parked, so the assigned agent will wait. Move it to + Todo when you want the agent to start. +

+
+
+ +
+
+ + Backlog + keeps the agent paused +
+
+ + Todo + starts the agent + +
+
+
+ +
+
+ +
+ + +
+
+
+ + ); +} diff --git a/packages/views/issues/components/issue-detail.tsx b/packages/views/issues/components/issue-detail.tsx index 01e49c81e..20a73b18a 100644 --- a/packages/views/issues/components/issue-detail.tsx +++ b/packages/views/issues/components/issue-detail.tsx @@ -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(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 + { + 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 */} ({ vi.mock("@multica/core/issues/mutations", () => ({ useCreateIssue: () => ({ mutateAsync: mockCreateIssue }), + useUpdateIssue: () => ({ mutate: vi.fn() }), })); vi.mock("@multica/core/hooks/use-file-upload", () => ({ diff --git a/packages/views/modals/create-issue.tsx b/packages/views/modals/create-issue.tsx index b558fe2af..a1bb687bb 100644 --- a/packages/views/modals/create-issue.tsx +++ b/packages/views/modals/create-issue.tsx @@ -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(null); // File upload — collect attachment IDs so we can link them after issue creation. const [attachmentIds, setAttachmentIds] = useState([]); @@ -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) => ( -
-
-
- + 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) => ( +
+
+
+ +
+ Issue created
- Issue created +
+ + {issue.identifier} – {issue.title} +
+
-
- - {issue.identifier} – {issue.title} -
- -
- ), { 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 ( - { if (!v) onClose(); }}> + { + if (!v) { + setBacklogHintIssueId(null); + onClose(); + } + }} + > - New Issue + {backlogHintIssueId ? ( + { + 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(); + }} + /> + ) : ( + <> + New Issue - {/* Header */} -
-
- {workspaceName} - - {typeof data?.parent_issue_identifier === "string" && ( - <> - {data.parent_issue_identifier} + {/* Header */} +
+
+ {workspaceName} - - )} - {data?.parent_issue_id ? "New sub-issue" : "New issue"} -
-
- - setIsExpanded(!isExpanded)} - className="rounded-sm p-1.5 opacity-70 hover:opacity-100 hover:bg-accent/60 transition-all cursor-pointer" - > - {isExpanded ? : } - - } + {typeof data?.parent_issue_identifier === "string" && ( + <> + {data.parent_issue_identifier} + + + )} + {data?.parent_issue_id ? "New sub-issue" : "New issue"} +
+
+ + setIsExpanded(!isExpanded)} + className="rounded-sm p-1.5 opacity-70 hover:opacity-100 hover:bg-accent/60 transition-all cursor-pointer" + > + {isExpanded ? : } + + } + /> + {isExpanded ? "Collapse" : "Expand"} + + + + + + } + /> + Close + +
+
+ + {/* Title */} +
+ updateTitle(v)} + onSubmit={handleSubmit} /> - {isExpanded ? "Collapse" : "Expand"} - - - - - - } +
+ + {/* Description — takes remaining space */} +
+ setDraft({ description: md })} + onUploadFile={handleUpload} + debounceMs={500} /> - Close - -
-
+ {descDragOver && } +
- {/* Title */} -
- updateTitle(v)} - onSubmit={handleSubmit} - /> -
+ {/* Property toolbar */} +
+ {/* Status */} + { if (u.status) updateStatus(u.status); }} + triggerRender={} + align="start" + /> - {/* Description — takes remaining space */} -
- setDraft({ description: md })} - onUploadFile={handleUpload} - debounceMs={500} - /> - {descDragOver && } -
+ {/* Priority */} + { if (u.priority) updatePriority(u.priority); }} + triggerRender={} + align="start" + /> - {/* Property toolbar */} -
- {/* Status */} - { if (u.status) updateStatus(u.status); }} - triggerRender={} - align="start" - /> + {/* Assignee */} + updateAssignee( + u.assignee_type ?? undefined, + u.assignee_id ?? undefined, + )} + triggerRender={} + align="start" + /> - {/* Priority */} - { if (u.priority) updatePriority(u.priority); }} - triggerRender={} - align="start" - /> + {/* Due date */} + updateDueDate(u.due_date ?? null)} + triggerRender={} + align="start" + /> - {/* Assignee */} - updateAssignee( - u.assignee_type ?? undefined, - u.assignee_id ?? undefined, - )} - triggerRender={} - align="start" - /> + {/* Project */} + setProjectId(u.project_id ?? undefined)} + triggerRender={} + align="start" + /> +
- {/* Due date */} - updateDueDate(u.due_date ?? null)} - triggerRender={} - align="start" - /> - - {/* Project */} - setProjectId(u.project_id ?? undefined)} - triggerRender={} - align="start" - /> -
- - {/* Footer */} -
- descEditorRef.current?.uploadFile(file)} - /> - -
+ {/* Footer */} +
+ descEditorRef.current?.uploadFile(file)} + /> + +
+ + )}
); diff --git a/server/internal/handler/handler_test.go b/server/internal/handler/handler_test.go index 7331a9f8d..c7e328f1b 100644 --- a/server/internal/handler/handler_test.go +++ b/server/internal/handler/handler_test.go @@ -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(`{ diff --git a/server/internal/handler/issue.go b/server/internal/handler/issue.go index 67344b218..247326ff5 100644 --- a/server/internal/handler/issue.go +++ b/server/internal/handler/issue.go @@ -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)