mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-26 20:45:37 +02:00
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:
@@ -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.",
|
||||
|
||||
@@ -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}} のリーダーが内容を評価し、タスクの割り振りを開始します。",
|
||||
|
||||
@@ -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}}의 리더가 내용을 평가한 뒤 작업 배분을 시작합니다.",
|
||||
|
||||
@@ -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}} 的队长会评估并开始安排任务。",
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user