From f810e9bc2bbd93f2556404b65f0e299eb89ea3c7 Mon Sep 17 00:00:00 2001 From: J Date: Wed, 10 Jun 2026 15:24:48 +0800 Subject: [PATCH] fix(skills): preserve bulk flow after conflict resolution Co-authored-by: multica-agent --- .../runtime-local-skill-import-panel.test.tsx | 75 +++++++++++++++++++ .../runtime-local-skill-import-panel.tsx | 16 +++- 2 files changed, 89 insertions(+), 2 deletions(-) diff --git a/packages/views/skills/components/runtime-local-skill-import-panel.test.tsx b/packages/views/skills/components/runtime-local-skill-import-panel.test.tsx index b4a3994eb..dab605bdf 100644 --- a/packages/views/skills/components/runtime-local-skill-import-panel.test.tsx +++ b/packages/views/skills/components/runtime-local-skill-import-panel.test.tsx @@ -453,4 +453,79 @@ describe("RuntimeLocalSkillImportPanel", () => { expect(await screen.findByText("Updated")).toBeInTheDocument(); }); + + it("keeps bulk completion behavior when conflict resolution leaves one success", async () => { + mockRuntimeLocalSkillsOptions.mockReturnValue({ + queryKey: ["runtimes", "local-skills", "runtime-1"], + queryFn: () => + Promise.resolve({ + supported: true, + skills: [MOCK_SKILL_A, MOCK_SKILL_B], + }), + }); + mockResolveRuntimeLocalSkillImport + .mockResolvedValueOnce({ + status: "conflict", + conflict: { + existing_skill_id: "existing-skill-1", + existing_created_by: "user-1", + can_overwrite: true, + }, + }) + .mockRejectedValueOnce(new Error("daemon failed")) + .mockResolvedValueOnce({ + status: "updated", + skill: { + ...MOCK_IMPORTED_SKILL_A, + id: "existing-skill-1", + }, + }); + + const onImported = vi.fn(); + const onBulkDone = vi.fn(); + renderPanel({ onImported, onBulkDone }); + + expect( + await screen.findByText("Review Helper", {}, { timeout: 5000 }), + ).toBeInTheDocument(); + + const selectAllLabel = screen.getByText(/Select all/i); + const selectAllCheckbox = selectAllLabel + .closest("label")! + .querySelector("input[type='checkbox']")!; + fireEvent.click(selectAllCheckbox); + + const importButton = screen.getByRole("button", { + name: /Import 2 Skills/i, + }); + await waitFor(() => expect(importButton).not.toBeDisabled(), { + timeout: 5000, + }); + fireEvent.click(importButton); + + expect( + await screen.findByText(/A skill with this name already exists/i), + ).toBeInTheDocument(); + + const applyButton = screen.getByRole("button", { + name: /Apply decisions/i, + }); + await waitFor(() => expect(applyButton).not.toBeDisabled(), { + timeout: 5000, + }); + fireEvent.click(applyButton); + + await waitFor( + () => { + expect( + screen.getByRole("button", { name: /Done/i }), + ).toBeInTheDocument(); + }, + { timeout: 10000 }, + ); + + fireEvent.click(screen.getByRole("button", { name: /Done/i })); + expect(onBulkDone).toHaveBeenCalledTimes(1); + expect(onImported).not.toHaveBeenCalled(); + }); }); diff --git a/packages/views/skills/components/runtime-local-skill-import-panel.tsx b/packages/views/skills/components/runtime-local-skill-import-panel.tsx index 13216441c..7a902a349 100644 --- a/packages/views/skills/components/runtime-local-skill-import-panel.tsx +++ b/packages/views/skills/components/runtime-local-skill-import-panel.tsx @@ -68,6 +68,7 @@ type BulkImportState = { phase: "idle" | "importing" | "resolving" | "done" | "cancelled"; total: number; completed: number; + selectedCount: number; results: BulkImportResult[]; }; @@ -82,6 +83,7 @@ const INITIAL_BULK_STATE: BulkImportState = { phase: "idle", total: 0, completed: 0, + selectedCount: 0, results: [], }; @@ -622,7 +624,13 @@ export function RuntimeLocalSkillImportPanel({ const total = skillsToImport.length; cancelRef.current = false; - setBulkState({ phase: "importing", total, completed: 0, results: [] }); + setBulkState({ + phase: "importing", + total, + completed: 0, + selectedCount: total, + results: [], + }); const results: BulkImportResult[] = []; @@ -1029,7 +1037,11 @@ export function RuntimeLocalSkillImportPanel({ ); // Single-import flow: navigate to the imported skill detail page. // Multi-import flow: close the dialog even if only one succeeded. - if (bulkState.total === 1 && succeeded.length === 1 && succeeded[0]!.skill) { + if ( + bulkState.selectedCount === 1 && + succeeded.length === 1 && + succeeded[0]!.skill + ) { onImported?.(succeeded[0]!.skill); } else { onBulkDone?.();