summaryrefslogtreecommitdiffhomepage
path: root/src
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-10-16 15:25:40 +0900
committerAdam Malczewski <[email protected]>2026-10-16 15:25:40 +0900
commita4aa86e66dad93f40bce640545d8903f7e6bcb64 (patch)
treef02f83ffffc5a03ed34e5bd460f806b456b02bbe /src
parentf6d96289470ac888670050ff64c54bbe69b973cb (diff)
downloaddispatch-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')
-rw-r--r--src/features/tabs/index.ts1
-rw-r--r--src/features/tabs/tab-groups.test.ts29
-rw-r--r--src/features/tabs/tab-groups.ts16
-rw-r--r--src/features/tabs/ui.test.ts74
-rw-r--r--src/features/tabs/ui/CloseSubagentsConfirm.svelte57
-rw-r--r--src/features/tabs/ui/TabList.svelte43
6 files changed, 218 insertions, 2 deletions
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
@@ -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 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();
+ });
+ });
});
});
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 @@
+<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.
+ * daisyUI `modal` (controlled: `modal-open`).
+ */
+ 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();
+</script>
+
+<svelte:window onkeydown={(e) => e.key === "Escape" && onDismiss()} />
+
+<div class="modal modal-open" role="dialog" aria-label="Close subagents too?">
+ <div class="modal-box max-w-md">
+ <h3 class="text-lg font-bold">Close subagents too?</h3>
+ <div 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">
+ <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>
+ <!-- svelte-ignore a11y_click_events_have_key_events, a11y_no_static_element_interactions -->
+ <div class="modal-backdrop" onclick={onDismiss}></div>
+</div>
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 @@
<script lang="ts">
import {
buildTabGroups,
+ cascadeCloseIds,
directGroupChildren,
groupGutterPx,
groupParentCandidates,
@@ -8,6 +9,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";
@@ -72,6 +74,26 @@
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);
+
+ function requestClose(conversationId: string): void {
+ 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)}
@@ -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}
+ <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}