mirror of
https://github.com/multica-ai/multica.git
synced 2026-06-26 08:59:31 +02:00
Compare commits
4 Commits
feature/co
...
fix/issue-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f1e2e2e9f2 | ||
|
|
85d3a3a708 | ||
|
|
b2e5424ca1 | ||
|
|
d6855541fa |
@@ -477,6 +477,7 @@ const AgentTaskResponseSchema = z.object({
|
||||
attempt: z.number().optional(),
|
||||
trigger_comment_id: z.string().optional(),
|
||||
trigger_summary: z.string().optional(),
|
||||
handoff_note: z.string().optional(),
|
||||
kind: z.string().optional(),
|
||||
work_dir: z.string().optional(),
|
||||
relative_work_dir: z.string().optional(),
|
||||
|
||||
@@ -517,11 +517,15 @@ export function useRealtimeSync(
|
||||
// open composer's chips (e.g. an agent finishing its run becomes
|
||||
// triggerable again mid-typing).
|
||||
qc.invalidateQueries({ queryKey: issueKeys.commentTriggerPreviewAll() });
|
||||
// Issue-trigger previews (assign/status/create/batch) are queue-
|
||||
// dependent the same way — the status source's pending dedup means a
|
||||
// task finishing changes "will this start a run", so refresh any open
|
||||
// picker/modal preview (MUL-3375).
|
||||
qc.invalidateQueries({ queryKey: issueKeys.issueTriggerPreviewAll() });
|
||||
// Issue-trigger previews (assign/status/create/batch) are deliberately
|
||||
// NOT invalidated here. Unlike comment triggers, the assign source
|
||||
// (create / assignee change) cancels existing tasks before enqueuing, so
|
||||
// a task event can never change its verdict; only the status source's
|
||||
// pending dedup could, and that preview is advisory — the write path
|
||||
// re-evaluates authoritatively, so a rare stale label is harmless.
|
||||
// Refetching every mounted preview on every workspace task event caused
|
||||
// visible flicker, so the preview now refetches only on input change
|
||||
// (signature), mirroring its query design (MUL-3375).
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -196,6 +196,13 @@ export interface AgentTask {
|
||||
* or deleted.
|
||||
*/
|
||||
trigger_summary?: string;
|
||||
/**
|
||||
* Handoff instruction the assigner attached when starting this run (MUL-3375).
|
||||
* Present only on assignment-triggered runs that carried a note; the execution
|
||||
* log shows it inline as the trigger reason. Absent (legacy / no note) falls
|
||||
* back to the generic "initial run" label.
|
||||
*/
|
||||
handoff_note?: string;
|
||||
/**
|
||||
* Server-computed source discriminator used by the activity row to label
|
||||
* tasks that have no linked issue (so e.g. quick-create tasks render
|
||||
|
||||
@@ -150,6 +150,24 @@ describe("useIssueActions", () => {
|
||||
expect(mockUpdateMutate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("assigning an agent to a backlog issue applies directly — backlog never starts a run", () => {
|
||||
const backlogIssue = { ...mockIssue, status: "backlog" } as Issue;
|
||||
const { result } = renderHook(() => useIssueActions(backlogIssue), { wrapper });
|
||||
|
||||
act(() => {
|
||||
result.current.updateField({
|
||||
assignee_type: "agent",
|
||||
assignee_id: "agent-1",
|
||||
});
|
||||
});
|
||||
|
||||
expect(mockUpdateMutate).toHaveBeenCalledWith(
|
||||
{ id: "issue-1", assignee_type: "agent", assignee_id: "agent-1" },
|
||||
expect.any(Object),
|
||||
);
|
||||
expect(mockOpenModal).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("assigning a member applies directly without the run-confirm modal", () => {
|
||||
const { result } = renderHook(() => useIssueActions(mockIssue), { wrapper });
|
||||
|
||||
|
||||
@@ -57,6 +57,7 @@ export function useIssueActions(issue: Issue | null): UseIssueActionsResult {
|
||||
const issueId = issue?.id ?? null;
|
||||
const issueIdentifier = issue?.identifier ?? null;
|
||||
const issueProjectId = issue?.project_id ?? null;
|
||||
const issueStatus = issue?.status ?? null;
|
||||
|
||||
const updateField = useCallback(
|
||||
(updates: Partial<UpdateIssueRequest>) => {
|
||||
@@ -66,9 +67,15 @@ export function useIssueActions(issue: Issue | null): UseIssueActionsResult {
|
||||
// which applies the change itself — the four entry points share this one
|
||||
// backend-driven flow instead of guessing (MUL-3375). Every other field
|
||||
// change (status, priority, member assign, unassign) applies directly.
|
||||
//
|
||||
// Backlog is the parking lot: assigning a backlog issue never starts a run
|
||||
// (server/internal/service/issue_trigger.go), so the modal would only show
|
||||
// an empty "won't start" box with a single Apply button. Apply directly,
|
||||
// matching the batch backlog short-circuit in BatchActionToolbar.
|
||||
if (
|
||||
(updates.assignee_type === "agent" || updates.assignee_type === "squad") &&
|
||||
updates.assignee_id
|
||||
updates.assignee_id &&
|
||||
issueStatus !== "backlog"
|
||||
) {
|
||||
openModal("issue-run-confirm", {
|
||||
issueIds: [issueId],
|
||||
@@ -90,7 +97,7 @@ export function useIssueActions(issue: Issue | null): UseIssueActionsResult {
|
||||
},
|
||||
);
|
||||
},
|
||||
[issueId, updateIssue, openModal, t],
|
||||
[issueId, issueStatus, updateIssue, openModal, t],
|
||||
);
|
||||
|
||||
const togglePin = useCallback(() => {
|
||||
|
||||
@@ -100,13 +100,23 @@ export function BatchActionToolbar({
|
||||
|
||||
const handleBatchAssignee = (updates: Partial<UpdateIssueRequest>) => {
|
||||
if ((updates.assignee_type === "agent" || updates.assignee_type === "squad") && updates.assignee_id) {
|
||||
openModal("issue-run-confirm", {
|
||||
issueIds: ids,
|
||||
mode: "assign",
|
||||
assigneeType: updates.assignee_type,
|
||||
assigneeId: updates.assignee_id,
|
||||
});
|
||||
return;
|
||||
// Backlog never starts a run on assign (parking lot), so if every selected
|
||||
// issue is in backlog the confirm modal would only render an empty "won't
|
||||
// start" box — apply directly, matching handleBatchStatus's backlog short-
|
||||
// circuit. A mixed selection still routes through the modal: the non-backlog
|
||||
// issues will trigger and need confirmation. An empty intersection (selected
|
||||
// ids not in `issues`) falls through to the modal — safer than skipping.
|
||||
const selected = issues.filter((i) => selectedIds.has(i.id));
|
||||
const allBacklog = selected.length > 0 && selected.every((i) => i.status === "backlog");
|
||||
if (!allBacklog) {
|
||||
openModal("issue-run-confirm", {
|
||||
issueIds: ids,
|
||||
mode: "assign",
|
||||
assigneeType: updates.assignee_type,
|
||||
assigneeId: updates.assignee_id,
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
void handleBatchUpdate(updates);
|
||||
};
|
||||
|
||||
@@ -224,6 +224,12 @@ function useTriggerText(task: AgentTask): string {
|
||||
}
|
||||
if (task.autopilot_run_id) return t(($) => $.execution_log.trigger_autopilot);
|
||||
if (task.trigger_comment_id) return t(($) => $.execution_log.trigger_comment);
|
||||
// Assignment-triggered run that carried a handoff note: show the note inline
|
||||
// (truncated by TriggerText) the way comment triggers show their text, so the
|
||||
// row reads as the handoff instead of the generic "initial run".
|
||||
if (task.handoff_note) {
|
||||
return retryPrefix + t(($) => $.execution_log.trigger_handoff_prefix) + stripMentionMarkdown(task.handoff_note);
|
||||
}
|
||||
return t(($) => $.execution_log.trigger_initial);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { keepPreviousData, useQuery } from "@tanstack/react-query";
|
||||
import { api } from "@multica/core/api";
|
||||
import { issueKeys } from "@multica/core/issues/queries";
|
||||
import type { IssueAssigneeType, IssueStatus, IssueTriggerPreviewItem } from "@multica/core/types";
|
||||
@@ -41,8 +41,19 @@ function previewSignature(params: UseIssueTriggerPreviewParams): string {
|
||||
|
||||
/** Reads the unified backend predicate via POST /api/issues/preview-trigger so
|
||||
* the four entry points never re-implement "will this start a run" (MUL-3375).
|
||||
* The answer is queue-dependent (status-source pending dedup), so it is never
|
||||
* cached stale: staleTime 0, and WS task events invalidate issueTriggerPreviewAll. */
|
||||
*
|
||||
* The verdict changes only with the inputs (assignee / status), so the query
|
||||
* refetches solely on signature change — it is deliberately NOT invalidated by
|
||||
* WS task events. The assign source (create / assignee change) cancels existing
|
||||
* tasks before enqueuing, so its verdict can't shift from a task event at all;
|
||||
* the status source's pending dedup could, but the preview is advisory and the
|
||||
* write path re-evaluates authoritatively, so a rare stale status label is
|
||||
* harmless — far better than refetching every mounted preview on every
|
||||
* workspace task event (the source of the visible flicker, MUL-3375).
|
||||
*
|
||||
* Mirrors the comment-trigger preview's data handling: keepPreviousData so an
|
||||
* input switch swaps the answer in place instead of collapsing, and only the
|
||||
* very first load (no prior data) counts as loading. */
|
||||
export function useIssueTriggerPreview(
|
||||
params: UseIssueTriggerPreviewParams,
|
||||
): UseIssueTriggerPreviewResult {
|
||||
@@ -67,13 +78,18 @@ export function useIssueTriggerPreview(
|
||||
enabled,
|
||||
retry: false,
|
||||
staleTime: 0,
|
||||
// Keep the prior verdict visible while a new signature (assignee/status
|
||||
// switch) refetches, so the hint swaps in place rather than collapsing.
|
||||
placeholderData: keepPreviousData,
|
||||
});
|
||||
|
||||
const triggers = previewQuery.data?.triggers ?? EMPTY;
|
||||
return {
|
||||
triggers,
|
||||
totalCount: previewQuery.data?.total_count ?? 0,
|
||||
isLoading: enabled && previewQuery.isFetching,
|
||||
// Only the first load (no prior data) is "loading"; a background/placeholder
|
||||
// refetch is not, so reveal animations gated on this never collapse mid-fetch.
|
||||
isLoading: enabled && previewQuery.isLoading,
|
||||
handoffSupported: triggers.length > 0 && triggers.every((t) => t.handoff_supported === true),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -371,6 +371,7 @@
|
||||
"trigger_autopilot": "Autopilot run",
|
||||
"trigger_comment": "Comment trigger",
|
||||
"trigger_initial": "Initial run",
|
||||
"trigger_handoff_prefix": "Handoff: ",
|
||||
"status_queued": "Queued",
|
||||
"status_dispatched": "Starting",
|
||||
"status_waiting_local_directory": "Waiting for local directory",
|
||||
|
||||
@@ -53,6 +53,7 @@
|
||||
"run_confirm": {
|
||||
"title_assign": "Assign and start?",
|
||||
"title_status": "Start working now?",
|
||||
"checking": "Checking…",
|
||||
"will_start_named": "Once assigned, {{name}} will start working on this issue right away.",
|
||||
"will_start_named_squad": "Once assigned, {{name}}'s leader will review it and start assigning the work.",
|
||||
"will_start": "This will start work on {{count}} issues right away.",
|
||||
|
||||
@@ -356,6 +356,7 @@
|
||||
"trigger_autopilot": "オートパイロット実行",
|
||||
"trigger_comment": "コメントトリガー",
|
||||
"trigger_initial": "初回実行",
|
||||
"trigger_handoff_prefix": "引き継ぎ:",
|
||||
"status_queued": "待機中",
|
||||
"status_dispatched": "開始中",
|
||||
"status_waiting_local_directory": "ローカルディレクトリ待機中",
|
||||
|
||||
@@ -53,6 +53,7 @@
|
||||
"run_confirm": {
|
||||
"title_assign": "割り当てて開始しますか?",
|
||||
"title_status": "今すぐ作業を開始しますか?",
|
||||
"checking": "確認中…",
|
||||
"will_start_named": "割り当てると、{{name}} がすぐにこのイシューの作業を開始します。",
|
||||
"will_start_named_squad": "割り当てると、{{name}} のリーダーが内容を評価し、タスクの割り振りを開始します。",
|
||||
"will_start": "{{count}} 件のイシューの作業をすぐに開始します。",
|
||||
|
||||
@@ -370,6 +370,7 @@
|
||||
"trigger_autopilot": "오토파일럿 실행",
|
||||
"trigger_comment": "댓글 트리거",
|
||||
"trigger_initial": "초기 실행",
|
||||
"trigger_handoff_prefix": "인계: ",
|
||||
"status_queued": "대기 중",
|
||||
"status_dispatched": "시작 중",
|
||||
"status_waiting_local_directory": "로컬 디렉터리 대기 중",
|
||||
|
||||
@@ -53,6 +53,7 @@
|
||||
"run_confirm": {
|
||||
"title_assign": "할당하고 시작할까요?",
|
||||
"title_status": "지금 작업을 시작할까요?",
|
||||
"checking": "확인 중…",
|
||||
"will_start_named": "할당하면 {{name}}이(가) 이 이슈 작업을 바로 시작합니다.",
|
||||
"will_start_named_squad": "할당하면 {{name}}의 리더가 내용을 평가한 뒤 작업 배분을 시작합니다.",
|
||||
"will_start": "{{count}}개 이슈의 작업을 바로 시작합니다.",
|
||||
|
||||
@@ -361,6 +361,7 @@
|
||||
"trigger_autopilot": "自动化运行",
|
||||
"trigger_comment": "评论触发",
|
||||
"trigger_initial": "首次运行",
|
||||
"trigger_handoff_prefix": "交接:",
|
||||
"status_queued": "排队中",
|
||||
"status_dispatched": "启动中",
|
||||
"status_waiting_local_directory": "等待本地目录释放",
|
||||
|
||||
@@ -53,6 +53,7 @@
|
||||
"run_confirm": {
|
||||
"title_assign": "指派并开始处理?",
|
||||
"title_status": "现在开始处理?",
|
||||
"checking": "检查中…",
|
||||
"will_start_named": "指派后,{{name}} 会立即开始工作。",
|
||||
"will_start_named_squad": "指派后,{{name}} 的队长会评估并开始安排任务。",
|
||||
"will_start": "将立即开始处理 {{count}} 个 issue。",
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
} from "@multica/ui/components/ui/dialog";
|
||||
import { Button } from "@multica/ui/components/ui/button";
|
||||
import { Textarea } from "@multica/ui/components/ui/textarea";
|
||||
import { Spinner } from "@multica/ui/components/ui/spinner";
|
||||
import type { IssueAssigneeType, IssueStatus, UpdateIssueRequest } from "@multica/core/types";
|
||||
import { useUpdateIssue, useBatchUpdateIssues } from "@multica/core/issues/mutations";
|
||||
import { useActorName } from "@multica/core/workspace/hooks";
|
||||
@@ -69,7 +70,11 @@ export function RunConfirmModal({
|
||||
const mode = d.mode ?? "assign";
|
||||
|
||||
const [note, setNote] = useState("");
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
// Which footer action is in flight, so only the clicked button shows a
|
||||
// spinner (the request runs an agent on the server for note assigns, so it is
|
||||
// not instant — the disabled-only state read as frozen).
|
||||
const [pendingAction, setPendingAction] = useState<"go" | "suppress" | null>(null);
|
||||
const submitting = pendingAction !== null;
|
||||
|
||||
const updateIssue = useUpdateIssue();
|
||||
const batchUpdate = useBatchUpdateIssues();
|
||||
@@ -82,6 +87,7 @@ export function RunConfirmModal({
|
||||
enabled: issueIds.length > 0,
|
||||
});
|
||||
|
||||
const loading = preview.isLoading;
|
||||
const willStart = preview.totalCount > 0;
|
||||
const canNote = mode === "assign" && willStart;
|
||||
// Soft gate: an old runtime can't render the note. Disable the box but let
|
||||
@@ -98,7 +104,7 @@ export function RunConfirmModal({
|
||||
|
||||
const submit = async (suppressRun: boolean) => {
|
||||
if (issueIds.length === 0 || submitting) return;
|
||||
setSubmitting(true);
|
||||
setPendingAction(suppressRun ? "suppress" : "go");
|
||||
const payload = applyTo({
|
||||
...(suppressRun ? { suppress_run: true } : {}),
|
||||
...(!suppressRun && canNote && !noteDisabled && note.trim()
|
||||
@@ -114,7 +120,7 @@ export function RunConfirmModal({
|
||||
onClose();
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error && err.message ? err.message : t(($) => $.run_confirm.toast_failed));
|
||||
setSubmitting(false);
|
||||
setPendingAction(null);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -152,10 +158,24 @@ export function RunConfirmModal({
|
||||
<DialogTitle>
|
||||
{mode === "assign" ? t(($) => $.run_confirm.title_assign) : t(($) => $.run_confirm.title_status)}
|
||||
</DialogTitle>
|
||||
<DialogDescription>{headline}</DialogDescription>
|
||||
<DialogDescription>
|
||||
{loading ? (
|
||||
<span className="flex items-center gap-1.5 text-muted-foreground">
|
||||
<Spinner className="size-3.5" />
|
||||
{t(($) => $.run_confirm.checking)}
|
||||
</span>
|
||||
) : (
|
||||
headline
|
||||
)}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{canNote ? (
|
||||
{/* Assign mode keeps the note box mounted while the preview is in flight
|
||||
(disabled), so the dialog opens at its resolved height instead of
|
||||
growing when the predicate lands. Parked (no run) is the only case
|
||||
without a note, and it can't be a Backlog assign (those skip this
|
||||
modal), so it is rare. */}
|
||||
{mode === "assign" && (loading || canNote) ? (
|
||||
<div className="grid gap-1.5">
|
||||
<label className="text-sm font-medium" htmlFor="handoff-note">
|
||||
{t(($) => $.run_confirm.note_label)}
|
||||
@@ -164,30 +184,34 @@ export function RunConfirmModal({
|
||||
id="handoff-note"
|
||||
value={note}
|
||||
maxLength={MAX_HANDOFF_NOTE}
|
||||
disabled={noteDisabled || submitting}
|
||||
disabled={loading || noteDisabled || submitting}
|
||||
placeholder={t(($) => $.run_confirm.note_placeholder)}
|
||||
onChange={(e) => setNote(e.target.value)}
|
||||
rows={3}
|
||||
/>
|
||||
{noteDisabled ? (
|
||||
{!loading && noteDisabled ? (
|
||||
<p className="text-xs text-muted-foreground">{t(($) => $.run_confirm.note_unsupported)}</p>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<DialogFooter>
|
||||
{willStart ? (
|
||||
{loading ? (
|
||||
<Button type="button" disabled>
|
||||
<Spinner className="size-4" />
|
||||
</Button>
|
||||
) : willStart ? (
|
||||
<>
|
||||
<Button type="button" variant="outline" disabled={submitting} onClick={() => submit(true)}>
|
||||
{t(($) => $.run_confirm.dont_start)}
|
||||
{pendingAction === "suppress" ? <Spinner className="size-4" /> : t(($) => $.run_confirm.dont_start)}
|
||||
</Button>
|
||||
<Button type="button" disabled={submitting} onClick={() => submit(false)}>
|
||||
{t(($) => $.run_confirm.start)}
|
||||
{pendingAction === "go" ? <Spinner className="size-4" /> : t(($) => $.run_confirm.start)}
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<Button type="button" disabled={submitting} onClick={() => submit(false)}>
|
||||
{t(($) => $.run_confirm.apply)}
|
||||
{pendingAction === "go" ? <Spinner className="size-4" /> : t(($) => $.run_confirm.apply)}
|
||||
</Button>
|
||||
)}
|
||||
</DialogFooter>
|
||||
|
||||
@@ -379,6 +379,10 @@ func taskToResponse(t db.AgentTaskQueue, workspaceID string) AgentTaskResponse {
|
||||
if t.WorkDir.Valid {
|
||||
workDir = t.WorkDir.String
|
||||
}
|
||||
handoffNote := ""
|
||||
if t.HandoffNote.Valid {
|
||||
handoffNote = t.HandoffNote.String
|
||||
}
|
||||
return AgentTaskResponse{
|
||||
ID: uuidToString(t.ID),
|
||||
AgentID: uuidToString(t.AgentID),
|
||||
@@ -399,6 +403,7 @@ func taskToResponse(t db.AgentTaskQueue, workspaceID string) AgentTaskResponse {
|
||||
CreatedAt: timestampToString(t.CreatedAt),
|
||||
TriggerCommentID: uuidToPtr(t.TriggerCommentID),
|
||||
TriggerSummary: textToPtr(t.TriggerSummary),
|
||||
HandoffNote: handoffNote,
|
||||
WorkDir: workDir,
|
||||
RelativeWorkDir: relativeWorkDir(workDir, workspaceID, uuidToString(t.ID)),
|
||||
// Surface task source so the UI can distinguish issue-linked tasks
|
||||
|
||||
@@ -1588,12 +1588,9 @@ func (h *Handler) ClaimTaskByRuntime(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
// Handoff note (MUL-3375): a first-class instruction set when the issue was
|
||||
// assigned/promoted. Surfaced to the daemon so its prompt + issue_context.md
|
||||
// can render the assignment-handoff branch. Empty for all other task kinds.
|
||||
if task.HandoffNote.Valid {
|
||||
resp.HandoffNote = task.HandoffNote.String
|
||||
}
|
||||
// Handoff note (MUL-3375) is populated by taskToResponse (the shared mapper
|
||||
// resp came from above), so the daemon's prompt + issue_context.md render the
|
||||
// assignment-handoff branch. Empty for all other task kinds.
|
||||
|
||||
// Quick-create task: no issue / chat / autopilot link — workspace and
|
||||
// prompt come from the task's context JSONB. Resolve workspace from
|
||||
|
||||
Reference in New Issue
Block a user