summaryrefslogtreecommitdiffhomepage
path: root/src
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-10-24 18:05:30 +0900
committerAdam Malczewski <[email protected]>2026-10-24 18:05:30 +0900
commitdaefad66bdf8dee132d701a3b43ce0d227dde241 (patch)
tree424ac806b3822247c0b835ee38ffabdf87f5016e /src
parentc8b2fb49835bde2f85d99a91b313e6a01ce1359c (diff)
parent0ba54b4455cb560cf7a82baf71b31ed129cfc29b (diff)
downloaddispatch-web-daefad66bdf8dee132d701a3b43ce0d227dde241.tar.gz
dispatch-web-daefad66bdf8dee132d701a3b43ce0d227dde241.zip
Merge branch 'feature/close-tab-cascade-subagents' into dev
Diffstat (limited to 'src')
-rw-r--r--src/app/App.test.ts115
-rw-r--r--src/features/tabs/index.ts1
-rw-r--r--src/features/tabs/tab-groups.test.ts38
-rw-r--r--src/features/tabs/tab-groups.ts16
-rw-r--r--src/features/tabs/ui.test.ts143
-rw-r--r--src/features/tabs/ui/CloseSubagentsConfirm.svelte83
-rw-r--r--src/features/tabs/ui/TabList.svelte55
7 files changed, 448 insertions, 3 deletions
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<typeof createAppStore>): 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<HTMLElement>('[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/index.ts b/src/features/tabs/index.ts
index 86ed83f..145a3d9 100644
--- a/src/features/tabs/index.ts
+++ b/src/features/tabs/index.ts
@@ -1,6 +1,7 @@
export type { GroupTone, TabGroupForest, TabGroupNode } from "./tab-groups";
export {
buildTabGroups,
+ cascadeCloseIds,
directGroupChildren,
GROUP_TONES,
groupBleedPx,
diff --git a/src/features/tabs/tab-groups.test.ts b/src/features/tabs/tab-groups.test.ts
index a09dbc8..46b04e7 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,
@@ -104,6 +105,43 @@ 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("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"]);
+ });
+});
+
describe("groupTone", () => {
it("rotates three colours by level, starting at the outermost box", () => {
expect([1, 2, 3, 4, 5, 6, 7].map(groupTone)).toEqual([
diff --git a/src/features/tabs/tab-groups.ts b/src/features/tabs/tab-groups.ts
index c7406d8..ff3ddd4 100644
--- a/src/features/tabs/tab-groups.ts
+++ b/src/features/tabs/tab-groups.ts
@@ -104,6 +104,22 @@ export function groupDescendants(tabs: readonly Tab[], conversationId: string):
}
/**
+ * 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),
* and its current visible group parent (already there). Tab order preserved.
diff --git a/src/features/tabs/ui.test.ts b/src/features/tabs/ui.test.ts
index c8e07ad..8f775a1 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 { gsap } from "gsap";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
@@ -680,5 +680,146 @@ 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("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();
+ });
+ });
});
});
diff --git a/src/features/tabs/ui/CloseSubagentsConfirm.svelte b/src/features/tabs/ui/CloseSubagentsConfirm.svelte
new file mode 100644
index 0000000..7ffda6c
--- /dev/null
+++ b/src/features/tabs/ui/CloseSubagentsConfirm.svelte
@@ -0,0 +1,83 @@
+<script lang="ts">
+ import type { Tab } from "../tabs";
+
+ /**
+ * The close-subagents confirm dialog: shown when the user closes a tab that
+ * still has subagent tabs in its (visible) subagent group. ONE dialog for
+ * 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.
+ * Native modal dialog with daisyUI chrome: top-layer rendering, focus
+ * containment, and inert background even inside the animated sidebar.
+ */
+ let {
+ tab,
+ subagentCount,
+ onKeep,
+ onCloseAll,
+ onDismiss,
+ }: {
+ /** The tab being closed (drives the title shown in the dialog). */
+ tab: Tab;
+ /** Number of tabs in the tab's subagent group (transitive). */
+ subagentCount: number;
+ /** "No" — close only this tab, keep the subagents open. */
+ onKeep: () => void;
+ /** "Yes" — close this tab AND cascade to all its subagents. */
+ onCloseAll: () => void;
+ /** 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();
+ },
+ };
+ }
+</script>
+
+<dialog
+ use:openModal
+ class="modal"
+ aria-label="Close subagents too?"
+ aria-describedby="close-subagents-description"
+ oncancel={(event) => {
+ event.preventDefault();
+ onDismiss();
+ }}
+>
+ <div class="modal-box max-w-md">
+ <h3 class="text-lg font-bold">Close subagents too?</h3>
+ <div id="close-subagents-description" class="flex flex-col gap-1 py-3">
+ <p class="text-sm">
+ "{tab.title}" has {subagentCount}
+ {subagentCount === 1 ? "subagent tab" : "subagent tabs"} (including nested ones).
+ </p>
+ <p class="text-sm">
+ Close them as well? Their own subagents are closed too, and any in-flight turns are
+ stopped.
+ </p>
+ </div>
+ <div class="modal-action flex-wrap">
+ <button type="button" class="btn btn-ghost btn-sm" onclick={onDismiss}>Cancel</button>
+ <button type="button" class="btn btn-sm" onclick={onKeep}>No, keep them open</button>
+ <button type="button" class="btn btn-warning btn-sm" onclick={onCloseAll}>
+ Yes, close all {subagentCount + 1}
+ </button>
+ </div>
+ </div>
+ <form
+ method="dialog"
+ class="modal-backdrop"
+ onsubmit={(event) => {
+ event.preventDefault();
+ onDismiss();
+ }}
+ >
+ <button type="submit" aria-label="Dismiss close confirmation">close</button>
+ </form>
+</dialog>
diff --git a/src/features/tabs/ui/TabList.svelte b/src/features/tabs/ui/TabList.svelte
index dfefc9a..8b388f0 100644
--- a/src/features/tabs/ui/TabList.svelte
+++ b/src/features/tabs/ui/TabList.svelte
@@ -3,6 +3,7 @@
import { forestFlipIds } from "../tab-flip";
import {
buildTabGroups,
+ cascadeCloseIds,
directGroupChildren,
groupGutterPx,
groupParentCandidates,
@@ -10,6 +11,7 @@
} from "../tab-groups";
import type { Tab } from "../tabs";
import { shortHandle } from "../tabs";
+ import CloseSubagentsConfirm from "./CloseSubagentsConfirm.svelte";
import TabCard from "./TabCard.svelte";
import TabGroupBox from "./TabGroupBox.svelte";
import TabMenu from "./TabMenu.svelte";
@@ -89,6 +91,38 @@
if (onSetTabGroup === undefined) return;
menu = { conversationId, x, y };
}
+
+ // Close-with-confirm: a tab whose (visible) subagent group is non-empty
+ // gets ONE confirm dialog covering its whole subtree (never one per level).
+ // The dialog's ids are snapshotted at open time; a tab the backend closed
+ // in the meantime is a no-op for `onClose`. A plain tab closes directly.
+ let closeConfirm = $state<{ tab: Tab; cascadeIds: readonly string[] } | null>(null);
+
+ // A remote close or workspace switch must not leave an actionable dialog
+ // for a tab that is no longer in this list.
+ $effect(() => {
+ if (
+ closeConfirm !== null &&
+ !tabs.some((tab) => tab.conversationId === closeConfirm?.tab.conversationId)
+ ) {
+ closeConfirm = null;
+ }
+ });
+
+ function requestClose(conversationId: string): void {
+ menu = null;
+ const cascadeIds = cascadeCloseIds(tabs, conversationId);
+ if (cascadeIds.length <= 1) {
+ onClose(conversationId);
+ return;
+ }
+ const tab = tabs.find((t) => t.conversationId === conversationId);
+ if (tab === undefined) {
+ onClose(conversationId);
+ return;
+ }
+ closeConfirm = { tab, cascadeIds };
+ }
</script>
{#snippet card(tab: Tab, wide: boolean, subagentCount: number)}
@@ -100,7 +134,7 @@
{wide}
{subagentCount}
{onSelect}
- {onClose}
+ onClose={requestClose}
{onRename}
onMenu={openMenu}
/>
@@ -165,3 +199,22 @@
onDismiss={() => (menu = null)}
/>
{/if}
+
+{#if closeConfirm !== null}
+ {@const target = closeConfirm.tab}
+ {@const cascadeIds = [...closeConfirm.cascadeIds]}
+ {@const closeOwner = onClose}
+ <CloseSubagentsConfirm
+ tab={target}
+ subagentCount={cascadeIds.length - 1}
+ onKeep={() => {
+ closeOwner(target.conversationId);
+ closeConfirm = null;
+ }}
+ onCloseAll={() => {
+ for (const id of cascadeIds) closeOwner(id);
+ closeConfirm = null;
+ }}
+ onDismiss={() => (closeConfirm = null)}
+ />
+{/if}