summaryrefslogtreecommitdiffhomepage
path: root/src/features/tabs/ui
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/features/tabs/ui
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/features/tabs/ui')
-rw-r--r--src/features/tabs/ui/CloseSubagentsConfirm.svelte57
-rw-r--r--src/features/tabs/ui/TabList.svelte43
2 files changed, 99 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..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}