From d930bcaa186ae6d54998da12d3d2341eda8b9206 Mon Sep 17 00:00:00 2001 From: Jiayuan Zhang Date: Wed, 15 Apr 2026 19:07:48 +0800 Subject: [PATCH] feat(server): trigger agent when issue moves out of backlog (#1006) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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. --- .../components/backlog-agent-hint-dialog.tsx | 124 +++++++ .../views/issues/components/issue-detail.tsx | 27 ++ packages/views/modals/create-issue.test.tsx | 1 + packages/views/modals/create-issue.tsx | 336 ++++++++++-------- server/internal/handler/handler_test.go | 109 ++++++ server/internal/handler/issue.go | 36 +- 6 files changed, 481 insertions(+), 152 deletions(-) create mode 100644 packages/views/issues/components/backlog-agent-hint-dialog.tsx 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)