From a4aa86e66dad93f40bce640545d8903f7e6bcb64 Mon Sep 17 00:00:00 2001 From: Adam Malczewski Date: Fri, 16 Oct 2026 15:25:40 +0900 Subject: feat(tabs): confirm dialog that cascades a tab close to its subagents MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- notes/assumptions-log.md | 20 ++++++ src/features/tabs/index.ts | 1 + src/features/tabs/tab-groups.test.ts | 29 +++++++++ src/features/tabs/tab-groups.ts | 16 +++++ src/features/tabs/ui.test.ts | 74 ++++++++++++++++++++++- src/features/tabs/ui/CloseSubagentsConfirm.svelte | 57 +++++++++++++++++ src/features/tabs/ui/TabList.svelte | 43 ++++++++++++- 7 files changed, 238 insertions(+), 2 deletions(-) create mode 100644 src/features/tabs/ui/CloseSubagentsConfirm.svelte diff --git a/notes/assumptions-log.md b/notes/assumptions-log.md index 728f8db..c6daf59 100644 --- a/notes/assumptions-log.md +++ b/notes/assumptions-log.md @@ -2,6 +2,26 @@ > Recorded while working autonomously (user away). Raise these when the user returns. +## 2026-10-16 — Close-tab cascade confirm dialog (subagent groups) + +1. **"Subagents" of a tab = its VISIBLE subagent group (open tabs only).** The confirm dialog + triggers when the closed tab has descendants in the tab grid's grouping + (`tabGroupParentId` forest, transitive). Subagent conversations that were never opened as + tabs are NOT closed — the FE has no knowledge of them (no lineage API consumed here). The + backend's `POST /conversations/:id/close` does not cascade either, so this matches the + visible mental model. + +2. **The cascade issues one `POST /close` per conversation** (via the existing `onClose` → + `store.closeTab` port, unchanged). The backend close is idempotent, so a descendant closed + by another client mid-dialog is a harmless no-op. No new cross-unit contract was needed: + the decision logic is pure (`cascadeCloseIds` in the tabs unit) and the dialog lives inside + `TabList`. + +3. **Dialog offers three outs:** "Yes, close all N" (cascade), "No, keep them open" (close + only the target tab — the pre-change behavior), and Cancel/Escape/backdrop (close nothing). + The user asked for a yes/no prompt; Cancel was added as the safe dismissal path. The + cascade-order snapshot (ids) is taken when the dialog opens. + ## 2026-06-24 — CR-10: workspaceId on conversation.open / statusChanged 1. **Contract version already at 0.19.0 in the backend repo.** The `file:` dep in `package.json` diff --git a/src/features/tabs/index.ts b/src/features/tabs/index.ts index 980e8ee..0094528 100644 --- a/src/features/tabs/index.ts +++ b/src/features/tabs/index.ts @@ -1,6 +1,7 @@ export type { TabGroupForest, TabGroupNode } from "./tab-groups"; export { buildTabGroups, + cascadeCloseIds, directGroupChildren, groupBleedPx, groupDescendants, diff --git a/src/features/tabs/tab-groups.test.ts b/src/features/tabs/tab-groups.test.ts index 83f273c..a89a469 100644 --- a/src/features/tabs/tab-groups.test.ts +++ b/src/features/tabs/tab-groups.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vitest"; import { buildTabGroups, + cascadeCloseIds, directGroupChildren, groupBleedPx, groupDescendants, @@ -103,6 +104,34 @@ describe("groupDescendants / directGroupChildren / groupParentCandidates", () => }); }); +describe("cascadeCloseIds (the ids a cascading tab close removes)", () => { + const tabs = [ + tab("p"), + tab("c1", "p"), + tab("c2", "p"), + tab("g", "c1"), // sub-sub-agent: grouped under c1, still cascades from p + tab("solo"), + ]; + + it("returns the tab first, then its transitive descendants in tab order", () => { + expect(cascadeCloseIds(tabs, "p")).toEqual(["p", "c1", "c2", "g"]); + }); + + it("starts the cascade at a mid-level tab, taking only its own subtree", () => { + expect(cascadeCloseIds(tabs, "c1")).toEqual(["c1", "g"]); + }); + + it("returns just the tab itself when it has no subagents (closes without a prompt)", () => { + expect(cascadeCloseIds(tabs, "solo")).toEqual(["solo"]); + expect(cascadeCloseIds(tabs, "c2")).toEqual(["c2"]); + }); + + it("ignores tabs whose group parent is no longer open", () => { + const stale = [tab("p"), tab("orphan", "gone")]; + expect(cascadeCloseIds(stale, "p")).toEqual(["p"]); + }); +}); + describe("groupBleedPx / groupGutterPx", () => { it("is zero without groups", () => { expect(groupGutterPx(0)).toBe(0); diff --git a/src/features/tabs/tab-groups.ts b/src/features/tabs/tab-groups.ts index 12870ee..a1aa464 100644 --- a/src/features/tabs/tab-groups.ts +++ b/src/features/tabs/tab-groups.ts @@ -103,6 +103,22 @@ export function groupDescendants(tabs: readonly Tab[], conversationId: string): return out; } +/** + * The tabs removed by a CASCADING close of `conversationId` (the user chose + * "also close its subagents"): the tab itself first, then its transitive + * visible-group descendants in tab order. ONE decision covers the whole + * subtree — closing never re-prompts per level. A single-element result (the + * tab alone) means the tab has no subagents and closes without a prompt. + */ +export function cascadeCloseIds(tabs: readonly Tab[], conversationId: string): string[] { + const descendants = groupDescendants(tabs, conversationId); + const ids = [conversationId]; + for (const tab of tabs) { + if (descendants.has(tab.conversationId)) ids.push(tab.conversationId); + } + return ids; +} + /** * The tabs `conversationId` may be moved under (the "Add to group" menu): * every other tab EXCEPT itself, its descendants (that would form a cycle), 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 { + 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(); + }); + }); }); }); diff --git a/src/features/tabs/ui/CloseSubagentsConfirm.svelte b/src/features/tabs/ui/CloseSubagentsConfirm.svelte new file mode 100644 index 0000000..cbbec79 --- /dev/null +++ b/src/features/tabs/ui/CloseSubagentsConfirm.svelte @@ -0,0 +1,57 @@ + + + e.key === "Escape" && onDismiss()} /> + + diff --git a/src/features/tabs/ui/TabList.svelte b/src/features/tabs/ui/TabList.svelte index 06d5aa6..4b96e08 100644 --- a/src/features/tabs/ui/TabList.svelte +++ b/src/features/tabs/ui/TabList.svelte @@ -1,6 +1,7 @@ {#snippet card(tab: Tab, wide: boolean, subagentCount: number)} @@ -83,7 +105,7 @@ {wide} {subagentCount} {onSelect} - {onClose} + onClose={requestClose} {onRename} onMenu={openMenu} /> @@ -147,3 +169,22 @@ onDismiss={() => (menu = null)} /> {/if} + +{#if closeConfirm !== null} + {@const target = closeConfirm.tab} + {@const cascadeIds = [...closeConfirm.cascadeIds]} + {@const closeOwner = onClose} + { + closeOwner(target.conversationId); + closeConfirm = null; + }} + onCloseAll={() => { + for (const id of cascadeIds) closeOwner(id); + closeConfirm = null; + }} + onDismiss={() => (closeConfirm = null)} + /> +{/if} -- cgit v1.2.3 From 0ba54b4455cb560cf7a82baf71b31ed129cfc29b Mon Sep 17 00:00:00 2001 From: Adam Malczewski Date: Sat, 24 Oct 2026 17:09:17 +0900 Subject: fix(tabs): finish accessible cascade confirmation and verify active closes --- backend-handoff.md | 60 +++++++++++ notes/assumptions-log.md | 12 +++ src/app/App.test.ts | 115 +++++++++++++++++++++- src/features/tabs/tab-groups.test.ts | 9 ++ src/features/tabs/ui.test.ts | 71 ++++++++++++- src/features/tabs/ui/CloseSubagentsConfirm.svelte | 44 +++++++-- src/features/tabs/ui/TabList.svelte | 12 +++ vitest-setup.ts | 12 +++ 8 files changed, 324 insertions(+), 11 deletions(-) diff --git a/backend-handoff.md b/backend-handoff.md index 0878b93..e6da7b9 100644 --- a/backend-handoff.md +++ b/backend-handoff.md @@ -18,6 +18,66 @@ > ?archived=`) was made in the same branch and the FE consumes it via the `file:` deps (dists > rebuilt). Recorded below. +## Milestone: Close-tab cascade subagents (`feature/close-tab-cascade-subagents`, 2026-10-24) + +**Slice status: COMPLETE across the isolated frontend + backend feature branches.** + +- Frontend integrated local `dev` at `913cc7b` (merge `b462f17`), preserving the new + tab colors, 70vh grid, FLIP close/reflow animations and current reliability fixes. +- Closing a tab with a nonempty **visible tab-group** subtree prompts once: + **Yes, close all N** closes the snapshotted transitive subtree; **No, keep them open** + closes only the parent; Cancel/Escape/backdrop close nothing. Leaf tabs close directly. + New tabs arriving after confirmation opens are not silently added to its close set. + This is visual `tabGroupParentId` grouping, NOT backend lineage traversal or an implicit + server cascade. Other workspaces and unopened conversations are outside this close set. +- Native modal dialog renders in the browser top layer (not clipped by animated/sidebar + containers), traps keyboard focus, starts on Cancel, restores focus on dismissal, and + wraps actions at mobile widths. Remote target closure/workspace switch dismisses stale UI. +- Each id uses the existing `AppStore.closeTab` → single `POST /conversations/:id/close` + route; real-store component regressions cover persistence + workspace isolation for + all/parent/cancel. No double-close workaround or new FE cross-unit port. + +**Backend race discovered and FIXED, not waived:** explicit Close aborted the turn, but +its finalization unconditionally persisted/published `idle` afterward, reopening the tab. +The original companion symlink to main was replaced (with user authorization) by a real +backend `feature/close-tab-cascade-subagents` worktree at local `dev` `4bf4dd9`. +Commit **`fd07259`** preserves Close through deferred lifecycle work and settlement, +keeps Stop → idle + explicit reopen behavior, suppresses stale broadcasts/auto-compaction, +and cleans per-turn lifecycle tracking. Five real-store regressions cover the race. +See backend `notes/close-tab-cascade-subagents.md` for reproducible direct HTTP steps. +Neither main checkout nor the user's running backend process was edited/restarted. + +**Contract pins/mirrors:** unchanged `file:../backend/packages/*` deps; `wire@0.12.0`, +`transport-contract@0.26.0`, `ui-contract@0.2.0` (existing additive mirrors retained). +Dependencies were refreshed after replacing the companion with its real worktree. +No API/type/version delta to mirror; no remaining backend asks. + +**Verification:** +- FE: `bun run typecheck` **0 errors/0 warnings**; full suite **2,516 passed ×2** + (127 files; an earlier pass before the final focus regression was 2,515); + `bun run check` clean; `bun run build` succeeds (existing Svelte capture/chunk-size warnings). +- BE: typecheck clean; Vitest **4,214 passed / 10 skipped**; Biome clean; + Bun suite final combined run **188 passed**, also all seven groups pass independently. + Two earlier combined Bun runs hit native Bun 1.3.14 crashes; subsequent combined run passed. +- Writable live verification used ONLY a private source backend at **24303/24305**, with + fresh SQLite/trace/journal/image/plugin paths under `/tmp/close-tab-isolated` and + inherited real credentials (never printed or replaced). Standard live probe **22/22**. +- Direct HTTP active-turn close now returns `abortedTurn: true`, seals/ends aborted and + stays absent from active/idle list after stream completion. Real Chromium end-to-end + cascade does the same for the nested active child, leaves unrelated tabs and stays + closed after reload. Desktop 1280px + mobile 420px checks pass: all/parent-only, + Cancel/Escape/backdrop, leaf close, remote target close, top-layer modal, focus containment + and restoration, mobile bounds; no uncaught browser exceptions. +- Fixture conversations closed/workspaces archived. Reports, screenshots and probe harnesses: + `reports/close-tab-cascade/` (gitignored). Private server + collector and browser/Vite + processes are cleaned up after verification. No merge back or push. + +**Human smoke check still requested:** open a real parent with nested subagent tabs; +close → Cancel/Escape, close → No (children stay), then close another group → Yes +(all descendants stop/close). At mobile width open the sidebar first; confirm all three +buttons remain reachable and the dialog is not clipped. Browser automation passed this +flow, but theme/layout should still be confirmed in the user's own browser. + ## Milestone: reasoning block boundaries on the live stream (2026-10-22) FE slice consuming a new ADDITIVE backend wire event. The kernel now emits one payload-free diff --git a/notes/assumptions-log.md b/notes/assumptions-log.md index c6daf59..89b057a 100644 --- a/notes/assumptions-log.md +++ b/notes/assumptions-log.md @@ -2,6 +2,18 @@ > Recorded while working autonomously (user away). Raise these when the user returns. +## 2026-10-24 — Close-tab cascade completion + +- Retained the original visible-group + snapshot semantics below, including one close + request per id; no backend lineage cascade and no close-all retry workaround. +- User explicitly authorized replacing the companion backend symlink with an isolated + feature worktree to fix the reproducible active-turn Close → idle finalization race. + Backend commit `fd07259` fixes this and adds regression coverage. Verification writes + for the fixed backend use fresh private runtime paths, never the user's instance. +- Native modal lifecycle (top layer/focus containment/safe initial focus) replaces the + original CSS-only overlay; stale target dialogs dismiss on workspace/remote removal. +- Final verification and cross-repo handoff: `backend-handoff.md` close-tab milestone. + ## 2026-10-16 — Close-tab cascade confirm dialog (subagent groups) 1. **"Subagents" of a tab = its VISIBLE subagent group (open tabs only).** The confirm dialog diff --git a/src/app/App.test.ts b/src/app/App.test.ts index 9c5f5cc..ebc8004 100644 --- a/src/app/App.test.ts +++ b/src/app/App.test.ts @@ -2,7 +2,7 @@ import type { LayoutTree } from "@dispatch/layout-contract"; import { DEFAULT_LAYOUT } from "@dispatch/layout-contract"; import type { SetCwdRequest, WsServerMessage } from "@dispatch/transport-contract"; import type { SurfaceServerMessage } from "@dispatch/ui-contract"; -import { render, screen } from "@testing-library/svelte"; +import { fireEvent, render, screen, waitFor, within } from "@testing-library/svelte"; import userEvent from "@testing-library/user-event"; import { describe, expect, it, vi } from "vitest"; import type { WebSocketLike } from "../adapters/ws"; @@ -180,6 +180,119 @@ function activeConversationId(store: ReturnType): string } describe("App component interaction tests", () => { + it.each([ + "all", + "parent", + "cancel", + ] as const)("group close %s flows through the real store, persistence and HTTP close port", async (choice) => { + const ws = fakeSocket(); + const storage = createFakeStorage(); + const closed: string[] = []; + const base = fakeFetchWithLayout(["tabs"]); + const store = createAppStore({ + socketFactory: () => ws, + fetchImpl: async (input, init) => { + const url = + typeof input === "string" ? input : input instanceof URL ? input.href : input.url; + if (url.includes("/conversations?status=")) { + return new Response( + JSON.stringify({ + conversations: [ + { id: "p", workspaceId: "default" }, + { id: "c1", workspaceId: "default", tabGroupParentId: "p" }, + { id: "c2", workspaceId: "default", tabGroupParentId: "p" }, + { id: "g", workspaceId: "default", tabGroupParentId: "c1" }, + { id: "solo", workspaceId: "default" }, + { id: "other-workspace", workspaceId: "other", tabGroupParentId: "p" }, + ].map((conversation) => ({ + ...conversation, + title: conversation.id, + status: "idle", + lastActivityAt: 0, + })), + }), + { status: 200 }, + ); + } + if (url.endsWith("/close")) { + expect(init?.method).toBe("POST"); + const id = url.split("/").at(-2) as string; + closed.push(id); + return new Response(JSON.stringify({ conversationId: id, abortedTurn: false }), { + status: 200, + }); + } + return base(input, init); + }, + localStorage: storage, + }); + try { + ws.resolveOpen(); + render(App, { props: { store, onNavigate: vi.fn() } }); + await screen.findByRole("tablist"); + for (const [id, parent] of [ + ["p", null], + ["c1", "p"], + ["c2", "p"], + ["g", "c1"], + ["solo", null], + ] as const) { + ws.feedServerMessage({ + type: "conversation.open", + conversationId: id, + workspaceId: "default", + ...(parent === null ? {} : { tabGroupParentId: parent }), + }); + } + ws.feedServerMessage({ + type: "conversation.open", + conversationId: "other-workspace", + workspaceId: "other", + tabGroupParentId: "p", + }); + await waitFor(() => expect(screen.getAllByRole("tab")).toHaveLength(5)); + store.selectTab("p"); + const parent = await waitFor(() => { + const tab = document.querySelector('[role="tab"][data-conversation-id="p"]'); + expect(tab).not.toBeNull(); + return tab; + }); + if (parent === null) throw new Error("Parent tab missing"); + await fireEvent.click(within(parent).getByRole("button", { name: "Close tab" })); + const dialog = screen.getByRole("dialog", { name: "Close subagents too?" }); + expect(dialog).toHaveTextContent("3 subagent tabs"); + expect(closed).toEqual([]); + await fireEvent.click( + within(dialog).getByRole("button", { + name: + choice === "all" + ? /Yes, close all 4/ + : choice === "parent" + ? /No, keep them open/ + : "Cancel", + }), + ); + const expected = choice === "all" ? ["p", "c1", "c2", "g"] : choice === "parent" ? ["p"] : []; + expect(closed).toEqual(expected); + expect(store.tabs.map((tab) => tab.conversationId)).toEqual( + ["p", "c1", "c2", "g", "solo"].filter((id) => !expected.includes(id)), + ); + expect(screen.queryByRole("dialog")).toBeNull(); + if (choice !== "cancel") { + const persisted = JSON.parse(storage.getItem("dispatch.tabs") as string) as { + tabs: { conversationId: string }[]; + }; + expect(persisted.tabs.map((tab) => tab.conversationId)).toEqual([ + ...["p", "c1", "c2", "g", "solo"].filter((id) => !expected.includes(id)), + "other-workspace", + ]); + expect(store.activeConversationId).not.toBe("p"); + } + } finally { + store.dispose(); + } + }); + it("renders the model selector and composer in draft mode", () => { const ws = fakeSocket(); const store = createAppStore({ diff --git a/src/features/tabs/tab-groups.test.ts b/src/features/tabs/tab-groups.test.ts index 67150ff..46b04e7 100644 --- a/src/features/tabs/tab-groups.test.ts +++ b/src/features/tabs/tab-groups.test.ts @@ -127,6 +127,15 @@ describe("cascadeCloseIds (the ids a cascading tab close removes)", () => { expect(cascadeCloseIds(tabs, "c2")).toEqual(["c2"]); }); + it("matches the rendered forest for cycles and manual regrouping", () => { + const cycle = [tab("p", "c1"), tab("c1", "p"), tab("g", "c1")]; + expect(cascadeCloseIds(cycle, "p")).toEqual(["p"]); + expect(cascadeCloseIds(cycle, "c1")).toEqual(["c1", "g"]); + const regrouped = [tab("p"), tab("c1", "solo"), tab("g", "c1"), tab("solo")]; + expect(cascadeCloseIds(regrouped, "p")).toEqual(["p"]); + expect(cascadeCloseIds(regrouped, "solo")).toEqual(["solo", "c1", "g"]); + }); + it("ignores tabs whose group parent is no longer open", () => { const stale = [tab("p"), tab("orphan", "gone")]; expect(cascadeCloseIds(stale, "p")).toEqual(["p"]); diff --git a/src/features/tabs/ui.test.ts b/src/features/tabs/ui.test.ts index 88b89bf..8f775a1 100644 --- a/src/features/tabs/ui.test.ts +++ b/src/features/tabs/ui.test.ts @@ -744,10 +744,79 @@ describe("TabList", () => { expect(onClose.mock.calls.map((c) => c[0])).toEqual(["c1", "g"]); }); + it("restores keyboard focus to the opener after cancellation", async () => { + renderClosable(); + const user = userEvent.setup(); + const opener = within(card("p")).getByRole("button", { name: "Close tab" }); + await user.click(opener); + expect(screen.getByRole("button", { name: "Cancel" })).toHaveFocus(); + await user.click(screen.getByRole("button", { name: "Cancel" })); + expect(opener).toHaveFocus(); + }); + + it("Cancel and backdrop dismiss without closing any tab", async () => { + const onClose = renderClosable(); + await clickCloseOn("p"); + expect(screen.getByRole("button", { name: "Cancel" })).toHaveFocus(); + await fireEvent.click(screen.getByRole("button", { name: "Cancel" })); + expect(screen.queryByRole("dialog")).toBeNull(); + await clickCloseOn("p"); + await fireEvent.submit(dialog().querySelector("form") as HTMLFormElement); + expect(screen.queryByRole("dialog")).toBeNull(); + expect(onClose).not.toHaveBeenCalled(); + }); + + it("keeps the confirmed subtree snapshot when new tabs arrive", async () => { + const onClose = vi.fn(); + const props = { + tabs: grouped, + activeConversationId: "p", + onSelect: vi.fn(), + onClose, + onNewDraft: vi.fn(), + }; + const { rerender } = render(TabList, { props }); + await clickCloseOn("p"); + await rerender({ + ...props, + tabs: [ + ...grouped, + { + conversationId: "late", + model: "m", + title: "Late", + workspaceId: "w", + tabGroupParentId: "p", + }, + ], + }); + expect(dialog()).toHaveTextContent("3 subagent tabs"); + await fireEvent.click(screen.getByRole("button", { name: /Yes, close all/ })); + expect(onClose.mock.calls.map((call) => call[0])).toEqual(["p", "c1", "c2", "g"]); + }); + + it("dismisses when the target disappears (remote close or workspace switch)", async () => { + const onClose = vi.fn(); + const props = { + tabs: grouped, + activeConversationId: "p", + onSelect: vi.fn(), + onClose, + onNewDraft: vi.fn(), + }; + const { rerender } = render(TabList, { props }); + await clickCloseOn("p"); + await rerender({ ...props, tabs: grouped.filter((tab) => tab.conversationId !== "p") }); + expect(screen.queryByRole("dialog")).toBeNull(); + await rerender(props); + expect(screen.queryByRole("dialog")).toBeNull(); + expect(onClose).not.toHaveBeenCalled(); + }); + it("Escape dismisses the dialog without closing anything", async () => { const onClose = renderClosable(); await clickCloseOn("p"); - await fireEvent.keyDown(window, { key: "Escape" }); + await fireEvent(dialog(), new Event("cancel", { cancelable: true })); expect(screen.queryByRole("dialog")).toBeNull(); expect(onClose).not.toHaveBeenCalled(); }); diff --git a/src/features/tabs/ui/CloseSubagentsConfirm.svelte b/src/features/tabs/ui/CloseSubagentsConfirm.svelte index cbbec79..7ffda6c 100644 --- a/src/features/tabs/ui/CloseSubagentsConfirm.svelte +++ b/src/features/tabs/ui/CloseSubagentsConfirm.svelte @@ -7,7 +7,8 @@ * the whole subtree — "Yes" cascades the close to every transitive * descendant (sub-sub-agents included, no further prompts); "No" closes * only the tab itself. Escape / backdrop dismisses without closing anything. - * daisyUI `modal` (controlled: `modal-open`). + * Native modal dialog with daisyUI chrome: top-layer rendering, focus + * containment, and inert background even inside the animated sidebar. */ let { tab, @@ -27,14 +28,31 @@ /** Escape / backdrop — close nothing. */ onDismiss: () => void; } = $props(); + function openModal(node: HTMLDialogElement) { + const opener = document.activeElement; + node.showModal(); + return { + destroy() { + node.close(); + if (opener instanceof HTMLElement && opener.isConnected) opener.focus(); + }, + }; + } - e.key === "Escape" && onDismiss()} /> - -