diff options
| author | Adam Malczewski <[email protected]> | 2026-10-16 15:25:40 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-10-16 15:25:40 +0900 |
| commit | a4aa86e66dad93f40bce640545d8903f7e6bcb64 (patch) | |
| tree | f02f83ffffc5a03ed34e5bd460f806b456b02bbe /src/features/tabs/ui.test.ts | |
| parent | f6d96289470ac888670050ff64c54bbe69b973cb (diff) | |
| download | dispatch-web-a4aa86e66dad93f40bce640545d8903f7e6bcb64.tar.gz dispatch-web-a4aa86e66dad93f40bce640545d8903f7e6bcb64.zip | |
feat(tabs): confirm dialog that cascades a tab close to its subagents
Closing a tab whose visible subagent group is non-empty now asks ONE
daisyUI modal (CloseSubagentsConfirm): 'Yes, close all N' closes the tab
plus every transitive descendant tab (sub-sub-agents included, no further
prompts), 'No, keep them open' closes only the tab, and Cancel/Escape/
backdrop closes nothing. Plain tabs close directly as before.
- cascadeCloseIds (pure, tab-groups.ts): the tab + its transitive visible
group descendants in tab order — one decision covers the whole subtree
- TabList intercepts tab-close and opens the dialog only when the
subtree has >1 tab; ids are snapshotted at open time
- each closed conversation goes through the existing onClose port, so
every close still POSTs /conversations/:id/close (backend doesn't
cascade; it is idempotent)
Diffstat (limited to 'src/features/tabs/ui.test.ts')
| -rw-r--r-- | src/features/tabs/ui.test.ts | 74 |
1 files changed, 73 insertions, 1 deletions
diff --git a/src/features/tabs/ui.test.ts b/src/features/tabs/ui.test.ts index 2569438..0243ea4 100644 --- a/src/features/tabs/ui.test.ts +++ b/src/features/tabs/ui.test.ts @@ -1,4 +1,4 @@ -import { fireEvent, render, screen } from "@testing-library/svelte"; +import { fireEvent, render, screen, within } from "@testing-library/svelte"; import userEvent from "@testing-library/user-event"; import { describe, expect, it, vi } from "vitest"; import type { Tab } from "./tabs"; @@ -544,5 +544,77 @@ describe("TabList", () => { vi.useRealTimers(); } }); + + describe("closing a tab with subagents (confirm + cascade)", () => { + const dialog = (): HTMLElement => + screen.getByRole("dialog", { name: "Close subagents too?" }); + + async function clickCloseOn(id: string): Promise<void> { + await fireEvent.click(within(card(id)).getByRole("button", { name: "Close tab" })); + } + + function renderClosable(onClose = vi.fn()) { + render(TabList, { + props: { + tabs: grouped, + activeConversationId: "p", + onSelect: vi.fn(), + onClose, + onNewDraft: vi.fn(), + }, + }); + return onClose; + } + + it("closes a tab WITHOUT subagents directly — no confirm dialog", async () => { + const onClose = renderClosable(); + await clickCloseOn("solo"); + expect(onClose).toHaveBeenCalledTimes(1); + expect(onClose).toHaveBeenCalledWith("solo"); + expect(screen.queryByRole("dialog")).toBeNull(); + }); + + it("shows ONE confirm dialog for a group parent (never one per descendant)", async () => { + const onClose = renderClosable(); + await clickCloseOn("p"); + // 3 subagent tabs: c1, c2 and the nested grandchild g. + expect(dialog()).toHaveTextContent("3 subagent tabs"); + expect(onClose).not.toHaveBeenCalled(); + }); + + it("'No, keep them open' closes only the tab itself", async () => { + const onClose = renderClosable(); + await clickCloseOn("p"); + await fireEvent.click(screen.getByRole("button", { name: /No, keep them open/ })); + expect(onClose).toHaveBeenCalledTimes(1); + expect(onClose).toHaveBeenCalledWith("p"); + expect(screen.queryByRole("dialog")).toBeNull(); + }); + + it("'Yes, close all' cascades to every descendant — no further prompts", async () => { + const onClose = renderClosable(); + await clickCloseOn("p"); + await fireEvent.click(screen.getByRole("button", { name: /Yes, close all/ })); + // p first, then its transitive descendants in tab order (c1, c2, g). + expect(onClose.mock.calls.map((c) => c[0])).toEqual(["p", "c1", "c2", "g"]); + expect(screen.queryByRole("dialog")).toBeNull(); + }); + + it("cascades from a mid-level subagent, taking only its own subtree", async () => { + const onClose = renderClosable(); + await clickCloseOn("c1"); + expect(dialog()).toHaveTextContent("1 subagent tab"); + await fireEvent.click(screen.getByRole("button", { name: /Yes, close all/ })); + expect(onClose.mock.calls.map((c) => c[0])).toEqual(["c1", "g"]); + }); + + it("Escape dismisses the dialog without closing anything", async () => { + const onClose = renderClosable(); + await clickCloseOn("p"); + await fireEvent.keyDown(window, { key: "Escape" }); + expect(screen.queryByRole("dialog")).toBeNull(); + expect(onClose).not.toHaveBeenCalled(); + }); + }); }); }); |
