Compare commits

...

4 Commits

Author SHA1 Message Date
Naiyuan Qing
f1e2e2e9f2 feat(issues): show handoff note in execution-log trigger text
An assignment-triggered run that carried a handoff note showed the
generic "Initial run" label. Surface the note inline (truncated, like
comment triggers show their text) so the row reads as the handoff.

taskToResponse now populates handoff_note for all callers (dropping the
now-redundant explicit set in ClaimTaskByRuntime); the field is added to
the AgentTask type + zod schema (optional, additive — old clients ignore
it via the loose schema, new clients fall back to "Initial run").

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 16:04:09 +08:00
Naiyuan Qing
85d3a3a708 fix(modals): run-confirm loading state + submit spinner
The dialog grew in height after open: it rendered the short "won't
start" variant while POST /api/issues/preview-trigger was in flight, then
the note box appeared when the predicate landed. Keep the note box
mounted (disabled) during loading so assign mode opens at its resolved
height, and show a Spinner + 'checking' headline while loading.

Submit had no feedback — buttons only disabled, which read as frozen for
note assigns (the request starts an agent server-side). Track which
footer action is in flight and show a Spinner on the clicked button.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 16:04:09 +08:00
Naiyuan Qing
b2e5424ca1 fix(issues): skip run-confirm modal for backlog assign
Assigning a Backlog issue to an agent/squad never starts a run (the
parking lot — server/internal/service/issue_trigger.go), so the
pre-trigger confirm modal only rendered an empty "won't start" box with
a single Apply button. Apply directly instead: the single path checks
issue.status, the batch path skips only when every selected issue is
Backlog (mixed selections still confirm — the non-backlog ones trigger).
Mirrors the existing backlog short-circuit in handleBatchStatus.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 16:04:09 +08:00
Naiyuan Qing
d6855541fa fix(issues): stop issue-trigger preview flicker
The pre-trigger preview re-rendered/refetched on every workspace task
event: WS task lifecycle invalidated issueTriggerPreviewAll (staleTime 0),
forcing a background refetch whose isFetching was surfaced as isLoading,
collapsing and reopening CreateRunHint's reveal band.

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 label is harmless. Drop
the WS invalidation so the preview refetches only on input (signature)
change. Keep the comment-trigger invalidation — its verdict genuinely
changes mid-compose and its chips drive an immediate, unconfirmed send.

Align the hook's data handling with the comment-trigger preview:
keepPreviousData so an input switch swaps in place instead of collapsing,
and treat only the first load (no prior data) as loading.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 14:37:46 +08:00
19 changed files with 138 additions and 35 deletions

View File

@@ -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(),

View File

@@ -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).
},
};

View File

@@ -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

View File

@@ -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 });

View File

@@ -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(() => {

View File

@@ -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);
};

View File

@@ -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);
}

View File

@@ -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),
};
}

View File

@@ -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",

View File

@@ -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.",

View File

@@ -356,6 +356,7 @@
"trigger_autopilot": "オートパイロット実行",
"trigger_comment": "コメントトリガー",
"trigger_initial": "初回実行",
"trigger_handoff_prefix": "引き継ぎ:",
"status_queued": "待機中",
"status_dispatched": "開始中",
"status_waiting_local_directory": "ローカルディレクトリ待機中",

View File

@@ -53,6 +53,7 @@
"run_confirm": {
"title_assign": "割り当てて開始しますか?",
"title_status": "今すぐ作業を開始しますか?",
"checking": "確認中…",
"will_start_named": "割り当てると、{{name}} がすぐにこのイシューの作業を開始します。",
"will_start_named_squad": "割り当てると、{{name}} のリーダーが内容を評価し、タスクの割り振りを開始します。",
"will_start": "{{count}} 件のイシューの作業をすぐに開始します。",

View File

@@ -370,6 +370,7 @@
"trigger_autopilot": "오토파일럿 실행",
"trigger_comment": "댓글 트리거",
"trigger_initial": "초기 실행",
"trigger_handoff_prefix": "인계: ",
"status_queued": "대기 중",
"status_dispatched": "시작 중",
"status_waiting_local_directory": "로컬 디렉터리 대기 중",

View File

@@ -53,6 +53,7 @@
"run_confirm": {
"title_assign": "할당하고 시작할까요?",
"title_status": "지금 작업을 시작할까요?",
"checking": "확인 중…",
"will_start_named": "할당하면 {{name}}이(가) 이 이슈 작업을 바로 시작합니다.",
"will_start_named_squad": "할당하면 {{name}}의 리더가 내용을 평가한 뒤 작업 배분을 시작합니다.",
"will_start": "{{count}}개 이슈의 작업을 바로 시작합니다.",

View File

@@ -361,6 +361,7 @@
"trigger_autopilot": "自动化运行",
"trigger_comment": "评论触发",
"trigger_initial": "首次运行",
"trigger_handoff_prefix": "交接:",
"status_queued": "排队中",
"status_dispatched": "启动中",
"status_waiting_local_directory": "等待本地目录释放",

View File

@@ -53,6 +53,7 @@
"run_confirm": {
"title_assign": "指派并开始处理?",
"title_status": "现在开始处理?",
"checking": "检查中…",
"will_start_named": "指派后,{{name}} 会立即开始工作。",
"will_start_named_squad": "指派后,{{name}} 的队长会评估并开始安排任务。",
"will_start": "将立即开始处理 {{count}} 个 issue。",

View File

@@ -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>

View File

@@ -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

View File

@@ -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