feat(issues): 确认指派弹窗去掉打开时的 run 预览等待 (MUL-5010) (#5678)

* feat(issues): confirm assignment without a pre-flight run preview (MUL-5010)

RunConfirmModal called POST /api/issues/preview-trigger on open and blocked
the entire dialog behind a "检查中…" spinner until it landed. That query is
keyed per issue id with staleTime 0, so every new issue was a guaranteed
cache miss — the wait was structural, not incidental. The dialog also
promised "会立即开始工作" while still checking whether anything would start.

Redefine it as "dialog = confirm the assignment", not "confirm N runs":

- Open fires no request. Note box and both buttons are usable on frame one.
- Title/primary action become 确认指派; copy states the assignment as certain
  and the run as conditional, with no predicted count.
- The write reports what actually happened: UpdateIssue returns runs_started
  (0/1) and batch-update returns the batch total, surfaced as one short
  toast — "已指派给 X,已启动 N 个 run", or just "已指派给 X".

runs_started counts successful enqueues rather than predicate hits, since an
insert can still no-op on the pending unique index. It is pointer+omitempty
like Labels, so only the PUT reply carries it, and it is stripped in
useUpdateIssue's onSuccess so a write-scoped fact never lands in the cache.

The handoff-note version gate stays local and synchronous, now covering
squads too by resolving the leader's runtime from the warm squad list —
removing the last reason the dialog needed the server round-trip.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>

* test(issues): assert runs_started on the write responses (MUL-5010)

The assign-confirm dialog now reports the enqueue outcome from the write
reply instead of predicting it, so the number itself is the contract. Pin
it directly on the responses:

- single assign that enqueues → runs_started 1
- single assign that starts nothing → 0, via both routes (suppress_run,
  and assigning into backlog), so a client can tell "no run" apart from
  "old backend omitting the field"
- batch → the real aggregate, asserted against a mixed selection where
  updated=3 but runs_started=2; the divergence from `updated` is the point
- absent on GET, guarding the pointer+omitempty contract that keeps a
  write-scoped fact off every read path

Each count is cross-checked against agent_task_queue so the assertions
track real enqueues rather than the predicate.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>

* refactor(issues): drop runs_started + result toast, keep silent completion (MUL-5010)

Final scope: the dialog only removes the preview wait; it does not add a
post-submit result toast. Per the confirmed decision, revert everything that
existed solely to report "N runs started" and keep completion silent, exactly
as it was before this PR — the assignee and any run already surface through
the issue's normal updates.

Removed:
- the success toast on submit and its two i18n keys (all four locales)
- the `runs_started` response field on IssueResponse, the single/batch write
  counting, and dispatchIssueRun's bool return (back to void)
- the `runs_started` type/schema/api-client additions and the onSuccess strip
- server/internal/handler/issue_runs_started_test.go (only served the field)

Kept: preview-trigger removal and the instant-usable dialog — note box and
buttons live on the first frame, agent/squad handoff-note version gate resolved
locally, and "确认指派" copy. Net diff vs base is now frontend-only; the backend
and packages/core files are byte-identical to base again.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>

* docs(issues): fix stale headline comment referencing removed result toast (MUL-5010)

The result toast was removed with runs_started, but the headline comment
still said "the real one arrives in the result toast". Reword to state only
what remains true: the copy names no run count because the run is conditional.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
This commit is contained in:
Naiyuan Qing
2026-07-21 15:48:47 +08:00
committed by GitHub
parent 6045a5dc80
commit 645e8a2983
6 changed files with 213 additions and 262 deletions

View File

@@ -51,19 +51,14 @@
"toast_success": "Added {{identifier}} as sub-issue"
},
"run_confirm": {
"title_assign": "Assign and start?",
"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.",
"will_start_squad": "This hands {{count}} issues to the squad's leader to review and assign.",
"nothing_assign": "Assigned — won't start working while in Backlog.",
"title_assign": "Confirm assignment?",
"assign_single": "Assign this issue to {{name}}. They'll start right away if the run conditions are met.",
"assign_batch": "Assign {{count}} issues to {{name}}. They'll start right away if the run conditions are met.",
"note_label": "Handoff note (optional)",
"note_placeholder": "Tell them what matters — scope, focus, or what to avoid…",
"note_unsupported": "Their runtime is too old to receive this handoff note; it may not be read.",
"start": "Start",
"confirm_assign": "Confirm assignment",
"dont_start": "Don't start yet",
"apply": "Apply",
"toast_failed": "Action failed",
"create_will_start": "{{name}} will start working right after creation.",
"create_will_start_squad": "{{name}}'s leader will review it and start assigning work right after creation.",

View File

@@ -51,19 +51,14 @@
"toast_success": "{{identifier}} をサブイシューとして追加しました"
},
"run_confirm": {
"title_assign": "割り当てて開始しますか?",
"checking": "確認中…",
"will_start_named": "割り当てると、{{name}} がすぐにこのイシューの作業を開始します。",
"will_start_named_squad": "割り当てると、{{name}} のリーダーが内容を評価し、タスクの割り振りを開始します。",
"will_start": "{{count}} 件のイシューの作業をすぐに開始します。",
"will_start_squad": "{{count}} 件のイシューをスクワッドのリーダーに渡し、評価して割り振ります。",
"nothing_assign": "割り当て済み — バックログ中は作業を開始しません。",
"title_assign": "割り当てを確認しますか?",
"assign_single": "このイシューを {{name}} に割り当てます。実行条件を満たす場合はすぐに作業を開始します。",
"assign_batch": "{{count}} 件のイシューを {{name}} に割り当てます。実行条件を満たす場合はすぐに作業を開始します。",
"note_label": "引き継ぎメモ(任意)",
"note_placeholder": "範囲・重点・触れてほしくない点などを一言で…",
"note_unsupported": "相手の実行環境が古いため、この引き継ぎメモは読み取れない可能性があります。",
"start": "開始",
"confirm_assign": "割り当てを確認",
"dont_start": "まだ開始しない",
"apply": "適用",
"toast_failed": "操作に失敗しました",
"create_will_start": "作成後、{{name}} がすぐに作業を開始します。",
"create_will_start_squad": "作成後、{{name}} のリーダーが内容を評価し、タスクの割り振りを開始します。",

View File

@@ -51,19 +51,14 @@
"toast_success": "{{identifier}}을(를) 하위 이슈로 추가했습니다"
},
"run_confirm": {
"title_assign": "할당하고 시작할까요?",
"checking": "확인 중…",
"will_start_named": "할당하면 {{name}}이(가) 이 이슈 작업을 바로 시작합니다.",
"will_start_named_squad": "할당하면 {{name}}의 리더가 내용을 평가한 뒤 작업 배분을 시작합니다.",
"will_start": "{{count}}개 이슈의 작업을 바로 시작합니다.",
"will_start_squad": "{{count}}개 이슈를 스쿼드 리더에게 넘겨 평가하고 배분합니다.",
"nothing_assign": "할당됨 — 백로그에서는 작업을 시작하지 않습니다.",
"title_assign": "할당을 확인할까요?",
"assign_single": "이 이슈를 {{name}}에게 할당합니다. 실행 조건을 충족하면 바로 시작합니다.",
"assign_batch": "{{count}}개 이슈를 {{name}}에게 할당합니다. 실행 조건을 충족하면 바로 시작합니다.",
"note_label": "인계 메모 (선택)",
"note_placeholder": "범위·핵심·건드리지 말 것 등을 한마디로…",
"note_unsupported": "상대 런타임이 오래되어 이 인계 메모를 읽지 못할 수 있습니다.",
"start": "시작",
"confirm_assign": "할당 확인",
"dont_start": "아직 시작 안 함",
"apply": "적용",
"toast_failed": "작업에 실패했습니다",
"create_will_start": "생성 후 {{name}}이(가) 바로 작업을 시작합니다.",
"create_will_start_squad": "생성 후 {{name}}의 리더가 내용을 평가한 뒤 작업 배분을 시작합니다.",

View File

@@ -51,19 +51,14 @@
"toast_success": "已添加 {{identifier}} 为子 issue"
},
"run_confirm": {
"title_assign": "指派并开始处理",
"checking": "检查中…",
"will_start_named": "指派后,{{name}} 会立即开始工作。",
"will_start_named_squad": "指派后,{{name}} 的队长会评估并开始安排任务。",
"will_start": "将立即开始处理 {{count}} 个 issue。",
"will_start_squad": "将把 {{count}} 个 issue 交给队长评估并安排。",
"nothing_assign": "已指派 —— 在 backlog 中不会开始处理。",
"title_assign": "确认指派?",
"assign_single": "将这个 issue 指派给 {{name}};符合运行条件时会立即开始处理。",
"assign_batch": "将 {{count}} 个 issue 指派给 {{name}};符合运行条件时会立即开始处理。",
"note_label": "交接说明(可选)",
"note_placeholder": "交代一句 —— 范围、重点、或别碰什么……",
"note_unsupported": "对方的运行环境版本较旧,这条交接说明可能读不到。",
"start": "开始",
"confirm_assign": "确认指派",
"dont_start": "暂不开始",
"apply": "确定",
"toast_failed": "操作失败",
"create_will_start": "创建后 {{name}} 会立即开始工作。",
"create_will_start_squad": "创建后 {{name}} 的队长会评估并开始安排任务。",

View File

@@ -2,39 +2,30 @@ import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
import { RunConfirmModal } from "./run-confirm";
// --- Controllable preview result ---------------------------------------------
const previewState = {
triggers: [{ issue_id: "issue-1", agent_id: "agent-1", source: "assign", handoff_supported: true }],
totalCount: 1,
isLoading: false,
handoffSupported: true,
};
vi.mock("../issues/hooks/use-issue-trigger-preview", () => ({
useIssueTriggerPreview: () => previewState,
}));
// --- Warm agent + runtime caches (prefetched in the real app) ----------------
// The modal resolves a concrete agent assignee → its runtime → cli_version
// locally, exactly like the quick-create version gate, so the note box never
// waits on the preview round-trip. Tests drive the local verdict by swapping
// the runtime's reported cli_version here.
// --- Warm agent / squad / runtime caches (prefetched in the real app) --------
// The modal resolves the target runtime's cli_version locally — an agent's own
// runtime, or a squad leader's — so nothing in the dialog waits on the network.
// Tests drive the verdict by swapping the runtime's reported cli_version here.
const cache = {
agents: [{ id: "agent-1", runtime_id: "runtime-1" }] as Array<{ id: string; runtime_id: string }>,
runtimes: [{ id: "runtime-1", metadata: { cli_version: "0.4.0" } }] as Array<{
id: string;
metadata: Record<string, unknown>;
}>,
squads: [{ id: "squad-1", leader_id: "agent-1" }] as Array<{ id: string; leader_id: string }>,
};
vi.mock("@tanstack/react-query", () => ({
useQuery: ({ queryKey }: { queryKey: string[] }) => {
if (queryKey[0] === "runtimes") return { data: cache.runtimes };
if (queryKey[0] === "workspaces" && queryKey[2] === "agents") return { data: cache.agents };
if (queryKey[0] === "workspaces" && queryKey[2] === "squads") return { data: cache.squads };
return { data: [] };
},
}));
vi.mock("@multica/core/hooks", () => ({ useWorkspaceId: () => "ws-test" }));
vi.mock("@multica/core/workspace/queries", () => ({
agentListOptions: (wsId: string) => ({ queryKey: ["workspaces", wsId, "agents"] }),
squadListOptions: (wsId: string) => ({ queryKey: ["workspaces", wsId, "squads"] }),
}));
// Stub the runtimes barrel: the query-options builder would otherwise drag the
// network layer in, and the deep cli-version module isn't an exported subpath.
@@ -53,8 +44,8 @@ vi.mock("@multica/core/runtimes", () => ({
},
}));
const mockUpdate = vi.fn().mockResolvedValue(undefined);
const mockBatch = vi.fn().mockResolvedValue(undefined);
const mockUpdate = vi.fn().mockResolvedValue({ id: "issue-1" });
const mockBatch = vi.fn().mockResolvedValue({ updated: 2 });
vi.mock("@multica/core/issues/mutations", () => ({
useUpdateIssue: () => ({ mutateAsync: mockUpdate }),
useBatchUpdateIssues: () => ({ mutateAsync: mockBatch }),
@@ -65,30 +56,30 @@ vi.mock("@multica/core/workspace/hooks", () => ({
}));
vi.mock("../i18n", () => ({
useT: () => ({ t: (sel: (x: Record<string, Record<string, string>>) => string) => {
// Resolve the accessor against a flat label map so assertions can target text.
const labels = {
run_confirm: {
title_assign: "Assign and start?",
will_start_named: "start Walt",
will_start_named_squad: "start squad Walt",
will_start: "start many",
will_start_squad: "start squad many",
nothing_assign: "no run (backlog)",
checking: "Checking…",
note_label: "Handoff note",
note_placeholder: "scope...",
note_unsupported: "runtime too old",
start: "Start",
dont_start: "Don't start yet",
apply: "Apply",
toast_failed: "failed",
create_will_start: "create start",
create_parked: "create parked",
},
};
return sel(labels);
} }),
useT: () => ({
t: (
sel: (x: Record<string, Record<string, string>>) => string,
vars?: Record<string, unknown>,
) => {
// Resolve the accessor against a flat label map so assertions can target
// text, then interpolate {{name}} / {{count}} the way i18next would — the
// headline substitutes the assignee name and the batch count.
const labels = {
run_confirm: {
title_assign: "Confirm assignment?",
assign_single: "assign to {{name}}",
assign_batch: "assign {{count}} to {{name}}",
note_label: "Handoff note",
note_placeholder: "scope...",
note_unsupported: "runtime too old",
confirm_assign: "Confirm assignment",
dont_start: "Don't start yet",
toast_failed: "failed",
},
};
return sel(labels).replace(/\{\{(\w+)\}\}/g, (_m, k) => String(vars?.[k] ?? ""));
},
}),
}));
// Keep the ui primitives as light DOM so the logic is what's under test.
@@ -111,29 +102,43 @@ vi.mock("@multica/ui/components/ui/textarea", () => ({
vi.mock("@multica/ui/components/ui/spinner", () => ({
Spinner: () => <span data-testid="spinner" />,
}));
vi.mock("sonner", () => ({ toast: { error: vi.fn() } }));
// vi.hoisted: vi.mock factories run before module-level consts initialize.
// Only error is used now — completion is silent (no result toast).
const mockToast = vi.hoisted(() => ({ error: vi.fn(), success: vi.fn() }));
vi.mock("sonner", () => ({ toast: mockToast }));
beforeEach(() => {
mockUpdate.mockClear();
mockBatch.mockClear();
previewState.triggers = [{ issue_id: "issue-1", agent_id: "agent-1", source: "assign", handoff_supported: true }];
previewState.totalCount = 1;
previewState.isLoading = false;
previewState.handoffSupported = true;
mockUpdate.mockClear().mockResolvedValue({ id: "issue-1" });
mockBatch.mockClear().mockResolvedValue({ updated: 2 });
mockToast.error.mockClear();
mockToast.success.mockClear();
cache.agents = [{ id: "agent-1", runtime_id: "runtime-1" }];
cache.runtimes = [{ id: "runtime-1", metadata: { cli_version: "0.4.0" } }];
cache.squads = [{ id: "squad-1", leader_id: "agent-1" }];
});
const single = {
issueIds: ["issue-1"],
mode: "assign" as const,
assigneeType: "agent" as const,
assigneeId: "agent-1",
};
describe("RunConfirmModal", () => {
it("single assign + Start sends the assignee change with the handoff note", async () => {
render(
<RunConfirmModal
onClose={vi.fn()}
data={{ issueIds: ["issue-1"], mode: "assign", assigneeType: "agent", assigneeId: "agent-1" }}
/>,
);
it("is fully operable on the first frame — no preview request, no spinner", () => {
// The MUL-5010 core: opening the dialog fires nothing and blocks nothing.
const { container } = render(<RunConfirmModal onClose={vi.fn()} data={single} />);
expect(screen.queryByTestId("spinner")).not.toBeInTheDocument();
expect(screen.getByPlaceholderText("scope...")).not.toBeDisabled();
expect(screen.getByText("Confirm assignment")).not.toBeDisabled();
// Headline reads across elements — the assignee name is bolded in place.
expect(container.textContent).toContain("assign to Walt");
});
it("single assign sends the assignee change with the handoff note", async () => {
render(<RunConfirmModal onClose={vi.fn()} data={single} />);
fireEvent.change(screen.getByPlaceholderText("scope..."), { target: { value: "only login" } });
fireEvent.click(screen.getByText("Start"));
fireEvent.click(screen.getByText("Confirm assignment"));
await waitFor(() => expect(mockUpdate).toHaveBeenCalledTimes(1));
expect(mockUpdate).toHaveBeenCalledWith({
id: "issue-1",
@@ -144,87 +149,80 @@ describe("RunConfirmModal", () => {
expect(mockBatch).not.toHaveBeenCalled();
});
it("completes silently on success — closes with no result toast", async () => {
// Final scope: the dialog only confirms the assignment. The assignee and any
// run surface through the issue's normal updates, so submit adds no toast.
const onClose = vi.fn();
render(<RunConfirmModal onClose={onClose} data={single} />);
fireEvent.click(screen.getByText("Confirm assignment"));
await waitFor(() => expect(onClose).toHaveBeenCalled());
expect(mockToast.success).not.toHaveBeenCalled();
expect(mockToast.error).not.toHaveBeenCalled();
});
it("'暂不开始' sends suppress_run and no handoff note", async () => {
render(
<RunConfirmModal
onClose={vi.fn()}
data={{ issueIds: ["issue-1"], mode: "assign", assigneeType: "agent", assigneeId: "agent-1" }}
/>,
);
render(<RunConfirmModal onClose={vi.fn()} data={single} />);
fireEvent.change(screen.getByPlaceholderText("scope..."), { target: { value: "ignored" } });
fireEvent.click(screen.getByText("Don't start yet"));
await waitFor(() => expect(mockUpdate).toHaveBeenCalledTimes(1));
const payload = mockUpdate.mock.calls[0]![0];
expect(payload.suppress_run).toBe(true);
expect(payload.handoff_note).toBeUndefined();
expect(mockToast.success).not.toHaveBeenCalled();
});
it("disables the note box from the local runtime version, before the preview resolves", () => {
// Old daemon that can't render handoff notes, and the predicate is still in
// flight. The box must already be disabled + warned from the warm runtime
// cache — no "checking…" wait, no reliance on the server verdict.
previewState.isLoading = true;
previewState.totalCount = 0;
it("disables the note box when the agent's runtime is too old", () => {
cache.runtimes = [{ id: "runtime-1", metadata: { cli_version: "0.2.21" } }];
render(<RunConfirmModal onClose={vi.fn()} data={single} />);
expect(screen.getByPlaceholderText("scope...")).toBeDisabled();
expect(screen.getByText("runtime too old")).toBeInTheDocument();
});
it("resolves a squad's verdict through its leader's runtime, locally", () => {
// A squad run is executed by its leader, so the leader's runtime decides.
// The squad list gives us leader_id, so this needs no server verdict.
cache.runtimes = [{ id: "runtime-1", metadata: { cli_version: "0.2.21" } }];
render(
<RunConfirmModal
onClose={vi.fn()}
data={{ issueIds: ["issue-1"], mode: "assign", assigneeType: "agent", assigneeId: "agent-1" }}
data={{ ...single, assigneeType: "squad", assigneeId: "squad-1" }}
/>,
);
expect(screen.getByPlaceholderText("scope...")).toBeDisabled();
expect(screen.getByText("runtime too old")).toBeInTheDocument();
});
it("keeps the note box usable while the preview is still loading for a supported agent", () => {
// The core of MUL-3706: a concrete agent on a current runtime should never
// see a "checking…" gate on the note box — the version is known locally.
previewState.isLoading = true;
previewState.totalCount = 0;
cache.runtimes = [{ id: "runtime-1", metadata: { cli_version: "0.4.0" } }];
render(
<RunConfirmModal
onClose={vi.fn()}
data={{ issueIds: ["issue-1"], mode: "assign", assigneeType: "agent", assigneeId: "agent-1" }}
/>,
);
it("leaves the note box enabled when the target runtime can't be resolved", () => {
// Unknown assignee → no verdict. The note is a soft gate, so an
// unresolvable target must not produce a spurious warning.
cache.agents = [];
render(<RunConfirmModal onClose={vi.fn()} data={single} />);
expect(screen.getByPlaceholderText("scope...")).not.toBeDisabled();
expect(screen.queryByText("runtime too old")).not.toBeInTheDocument();
});
it("squad assignee defers to the server handoff verdict (not locally resolvable)", () => {
// A squad routes to its leader agent, picked server-side — the target
// runtime isn't knowable client-side, so the box must follow the preview's
// handoff_supported, exactly as before.
previewState.handoffSupported = false;
render(
<RunConfirmModal
onClose={vi.fn()}
data={{ issueIds: ["issue-1"], mode: "assign", assigneeType: "squad", assigneeId: "squad-1" }}
/>,
it("batch assign (N ids) applies via batchUpdate", async () => {
const { container } = render(
<RunConfirmModal onClose={vi.fn()} data={{ ...single, issueIds: ["i1", "i2"] }} />,
);
expect(screen.getByPlaceholderText("scope...")).toBeDisabled();
expect(screen.getByText("runtime too old")).toBeInTheDocument();
});
it("batch assign (N ids) applies via batchUpdate with the assignee change", async () => {
previewState.triggers = [
{ issue_id: "i1", agent_id: "a1", source: "assign", handoff_supported: true },
{ issue_id: "i2", agent_id: "a2", source: "assign", handoff_supported: true },
];
previewState.totalCount = 2;
render(
<RunConfirmModal
onClose={vi.fn()}
data={{ issueIds: ["i1", "i2"], mode: "assign", assigneeType: "agent", assigneeId: "agent-1" }}
/>,
);
fireEvent.click(screen.getByText("Start"));
expect(container.textContent).toContain("assign 2 to Walt");
fireEvent.click(screen.getByText("Confirm assignment"));
await waitFor(() => expect(mockBatch).toHaveBeenCalledTimes(1));
expect(mockBatch).toHaveBeenCalledWith({
ids: ["i1", "i2"],
updates: { assignee_type: "agent", assignee_id: "agent-1" },
});
expect(mockUpdate).not.toHaveBeenCalled();
expect(mockToast.success).not.toHaveBeenCalled();
});
it("keeps the dialog open and surfaces the error when the write fails", async () => {
const onClose = vi.fn();
mockUpdate.mockRejectedValue(new Error("boom"));
render(<RunConfirmModal onClose={onClose} data={single} />);
fireEvent.click(screen.getByText("Confirm assignment"));
await waitFor(() => expect(mockToast.error).toHaveBeenCalledWith("boom"));
expect(onClose).not.toHaveBeenCalled();
expect(mockToast.success).not.toHaveBeenCalled();
});
});

View File

@@ -18,9 +18,8 @@ import type { IssueAssigneeType, UpdateIssueRequest } from "@multica/core/types"
import { useUpdateIssue, useBatchUpdateIssues } from "@multica/core/issues/mutations";
import { useActorName } from "@multica/core/workspace/hooks";
import { useWorkspaceId } from "@multica/core/hooks";
import { agentListOptions } from "@multica/core/workspace/queries";
import { agentListOptions, squadListOptions } from "@multica/core/workspace/queries";
import { runtimeListOptions, readRuntimeCliVersion, handoffSupported } from "@multica/core/runtimes";
import { useIssueTriggerPreview } from "../issues/hooks/use-issue-trigger-preview";
import { useT } from "../i18n";
const MAX_HANDOFF_NOTE = 2000;
@@ -55,12 +54,21 @@ interface RunConfirmData {
}
/**
* Pre-trigger confirmation for issue assignment that may start agent runs
* (MUL-3375 §4). Shows what the unified backend predicate says will start (via
* the preview endpoint — never a frontend guess), lets the user attach a
* handoff note and choose "暂不开始", then applies the change. Dismissing the
* dialog (X / Esc / click-outside) cancels without any write. Shared by single
* assign (1 id) and batch assign (N ids).
* Assignment confirmation for issues that may start agent runs.
*
* The rule is "dialog = you are confirming an assignment", NOT "you are
* confirming N runs" (MUL-5010). It therefore does no pre-flight prediction:
* opening it fires no request, so the note box and buttons are usable on the
* first frame. Previously it called POST /api/issues/preview-trigger on open
* and blocked the whole dialog behind a "检查中…" spinner; because that query is
* keyed per issue id with staleTime 0, every new issue was a guaranteed cache
* miss and the wait was unavoidable.
*
* Completion is silent: the assignee change and any run it starts surface
* through the issue's normal assignee / run-status updates, so the confirm adds
* no result toast. Whether a run starts stays the server's existing decision at
* write time. Dismissing the dialog (X / Esc / click-outside) cancels without
* any write. Shared by single assign (1 id) and batch assign (N ids).
*/
export function RunConfirmModal({
onClose,
@@ -84,44 +92,39 @@ export function RunConfirmModal({
const updateIssue = useUpdateIssue();
const batchUpdate = useBatchUpdateIssues();
const preview = useIssueTriggerPreview({
issueIds,
assigneeType: d.assigneeType ?? null,
assigneeId: d.assigneeId ?? null,
enabled: issueIds.length > 0,
});
const loading = preview.isLoading;
const willStart = preview.totalCount > 0;
const canNote = willStart;
// Local-first handoff-support verdict. For a concrete agent assignee the
// target runtime is exactly that agent's, and its CLI version is already warm
// in the prefetched agent + runtime caches (useWorkspacePresencePrefetch) —
// so we can decide whether the note box is usable synchronously, the same way
// the quick-create version gate does, instead of waiting on the preview
// round-trip just to learn something the client already holds. Squad /
// unresolved-agent stay `null` and fall through to the server's verdict,
// because their resolved trigger set (hence runtime versions) is only known
// after the backend predicate lands.
// Handoff-support verdict, resolved entirely from warm client caches
// (useWorkspacePresencePrefetch keeps agents / squads / runtimes hot), so the
// note box settles on the first frame with no round-trip — the same shape as
// the quick-create version gate. An agent assignee targets its own runtime; a
// squad targets its leader's, which the squad list gives us directly, so both
// are knowable locally. `null` means "cannot tell" (assignee not in cache
// yet, or no runtime bound) and leaves the box enabled: the note is a soft
// gate, and a spurious warning is worse than a note an old daemon drops.
const wsId = useWorkspaceId();
const { data: agents = [] } = useQuery({ ...agentListOptions(wsId), enabled: !!wsId });
const { data: runtimes = [] } = useQuery({ ...runtimeListOptions(wsId), enabled: !!wsId });
const { data: squads = [] } = useQuery({ ...squadListOptions(wsId), enabled: !!wsId });
const localHandoff = useMemo<boolean | null>(() => {
if (d.assigneeType !== "agent" || !d.assigneeId) return null;
const agent = agents.find((a) => a.id === d.assigneeId);
if (!d.assigneeId) return null;
let agentId: string | undefined;
if (d.assigneeType === "agent") {
agentId = d.assigneeId;
} else if (d.assigneeType === "squad") {
// A squad run is executed by its leader, so the leader's runtime is the
// one that has to render the note.
agentId = squads.find((s) => s.id === d.assigneeId)?.leader_id;
}
if (!agentId) return null;
const agent = agents.find((a) => a.id === agentId);
if (!agent?.runtime_id) return null;
const runtime = runtimes.find((r) => r.id === agent.runtime_id);
if (!runtime) return null;
return handoffSupported(readRuntimeCliVersion(runtime.metadata));
}, [d.assigneeType, d.assigneeId, agents, runtimes]);
}, [d.assigneeType, d.assigneeId, agents, runtimes, squads]);
// Soft gate: an old runtime can't render the note. Disable the box but let
// the assignment proceed (MUL-3375 §6.3). The local verdict resolves it
// instantly when available; otherwise we use the server's preview value once
// it lands (and only then, since `canNote` is false while loading).
const noteDisabled =
localHandoff !== null ? localHandoff === false : canNote && !preview.handoffSupported;
// the assignment proceed (MUL-3375 §6.3).
const noteDisabled = localHandoff === false;
const applyTo = (extra: Partial<UpdateIssueRequest>) => {
const base: UpdateIssueRequest = {
@@ -131,16 +134,24 @@ export function RunConfirmModal({
return { ...base, ...extra };
};
// The copy names whoever the issue is handed to; for a squad that is the
// squad itself, since its leader deciding who works is an internal detail.
const assigneeName =
d.assigneeName ??
getActorName(d.assigneeType === "squad" ? "squad" : "agent", d.assigneeId ?? "");
const submit = async (suppressRun: boolean) => {
if (issueIds.length === 0 || submitting) return;
setPendingAction(suppressRun ? "suppress" : "go");
const payload = applyTo({
...(suppressRun ? { suppress_run: true } : {}),
...(!suppressRun && canNote && !noteDisabled && note.trim()
? { handoff_note: note.trim() }
: {}),
...(!suppressRun && !noteDisabled && note.trim() ? { handoff_note: note.trim() } : {}),
});
try {
// Completion is silent, exactly as before: the assignee and any run show
// up through the issue's normal assignee / run-status updates, so there is
// no result toast to add here. Whether a run started is the server's
// existing decision at write time, not something this dialog reports.
if (issueIds.length === 1) {
await updateIssue.mutateAsync({ id: issueIds[0]!, ...payload });
} else {
@@ -153,94 +164,56 @@ export function RunConfirmModal({
}
};
// A squad doesn't "work" — its leader evaluates the issue and delegates. The
// copy reflects that (see issues.json squad_leader_*).
const isSquad = d.assigneeType === "squad";
const headline: ReactNode = (() => {
if (!willStart) {
return t(($) => $.run_confirm.nothing_assign);
}
// Single trigger → name the assignee (bolded), resolved from the preview's
// runnable agent (squad leader for squads). Batch → count.
if (preview.triggers.length === 1) {
if (isSquad) {
const name = d.assigneeName ?? getActorName("squad", d.assigneeId ?? "");
return boldName(t(($) => $.run_confirm.will_start_named_squad, { name: `${NAME_FENCE}${name}${NAME_FENCE}` }));
}
const name = d.assigneeName ?? getActorName("agent", preview.triggers[0]!.agent_id);
return boldName(t(($) => $.run_confirm.will_start_named, { name: `${NAME_FENCE}${name}${NAME_FENCE}` }));
}
return isSquad
? t(($) => $.run_confirm.will_start_squad, { count: preview.totalCount })
: t(($) => $.run_confirm.will_start, { count: preview.totalCount });
})();
// States the action, not a prediction: the assignment is certain, the run is
// conditional, so the copy names no run count.
const headline: ReactNode = boldName(
issueIds.length > 1
? t(($) => $.run_confirm.assign_batch, {
name: `${NAME_FENCE}${assigneeName}${NAME_FENCE}`,
count: issueIds.length,
})
: t(($) => $.run_confirm.assign_single, {
name: `${NAME_FENCE}${assigneeName}${NAME_FENCE}`,
}),
);
return (
<Dialog open onOpenChange={(v) => { if (!v && !submitting) onClose(); }}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t(($) => $.run_confirm.title_assign)}</DialogTitle>
<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>
<DialogDescription>{headline}</DialogDescription>
</DialogHeader>
{/* The note box stays 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. */}
{loading || canNote ? (
<div className="grid gap-1.5">
<label className="text-sm font-medium" htmlFor="handoff-note">
{t(($) => $.run_confirm.note_label)}
</label>
<Textarea
id="handoff-note"
value={note}
maxLength={MAX_HANDOFF_NOTE}
// Only block on the preview round-trip when we have no local
// verdict (squad). With a local verdict the box is usable the
// instant it opens — supported → editable, old runtime → disabled
// — never a "checking…" wait.
disabled={submitting || noteDisabled || (localHandoff === null && loading)}
placeholder={t(($) => $.run_confirm.note_placeholder)}
onChange={(e) => setNote(e.target.value)}
rows={3}
/>
{noteDisabled && (localHandoff !== null || !loading) ? (
<p className="text-xs text-muted-foreground">{t(($) => $.run_confirm.note_unsupported)}</p>
) : null}
</div>
) : null}
{/* Always mounted and always usable on the first frame — nothing about
this box depends on a server answer. */}
<div className="grid gap-1.5">
<label className="text-sm font-medium" htmlFor="handoff-note">
{t(($) => $.run_confirm.note_label)}
</label>
<Textarea
id="handoff-note"
value={note}
maxLength={MAX_HANDOFF_NOTE}
disabled={submitting || noteDisabled}
placeholder={t(($) => $.run_confirm.note_placeholder)}
onChange={(e) => setNote(e.target.value)}
rows={3}
/>
{noteDisabled ? (
<p className="text-xs text-muted-foreground">{t(($) => $.run_confirm.note_unsupported)}</p>
) : null}
</div>
{/* The only spinner left is on the button the user just pressed, and it
reflects the write in flight — never a pre-flight check. */}
<DialogFooter>
{loading ? (
<Button type="button" disabled>
<Spinner className="size-4" />
</Button>
) : willStart ? (
<>
<Button type="button" variant="outline" disabled={submitting} onClick={() => submit(true)}>
{pendingAction === "suppress" ? <Spinner className="size-4" /> : t(($) => $.run_confirm.dont_start)}
</Button>
<Button type="button" disabled={submitting} onClick={() => submit(false)}>
{pendingAction === "go" ? <Spinner className="size-4" /> : t(($) => $.run_confirm.start)}
</Button>
</>
) : (
<Button type="button" disabled={submitting} onClick={() => submit(false)}>
{pendingAction === "go" ? <Spinner className="size-4" /> : t(($) => $.run_confirm.apply)}
</Button>
)}
<Button type="button" variant="outline" disabled={submitting} onClick={() => submit(true)}>
{pendingAction === "suppress" ? <Spinner className="size-4" /> : t(($) => $.run_confirm.dont_start)}
</Button>
<Button type="button" disabled={submitting} onClick={() => submit(false)}>
{pendingAction === "go" ? <Spinner className="size-4" /> : t(($) => $.run_confirm.confirm_assign)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>