summaryrefslogtreecommitdiffhomepage
path: root/src/features/tabs/ui.test.ts
diff options
context:
space:
mode:
Diffstat (limited to 'src/features/tabs/ui.test.ts')
-rw-r--r--src/features/tabs/ui.test.ts547
1 files changed, 537 insertions, 10 deletions
diff --git a/src/features/tabs/ui.test.ts b/src/features/tabs/ui.test.ts
index ae2a021..8f775a1 100644
--- a/src/features/tabs/ui.test.ts
+++ b/src/features/tabs/ui.test.ts
@@ -1,6 +1,7 @@
-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 { gsap } from "gsap";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { Tab } from "./tabs";
import TabList from "./ui/TabList.svelte";
@@ -165,7 +166,7 @@ describe("TabList", () => {
expect(screen.getByText("7c2d")).toBeInTheDocument();
});
- it("renders each tab as a single vertical row (flex-col list, not a horizontal strip)", () => {
+ it("renders the tabs as a 2-column grid of same-size cards", () => {
render(TabList, {
props: {
tabs: sampleTabs,
@@ -176,14 +177,17 @@ describe("TabList", () => {
},
});
- // The scroll region containing the tab rows is a vertical flex column.
const tabs = screen.getAllByRole("tab");
- expect(tabs.length).toBeGreaterThan(0);
- const region = tabs[0]?.parentElement;
- expect(region).toHaveClass("flex-col");
+ const grid = tabs[0]?.parentElement;
+ expect(grid).toHaveClass("grid", "grid-cols-2");
+ // Every card has the same fixed height; none is double-width without a group.
+ for (const t of tabs) {
+ expect(t).toHaveClass("h-[4.5rem]");
+ expect(t).not.toHaveClass("col-span-2");
+ }
});
- it("fixes the tab list region at 40vh so a long set scrolls internally", () => {
+ it("fixes the tab grid region at 70vh so a long set scrolls internally", () => {
render(TabList, {
props: {
tabs: sampleTabs,
@@ -195,8 +199,8 @@ describe("TabList", () => {
});
const tabs = screen.getAllByRole("tab");
- const region = tabs[0]?.parentElement;
- expect(region).toHaveClass("h-[40vh]");
+ const region = tabs[0]?.parentElement?.parentElement;
+ expect(region).toHaveClass("h-[70vh]");
expect(region).toHaveClass("overflow-y-auto");
});
@@ -295,4 +299,527 @@ describe("TabList", () => {
// c3 has no status → no spinner at all.
expect(idleTab.querySelector(".loading")).toBeNull();
});
+
+ describe("subagent groups", () => {
+ const grouped: readonly Tab[] = [
+ { conversationId: "p", model: "m", title: "Parent", workspaceId: "w" },
+ {
+ conversationId: "c1",
+ model: "m",
+ title: "Child 1",
+ workspaceId: "w",
+ tabGroupParentId: "p",
+ },
+ {
+ conversationId: "c2",
+ model: "m",
+ title: "Child 2",
+ workspaceId: "w",
+ tabGroupParentId: "p",
+ },
+ {
+ conversationId: "g",
+ model: "m",
+ title: "Grandchild",
+ workspaceId: "w",
+ tabGroupParentId: "c1",
+ },
+ { conversationId: "solo", model: "m", title: "Solo", workspaceId: "w" },
+ ];
+
+ function renderGrouped(onSetTabGroup = vi.fn()) {
+ render(TabList, {
+ props: {
+ tabs: grouped,
+ activeConversationId: "p",
+ onSelect: vi.fn(),
+ onClose: vi.fn(),
+ onNewDraft: vi.fn(),
+ onSetTabGroup,
+ },
+ });
+ return onSetTabGroup;
+ }
+
+ const card = (id: string): HTMLElement => {
+ const el = document.querySelector<HTMLElement>(`[role="tab"][data-conversation-id="${id}"]`);
+ if (el === null) throw new Error(`card ${id} not found`);
+ return el;
+ };
+
+ it("renders a group parent double-width inside a darker group box with its subagents", () => {
+ renderGrouped();
+ const box = document.querySelector<HTMLElement>('[data-tab-group="p"]');
+ expect(box).not.toBeNull();
+ expect(box).toHaveClass("col-span-2");
+ expect(card("p")).toHaveClass("col-span-2");
+ expect(card("p")).toHaveTextContent("2 subagents");
+ expect(box?.contains(card("c1"))).toBe(true);
+ expect(box?.contains(card("c2"))).toBe(true);
+ expect(box?.contains(card("solo"))).toBe(false);
+ // Subagents are regular half-width cards.
+ expect(card("c2")).not.toHaveClass("col-span-2");
+ });
+
+ it("nests a subagent that has its own subagents in an inner box", () => {
+ renderGrouped();
+ const outer = document.querySelector<HTMLElement>('[data-tab-group="p"]');
+ const inner = document.querySelector<HTMLElement>('[data-tab-group="c1"]');
+ expect(inner).not.toBeNull();
+ expect(outer?.contains(inner)).toBe(true);
+ expect(inner?.dataset.level).toBe("2");
+ expect(card("c1")).toHaveClass("col-span-2");
+ expect(inner?.contains(card("g"))).toBe(true);
+ // Inner boxes bleed less than outer ones, so nested edges stay visible
+ // while the cards keep identical widths.
+ const bleed = (el: HTMLElement | null) => Number.parseFloat(el?.style.paddingLeft ?? "");
+ expect(bleed(inner)).toBeGreaterThan(0);
+ expect(bleed(outer)).toBeGreaterThan(bleed(inner));
+ // Equal internal padding on all four sides (an even-looking box).
+ for (const box of [outer, inner]) {
+ const s = box?.style;
+ expect([s?.paddingTop, s?.paddingRight, s?.paddingBottom]).toEqual([
+ s?.paddingLeft,
+ s?.paddingLeft,
+ s?.paddingLeft,
+ ]);
+ }
+ // Each level is tinted with its own rotating theme colour.
+ expect(outer?.dataset.tone).toBe("secondary");
+ expect(inner?.dataset.tone).toBe("success");
+ // The tint lives on each box's own backdrop layer (FLIP fades it in).
+ const tint = (box: HTMLElement | null) =>
+ box
+ ?.querySelector<HTMLElement>(":scope > [data-group-backdrop]")
+ ?.style.getPropertyValue("--group-tone");
+ expect(tint(outer)).toBe("var(--color-secondary)");
+ expect(tint(inner)).toBe("var(--color-success)");
+ expect(outer?.style.getPropertyValue("margin-inline")).toBe(`-${outer?.style.paddingLeft}`);
+ });
+
+ it("returns the group parent to a half-width card once all its subagent tabs close", async () => {
+ const props = {
+ tabs: grouped,
+ activeConversationId: "p",
+ onSelect: vi.fn(),
+ onClose: vi.fn(),
+ onNewDraft: vi.fn(),
+ onSetTabGroup: vi.fn(),
+ };
+ const { rerender } = render(TabList, { props });
+ expect(card("p")).toHaveClass("col-span-2");
+
+ // Close one subagent: still a group (1 left).
+ await rerender({ ...props, tabs: grouped.filter((t) => t.conversationId !== "c2") });
+ expect(card("p")).toHaveClass("col-span-2");
+ expect(card("p")).toHaveTextContent("1 subagent");
+
+ // Close the rest (c1 + its own subagent g): back to a plain half-width card.
+ await rerender({
+ ...props,
+ tabs: grouped.filter((t) => ["p", "solo"].includes(t.conversationId)),
+ });
+ expect(card("p")).not.toHaveClass("col-span-2");
+ expect(card("p")).not.toHaveTextContent("subagent");
+ expect(document.querySelector("[data-tab-group]")).toBeNull();
+ });
+
+ describe("FLIP animation (motion allowed)", () => {
+ /** Jump every running GSAP animation to `progress` (0–1), delays included. */
+ function seekAnimations(progress: number): void {
+ for (const t of gsap.globalTimeline.getChildren(true, true, false)) t.progress(progress);
+ }
+
+ // jsdom has no matchMedia, which the grid reads as "no motion" — stub
+ // the outermost edge so the GSAP Flip path actually runs.
+ beforeEach(() => {
+ vi.stubGlobal(
+ "matchMedia",
+ vi.fn(() => ({ matches: false }) as unknown as MediaQueryList),
+ );
+ });
+ afterEach(() => {
+ vi.unstubAllGlobals();
+ });
+
+ const props = {
+ activeConversationId: "p",
+ onSelect: vi.fn(),
+ onClose: vi.fn(),
+ onNewDraft: vi.fn(),
+ onSetTabGroup: vi.fn(),
+ };
+
+ it("leaves an inert ghost of a closed tab (and its dissolved box) that is never a tab", async () => {
+ const two = grouped.filter((t) => ["p", "c1", "solo"].includes(t.conversationId));
+ const { rerender } = render(TabList, { props: { ...props, tabs: two } });
+ // jsdom lays nothing out (every box is 0×0) — give elements a real size.
+ const rect = vi
+ .spyOn(Element.prototype, "getBoundingClientRect")
+ .mockReturnValue(new DOMRect(0, 0, 120, 72));
+ // Close c1: its card AND p's group box leave.
+ await rerender({ ...props, tabs: two.filter((t) => t.conversationId !== "c1") });
+ rect.mockRestore();
+
+ const ghosts = [...document.querySelectorAll<HTMLElement>("[data-tab-ghost]")];
+ expect(ghosts).toHaveLength(2);
+ for (const g of ghosts) {
+ expect(g).toHaveAttribute("aria-hidden", "true");
+ expect(g.querySelector("[data-conversation-id],[data-flip-id],[role]")).toBeNull();
+ }
+ // Paint order: the dissolving box's tint goes FIRST in its enclosing
+ // grid (under every card); the closed card's ghost goes on top.
+ const grid = screen.getByRole("tablist");
+ const boxGhost = grid.firstElementChild as HTMLElement;
+ expect(boxGhost).toHaveAttribute("data-tab-ghost");
+ // A clipping frame around a copy of the tint only — no card copies.
+ expect(boxGhost.childElementCount).toBe(1);
+ expect(boxGhost.firstElementChild?.childElementCount).toBe(0);
+ // Ghosts SHRINK (a clipped height), they don't fade or scale: halfway
+ // through, each is shorter than it started; at the end they're gone.
+ const full = ghosts.map((g) => Number.parseFloat(g.style.height));
+ seekAnimations(0.5);
+ ghosts.forEach((g, i) => {
+ expect(g.style.overflow).toBe("hidden");
+ expect(g.style.opacity).toBe("");
+ expect(Number.parseFloat(g.style.height)).toBeLessThan(full[i] ?? 0);
+ });
+ const cardGhost = ghosts.find((g) => g !== boxGhost);
+ expect(cardGhost?.parentElement).toBe(grid.parentElement);
+ expect(cardGhost).toHaveTextContent("Child 1");
+ // Ghosts are invisible to queries: only the real remaining tabs count.
+ expect(screen.getAllByRole("tab").map((t) => t.dataset.conversationId)).toEqual([
+ "p",
+ "solo",
+ ]);
+ expect(screen.getAllByRole("button", { name: "Close tab" })).toHaveLength(2);
+ seekAnimations(1);
+ expect(document.querySelector("[data-tab-ghost]")).toBeNull();
+ });
+
+ it("measures each element at most twice per change (no layout thrash)", async () => {
+ // Regression: GSAP's Flip plugin cost ~430ms of main-thread work per
+ // close with ~40 nested tabs. The hand-rolled FLIP reads every element's
+ // box once before the change and once after — never per tween.
+ const { rerender } = render(TabList, { props: { ...props, tabs: grouped } });
+ const elements = document.querySelectorAll("[data-flip-id]").length;
+ const spy = vi.spyOn(Element.prototype, "getBoundingClientRect");
+ try {
+ await rerender({ ...props, tabs: grouped.filter((t) => t.conversationId !== "c2") });
+ // 2 per element + 1 host read per ghost (one ghost here: c2's card).
+ expect(spy.mock.calls.length).toBeLessThanOrEqual(2 * elements + 1);
+ } finally {
+ spy.mockRestore();
+ }
+ });
+
+ it("does not ghost anything when a subagent is summoned", async () => {
+ const base = grouped.filter((t) => ["p", "solo"].includes(t.conversationId));
+ const { rerender } = render(TabList, { props: { ...props, tabs: base } });
+ await rerender({ ...props, tabs: grouped });
+ expect(document.querySelector("[data-tab-ghost]")).toBeNull();
+ expect(screen.getAllByRole("tab")).toHaveLength(grouped.length);
+ // New cards start fully clipped (0 visible height) and grow in from
+ // the top edge; once done no motion style is left behind.
+ const ids = ["c1", "c2", "g"];
+ for (const id of ids) expect(card(id).style.clipPath).toBe("inset(0% 0% 100% 0%)");
+ seekAnimations(1);
+ for (const id of ids) {
+ expect(card(id).style.clipPath).toBe("");
+ expect(card(id).style.transform).toBe("");
+ }
+ });
+ });
+
+ it("never animates when the user prefers reduced motion", async () => {
+ vi.stubGlobal(
+ "matchMedia",
+ vi.fn(() => ({ matches: true }) as unknown as MediaQueryList),
+ );
+ try {
+ const props = {
+ tabs: grouped,
+ activeConversationId: "p",
+ onSelect: vi.fn(),
+ onClose: vi.fn(),
+ onNewDraft: vi.fn(),
+ };
+ const { rerender } = render(TabList, { props });
+ await rerender({ ...props, tabs: grouped.filter((t) => t.conversationId !== "c2") });
+ expect(document.querySelector("[data-tab-ghost]")).toBeNull();
+ } finally {
+ vi.unstubAllGlobals();
+ }
+ });
+
+ it("keeps the parent wide while a nested descendant's own parent remains open", async () => {
+ const props = {
+ tabs: grouped,
+ activeConversationId: "p",
+ onSelect: vi.fn(),
+ onClose: vi.fn(),
+ onNewDraft: vi.fn(),
+ };
+ const { rerender } = render(TabList, { props });
+ // Close the grandchild only: c1 goes back to half width, p stays a group.
+ await rerender({ ...props, tabs: grouped.filter((t) => t.conversationId !== "g") });
+ expect(card("c1")).not.toHaveClass("col-span-2");
+ expect(document.querySelector('[data-tab-group="c1"]')).toBeNull();
+ expect(card("p")).toHaveClass("col-span-2");
+ });
+
+ it("trims the bottom padding under a nested box on the last row to match its side gaps", () => {
+ // p's children: c2 (card) then c1 (nested group) → c1's box is the last row.
+ const lastIsGroup: readonly Tab[] = [
+ { conversationId: "p", model: "m", title: "Parent", workspaceId: "w" },
+ { conversationId: "c2", model: "m", title: "C2", workspaceId: "w", tabGroupParentId: "p" },
+ { conversationId: "c1", model: "m", title: "C1", workspaceId: "w", tabGroupParentId: "p" },
+ { conversationId: "g", model: "m", title: "G", workspaceId: "w", tabGroupParentId: "c1" },
+ ];
+ render(TabList, {
+ props: {
+ tabs: lastIsGroup,
+ activeConversationId: "p",
+ onSelect: vi.fn(),
+ onClose: vi.fn(),
+ onNewDraft: vi.fn(),
+ },
+ });
+ const outer = document.querySelector<HTMLElement>('[data-tab-group="p"]');
+ const inner = document.querySelector<HTMLElement>('[data-tab-group="c1"]');
+ const px = (v: string | undefined) => Number.parseFloat(v ?? "");
+ const outerSide = px(outer?.style.paddingLeft);
+ const innerBleed = px(inner?.style.paddingLeft);
+ // Side gap between the two box edges = outer bleed − inner bleed; the
+ // bottom gap must equal it.
+ expect(px(outer?.style.paddingBottom)).toBe(outerSide - innerBleed);
+ expect(px(outer?.style.paddingTop)).toBe(outerSide);
+ // The inner box ends in a card → untouched, even on all sides.
+ expect(px(inner?.style.paddingBottom)).toBe(innerBleed);
+ });
+
+ it("right-click opens the menu; 'Remove from group' ungroups the tab", async () => {
+ const onSetTabGroup = renderGrouped();
+ await fireEvent.contextMenu(card("c2"), { clientX: 10, clientY: 10 });
+ const menu = screen.getByRole("menu");
+ expect(menu).toBeInTheDocument();
+ await fireEvent.click(screen.getByRole("menuitem", { name: /Remove from group/ }));
+ expect(onSetTabGroup).toHaveBeenCalledWith("c2", null);
+ expect(screen.queryByRole("menu")).toBeNull();
+ });
+
+ it("'Add to group' lists valid parents (no self/descendants/current) and moves the tab", async () => {
+ const onSetTabGroup = renderGrouped();
+ await fireEvent.contextMenu(card("c1"));
+ const candidates = [...document.querySelectorAll<HTMLElement>("[data-group-candidate]")].map(
+ (el) => el.dataset.groupCandidate,
+ );
+ expect(candidates).toEqual(["c2", "solo"]);
+ const solo = document.querySelector<HTMLElement>('[data-group-candidate="solo"]');
+ if (solo === null) throw new Error("solo candidate missing");
+ await fireEvent.click(solo);
+ expect(onSetTabGroup).toHaveBeenCalledWith("c1", "solo");
+ });
+
+ it("a group parent's menu can ungroup all of its direct subagents", async () => {
+ const onSetTabGroup = renderGrouped();
+ await fireEvent.contextMenu(card("p"));
+ expect(screen.queryByRole("menuitem", { name: /Remove from group/ })).toBeNull();
+ await fireEvent.click(screen.getByRole("menuitem", { name: /Ungroup 2 subagents/ }));
+ expect(onSetTabGroup).toHaveBeenCalledTimes(2);
+ expect(onSetTabGroup).toHaveBeenCalledWith("c1", null);
+ expect(onSetTabGroup).toHaveBeenCalledWith("c2", null);
+ });
+
+ it("Escape closes the menu without changing anything", async () => {
+ const onSetTabGroup = renderGrouped();
+ await fireEvent.contextMenu(card("solo"));
+ expect(screen.getByRole("menu")).toBeInTheDocument();
+ await fireEvent.keyDown(window, { key: "Escape" });
+ expect(screen.queryByRole("menu")).toBeNull();
+ expect(onSetTabGroup).not.toHaveBeenCalled();
+ });
+
+ it("touch-and-hold opens the menu and swallows the follow-up click", async () => {
+ vi.useFakeTimers();
+ try {
+ const onSelect = vi.fn();
+ render(TabList, {
+ props: {
+ tabs: grouped,
+ activeConversationId: "p",
+ onSelect,
+ onClose: vi.fn(),
+ onNewDraft: vi.fn(),
+ onSetTabGroup: vi.fn(),
+ },
+ });
+ await fireEvent.pointerDown(card("solo"), { pointerType: "touch", clientX: 5, clientY: 5 });
+ await vi.advanceTimersByTimeAsync(600);
+ expect(screen.getByRole("menu")).toBeInTheDocument();
+ await fireEvent.click(card("solo"));
+ expect(onSelect).not.toHaveBeenCalled();
+ } finally {
+ vi.useRealTimers();
+ }
+ });
+
+ it("a touch that moves away cancels the long-press", async () => {
+ vi.useFakeTimers();
+ try {
+ renderGrouped();
+ await fireEvent.pointerDown(card("solo"), { pointerType: "touch", clientX: 5, clientY: 5 });
+ await fireEvent.pointerMove(card("solo"), {
+ pointerType: "touch",
+ clientX: 5,
+ clientY: 60,
+ });
+ await vi.advanceTimersByTimeAsync(600);
+ expect(screen.queryByRole("menu")).toBeNull();
+ } finally {
+ 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("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(dialog(), new Event("cancel", { cancelable: true }));
+ expect(screen.queryByRole("dialog")).toBeNull();
+ expect(onClose).not.toHaveBeenCalled();
+ });
+ });
+ });
});