summaryrefslogtreecommitdiffhomepage
path: root/src/features/tabs/ui
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/features/tabs/ui
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/features/tabs/ui')
-rw-r--r--src/features/tabs/ui/CloseSubagentsConfirm.svelte83
-rw-r--r--src/features/tabs/ui/TabList.svelte55
2 files changed, 137 insertions, 1 deletions
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}