From 645e8a298343aa81e4e49e9728c2784a59a9335d Mon Sep 17 00:00:00 2001 From: Naiyuan Qing <145280634+NevilleQingNY@users.noreply.github.com> Date: Tue, 21 Jul 2026 15:48:47 +0800 Subject: [PATCH] =?UTF-8?q?feat(issues):=20=E7=A1=AE=E8=AE=A4=E6=8C=87?= =?UTF-8?q?=E6=B4=BE=E5=BC=B9=E7=AA=97=E5=8E=BB=E6=8E=89=E6=89=93=E5=BC=80?= =?UTF-8?q?=E6=97=B6=E7=9A=84=20run=20=E9=A2=84=E8=A7=88=E7=AD=89=E5=BE=85?= =?UTF-8?q?=20(MUL-5010)=20(#5678)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 Co-authored-by: multica-agent * 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 Co-authored-by: multica-agent * 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 Co-authored-by: multica-agent * 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 Co-authored-by: multica-agent --------- Co-authored-by: Claude Opus 4.8 Co-authored-by: multica-agent --- packages/views/locales/en/modals.json | 13 +- packages/views/locales/ja/modals.json | 13 +- packages/views/locales/ko/modals.json | 13 +- packages/views/locales/zh-Hans/modals.json | 13 +- packages/views/modals/run-confirm.test.tsx | 216 ++++++++++----------- packages/views/modals/run-confirm.tsx | 207 +++++++++----------- 6 files changed, 213 insertions(+), 262 deletions(-) diff --git a/packages/views/locales/en/modals.json b/packages/views/locales/en/modals.json index fc40a1891..c12de5045 100644 --- a/packages/views/locales/en/modals.json +++ b/packages/views/locales/en/modals.json @@ -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.", diff --git a/packages/views/locales/ja/modals.json b/packages/views/locales/ja/modals.json index 1c05c50a6..e6b033fb9 100644 --- a/packages/views/locales/ja/modals.json +++ b/packages/views/locales/ja/modals.json @@ -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}} のリーダーが内容を評価し、タスクの割り振りを開始します。", diff --git a/packages/views/locales/ko/modals.json b/packages/views/locales/ko/modals.json index f9a83bd13..8cc1e5bb3 100644 --- a/packages/views/locales/ko/modals.json +++ b/packages/views/locales/ko/modals.json @@ -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}}의 리더가 내용을 평가한 뒤 작업 배분을 시작합니다.", diff --git a/packages/views/locales/zh-Hans/modals.json b/packages/views/locales/zh-Hans/modals.json index 90ed5ed46..1fba0bc8f 100644 --- a/packages/views/locales/zh-Hans/modals.json +++ b/packages/views/locales/zh-Hans/modals.json @@ -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}} 的队长会评估并开始安排任务。", diff --git a/packages/views/modals/run-confirm.test.tsx b/packages/views/modals/run-confirm.test.tsx index 50a07dd4f..d3f320335 100644 --- a/packages/views/modals/run-confirm.test.tsx +++ b/packages/views/modals/run-confirm.test.tsx @@ -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; }>, + 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) => { - // 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, + vars?: Record, + ) => { + // 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: () => , })); -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( - , - ); + 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(); + 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(); 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(); + 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( - , - ); + render(); 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(); + 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( , ); 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( - , - ); + 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(); 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( - , + it("batch assign (N ids) applies via batchUpdate", async () => { + const { container } = render( + , ); - 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( - , - ); - 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(); + fireEvent.click(screen.getByText("Confirm assignment")); + await waitFor(() => expect(mockToast.error).toHaveBeenCalledWith("boom")); + expect(onClose).not.toHaveBeenCalled(); + expect(mockToast.success).not.toHaveBeenCalled(); }); }); diff --git a/packages/views/modals/run-confirm.tsx b/packages/views/modals/run-confirm.tsx index 8d6c1f2ba..c3763a78a 100644 --- a/packages/views/modals/run-confirm.tsx +++ b/packages/views/modals/run-confirm.tsx @@ -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(() => { - 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) => { 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 ( { if (!v && !submitting) onClose(); }}> {t(($) => $.run_confirm.title_assign)} - - {loading ? ( - - - {t(($) => $.run_confirm.checking)} - - ) : ( - headline - )} - + {headline} - {/* 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 ? ( -
- -