summaryrefslogtreecommitdiffhomepage
path: root/src/features/tabs/ui
diff options
context:
space:
mode:
Diffstat (limited to 'src/features/tabs/ui')
-rw-r--r--src/features/tabs/ui/CloseSubagentsConfirm.svelte83
-rw-r--r--src/features/tabs/ui/TabCard.svelte234
-rw-r--r--src/features/tabs/ui/TabGroupBox.svelte90
-rw-r--r--src/features/tabs/ui/TabList.svelte375
-rw-r--r--src/features/tabs/ui/TabMenu.svelte145
-rw-r--r--src/features/tabs/ui/tab-flip.ts374
6 files changed, 1132 insertions, 169 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/TabCard.svelte b/src/features/tabs/ui/TabCard.svelte
new file mode 100644
index 0000000..ae891c0
--- /dev/null
+++ b/src/features/tabs/ui/TabCard.svelte
@@ -0,0 +1,234 @@
+<script lang="ts">
+ import { cardFlipId } from "../tab-flip";
+ import type { Tab } from "../tabs";
+
+ /**
+ * One tab in the tab grid — a fixed-height card. Every card has IDENTICAL
+ * dimensions; a group parent (`wide`) simply spans both grid columns.
+ * Right-click (or touch-and-hold) asks the owner to open the tab menu.
+ */
+ let {
+ tab,
+ handle,
+ active,
+ status,
+ wide = false,
+ subagentCount = 0,
+ onSelect,
+ onClose,
+ onRename,
+ onMenu,
+ }: {
+ tab: Tab;
+ /** Short-handle (git-style unique prefix) shown in the id badge. */
+ handle: string;
+ active: boolean;
+ /** Lifecycle status (drives the spinner), when known. */
+ status: string | undefined;
+ /** Group parent: spans both grid columns. */
+ wide?: boolean;
+ /** Number of direct subagents (shown on a group parent). */
+ subagentCount?: number;
+ onSelect: (conversationId: string) => void;
+ onClose: (conversationId: string) => void;
+ onRename?: ((conversationId: string, title: string) => void) | undefined;
+ /** Open the tab menu at viewport coordinates. */
+ onMenu: (conversationId: string, x: number, y: number) => void;
+ } = $props();
+
+ // Inline rename: double-click the title to edit, Enter/blur to save.
+ let editing = $state(false);
+ let editValue = $state("");
+ let editEl = $state<HTMLInputElement>();
+
+ function startRename(): void {
+ if (onRename === undefined) return;
+ editing = true;
+ editValue = tab.title;
+ queueMicrotask(() => editEl?.focus());
+ }
+
+ function commitRename(): void {
+ if (!editing) return;
+ const trimmed = editValue.trim();
+ if (trimmed.length > 0 && onRename !== undefined) onRename(tab.conversationId, trimmed);
+ editing = false;
+ }
+
+ // Click-to-copy the FULL conversation id; the badge text is selected as the
+ // feedback (and as a manual-copy fallback when the clipboard is unavailable).
+ async function copyId(el: HTMLElement): Promise<void> {
+ const range = document.createRange();
+ range.selectNodeContents(el);
+ const selection = window.getSelection();
+ selection?.removeAllRanges();
+ selection?.addRange(range);
+ const clipboard = navigator.clipboard;
+ if (clipboard === undefined) return;
+ try {
+ await clipboard.writeText(tab.conversationId);
+ } catch {
+ // Selection already lets the user copy manually.
+ }
+ }
+
+ // Touch-and-hold → menu (iOS Safari never fires `contextmenu`; Android does,
+ // and opening twice at the same spot is idempotent).
+ const LONG_PRESS_MS = 500;
+ const MOVE_TOLERANCE_PX = 10;
+ let pressTimer: ReturnType<typeof setTimeout> | null = null;
+ let pressX = 0;
+ let pressY = 0;
+ let suppressClick = false;
+
+ function cancelPress(): void {
+ if (pressTimer !== null) clearTimeout(pressTimer);
+ pressTimer = null;
+ }
+
+ function onPointerDown(e: PointerEvent): void {
+ if (e.pointerType !== "touch") return;
+ cancelPress();
+ pressX = e.clientX;
+ pressY = e.clientY;
+ pressTimer = setTimeout(() => {
+ pressTimer = null;
+ suppressClick = true;
+ onMenu(tab.conversationId, pressX, pressY);
+ }, LONG_PRESS_MS);
+ }
+
+ function onPointerMove(e: PointerEvent): void {
+ if (pressTimer === null) return;
+ if (Math.hypot(e.clientX - pressX, e.clientY - pressY) > MOVE_TOLERANCE_PX) cancelPress();
+ }
+
+ $effect(() => cancelPress);
+</script>
+
+<div
+ class="tab-card group/card relative flex h-[4.5rem] min-w-0 cursor-pointer flex-col justify-between rounded-md border px-2 py-1.5 text-sm transition-colors {active
+ ? 'border-primary bg-primary/15'
+ : 'border-base-content/10 bg-base-300 hover:border-base-content/25'}"
+ class:col-span-2={wide}
+ role="tab"
+ tabindex="0"
+ aria-selected={active}
+ title={tab.title}
+ data-conversation-id={tab.conversationId}
+ data-flip-id={cardFlipId(tab.conversationId)}
+ data-wide={wide ? "true" : undefined}
+ onclick={() => {
+ if (suppressClick) {
+ suppressClick = false;
+ return;
+ }
+ onSelect(tab.conversationId);
+ }}
+ onkeydown={(e) => {
+ if (e.target !== e.currentTarget) return;
+ if (e.key === "Enter") onSelect(tab.conversationId);
+ else if (e.key === "ContextMenu" || (e.shiftKey && e.key === "F10")) {
+ e.preventDefault();
+ const r = e.currentTarget.getBoundingClientRect();
+ onMenu(tab.conversationId, r.left + 8, r.bottom);
+ }
+ }}
+ oncontextmenu={(e) => {
+ e.preventDefault();
+ cancelPress();
+ onMenu(tab.conversationId, e.clientX, e.clientY);
+ }}
+ onpointerdown={onPointerDown}
+ onpointermove={onPointerMove}
+ onpointerup={cancelPress}
+ onpointercancel={cancelPress}
+ onpointerleave={cancelPress}
+>
+ <div class="flex min-w-0 items-center gap-1">
+ <button
+ type="button"
+ class="shrink-0 select-text rounded bg-base-100 px-1 py-0.5 font-mono text-[10px] leading-none text-base-content/60 transition-colors hover:bg-primary hover:text-primary-content"
+ data-copy-id={tab.conversationId}
+ title="Click to copy conversation id"
+ aria-label={`Copy conversation id ${tab.conversationId}`}
+ onclick={(e) => {
+ e.stopPropagation();
+ void copyId(e.currentTarget);
+ }}
+ >
+ {handle}
+ </button>
+ {#if wide && subagentCount > 0}
+ <span
+ class="shrink-0 truncate rounded bg-base-100 px-1 py-0.5 text-[10px] leading-none text-base-content/60"
+ data-subagent-count={subagentCount}
+ >
+ {subagentCount}
+ {subagentCount === 1 ? "subagent" : "subagents"}
+ </span>
+ {/if}
+ <span class="flex-1"></span>
+ {#if status === "queued"}
+ <!-- Waiting for a concurrency slot — a ring (vs the dots of `active`). -->
+ <span
+ class="loading loading-ring loading-xs shrink-0 text-primary"
+ aria-label="Queued"
+ title="Waiting for a concurrency slot"
+ ></span>
+ {:else if status === "active"}
+ <span class="loading loading-dots loading-xs shrink-0 text-primary"></span>
+ {/if}
+ <button
+ type="button"
+ class="btn btn-ghost btn-xs -mr-1 h-5 min-h-5 w-5 shrink-0 p-0 text-base-content/60"
+ aria-label="Close tab"
+ onclick={(e) => {
+ e.stopPropagation();
+ onClose(tab.conversationId);
+ }}
+ >
+ &times;
+ </button>
+ </div>
+ {#if editing}
+ <input
+ bind:this={editEl}
+ bind:value={editValue}
+ class="w-full min-w-0 rounded bg-base-100 px-1 py-0.5 text-left text-xs outline outline-1 outline-primary"
+ onclick={(e) => e.stopPropagation()}
+ onkeydown={(e) => {
+ if (e.key === "Enter") {
+ e.preventDefault();
+ commitRename();
+ } else if (e.key === "Escape") {
+ e.preventDefault();
+ editing = false;
+ }
+ }}
+ onblur={commitRename}
+ />
+ {:else}
+ <span
+ class="line-clamp-2 min-w-0 break-words text-left text-xs leading-tight"
+ role="button"
+ tabindex="-1"
+ title={tab.title}
+ ondblclick={(e) => {
+ e.stopPropagation();
+ startRename();
+ }}
+ >
+ {tab.title}
+ </span>
+ {/if}
+</div>
+
+<style>
+ /* Touch-and-hold opens the tab menu — suppress the native callout/selection. */
+ .tab-card {
+ -webkit-touch-callout: none;
+ -webkit-user-select: none;
+ user-select: none;
+ }
+</style>
diff --git a/src/features/tabs/ui/TabGroupBox.svelte b/src/features/tabs/ui/TabGroupBox.svelte
new file mode 100644
index 0000000..5b644e0
--- /dev/null
+++ b/src/features/tabs/ui/TabGroupBox.svelte
@@ -0,0 +1,90 @@
+<script lang="ts">
+ import type { Snippet } from "svelte";
+ import type { TabGroupNode } from "../tab-groups";
+ import { groupFlipId } from "../tab-flip";
+ import { groupBleedPx, groupTone, lastRowIsGroup } from "../tab-groups";
+ import type { Tab } from "../tabs";
+ import TabGroupBox from "./TabGroupBox.svelte";
+
+ /**
+ * A subagent group: a tinted box spanning both grid columns that wraps the
+ * group parent's (double-width) card + its subagents' cards. Recursive —
+ * a subagent with its own subagents renders a nested box. The box BLEEDS
+ * outward (negative margin + equal padding, `groupBleedPx`) so the cards
+ * inside stay on the same two columns as top-level cards: every card keeps
+ * identical dimensions at every depth. The padding is EQUAL on all four
+ * sides (= the bleed) so the box looks even — except below a nested box on
+ * the last row, where it is trimmed to match that box's side gaps. Each
+ * box is tinted with one of three theme colours ROTATING by level
+ * (`groupTone`), painted OPAQUE (mixed into the sidebar well colour) so a
+ * nested box never blends with the box around it, plus a border in the same
+ * colour so every box edge reads at a glance. The tint lives on its own
+ * BACKDROP layer so the FLIP animation (`tab-flip.ts`) can fade a new
+ * group's tint in while its cards fly into place.
+ */
+ let {
+ node,
+ level,
+ maxDepth,
+ card,
+ }: {
+ node: TabGroupNode;
+ /** Nesting level of THIS box (1 = outermost). */
+ level: number;
+ maxDepth: number;
+ /** Renders one card: (tab, wide, subagentCount). */
+ card: Snippet<[Tab, boolean, number]>;
+ } = $props();
+
+ const bleed = $derived(groupBleedPx(level, maxDepth));
+ const tone = $derived(groupTone(level));
+ // Even spacing: when the last row is a nested box, the gap below it would
+ // be this box's full padding while its side gaps are only the DIFFERENCE
+ // in bleed (the nested box bleeds outward too) — trim the bottom to match.
+ const padBottom = $derived(
+ lastRowIsGroup(node.children) ? bleed - groupBleedPx(level + 1, maxDepth) : bleed,
+ );
+</script>
+
+<div
+ class="relative col-span-2"
+ style:margin-inline="-{bleed}px"
+ style:padding="{bleed}px {bleed}px {padBottom}px"
+ role="group"
+ aria-label={`Subagents of ${node.tab.title}`}
+ data-tab-group={node.tab.conversationId}
+ data-level={level}
+ data-tone={tone}
+ data-flip-id={groupFlipId(node.tab.conversationId)}
+>
+ <div
+ class="tab-group-backdrop pointer-events-none absolute inset-0 rounded-lg"
+ style:--group-tone="var(--color-{tone})"
+ data-group-backdrop
+ aria-hidden="true"
+ ></div>
+ <div class="relative grid grid-flow-row-dense grid-cols-2 gap-1.5">
+ {@render card(node.tab, true, node.children.length)}
+ {#each node.children as child (child.tab.conversationId)}
+ {#if child.children.length > 0}
+ <TabGroupBox node={child} level={level + 1} {maxDepth} {card} />
+ {:else}
+ {@render card(child.tab, false, 0)}
+ {/if}
+ {/each}
+ </div>
+</div>
+
+<style>
+ /* Opaque tint: the tone mixed into the sidebar's `.inset-well` colour (the
+ surface the tab grid sits on), so nested boxes replace — never compound
+ with — the colour around them. */
+ .tab-group-backdrop {
+ background-color: color-mix(
+ in oklab,
+ var(--group-tone) 16%,
+ var(--inset-well-bg, var(--color-base-200))
+ );
+ box-shadow: inset 0 0 0 1px color-mix(in oklab, var(--group-tone) 55%, transparent);
+ }
+</style>
diff --git a/src/features/tabs/ui/TabList.svelte b/src/features/tabs/ui/TabList.svelte
index 6d85065..8b388f0 100644
--- a/src/features/tabs/ui/TabList.svelte
+++ b/src/features/tabs/ui/TabList.svelte
@@ -1,183 +1,220 @@
<script lang="ts">
- import type { Tab } from "../tabs";
- import { shortHandle } from "../tabs";
+ import { untrack } from "svelte";
+ import { forestFlipIds } from "../tab-flip";
+ import {
+ buildTabGroups,
+ cascadeCloseIds,
+ directGroupChildren,
+ groupGutterPx,
+ groupParentCandidates,
+ visibleGroupParents,
+ } 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";
+ import { createTabFlip } from "./tab-flip";
- let {
- tabs,
- activeConversationId,
- statusFor,
- onSelect,
- onClose,
- onNewDraft,
- onRename,
- }: {
- tabs: readonly Tab[];
- activeConversationId: string | null;
- /** Returns the conversation's lifecycle status, or undefined when unknown. */
- statusFor?: (conversationId: string) => string | undefined;
- onSelect: (conversationId: string) => void;
- onClose: (conversationId: string) => void;
- onNewDraft: () => void;
- onRename?: (conversationId: string, title: string) => void;
- } = $props();
+ let {
+ tabs,
+ activeConversationId,
+ statusFor,
+ onSelect,
+ onClose,
+ onNewDraft,
+ onRename,
+ onSetTabGroup,
+ }: {
+ tabs: readonly Tab[];
+ activeConversationId: string | null;
+ /** Returns the conversation's lifecycle status, or undefined when unknown. */
+ statusFor?: (conversationId: string) => string | undefined;
+ onSelect: (conversationId: string) => void;
+ onClose: (conversationId: string) => void;
+ onNewDraft: () => void;
+ onRename?: (conversationId: string, title: string) => void;
+ /**
+ * Move a tab into a subagent group (parent id) or out of it (null). Purely
+ * presentational grouping. When absent, the tab menu is disabled.
+ */
+ onSetTabGroup?: (conversationId: string, parentId: string | null) => void;
+ } = $props();
- // Git-style short handle (shortest unique prefix) per open tab — the visible
- // "tab ID". Derived from the set of open conversation ids; pure helper.
- const handles = $derived.by(() => {
- const ids = tabs.map((t) => t.conversationId);
- const map = new Map<string, string>();
- for (const id of ids) map.set(id, shortHandle(id, ids));
- return map;
- });
+ // Git-style short handle (shortest unique prefix) per open tab — the visible
+ // "tab ID". Derived from the set of open conversation ids; pure helper.
+ const handles = $derived.by(() => {
+ const ids = tabs.map((t) => t.conversationId);
+ const map = new Map<string, string>();
+ for (const id of ids) map.set(id, shortHandle(id, ids));
+ return map;
+ });
+ const handleFor = (id: string): string => handles.get(id) ?? id;
- // Inline rename: double-click a tab's title to edit, Enter/blur to save.
- let editingId = $state<string | null>(null);
- let editValue = $state("");
- let editEl = $state<HTMLInputElement>();
+ // The subagent-group forest (pure) + the gutter the outermost box bleeds into.
+ const forest = $derived(buildTabGroups(tabs));
+ const gutter = $derived(groupGutterPx(forest.maxDepth));
- function startRename(tab: Tab): void {
- if (onRename === undefined) return;
- editingId = tab.conversationId;
- editValue = tab.title;
- // Focus the input after it renders.
- queueMicrotask(() => editEl?.focus());
- }
+ // FLIP: a summoned subagent / closed tab / regroup animates (GSAP Flip).
+ // Capture positions (+ ghost leaving elements) BEFORE the DOM updates, play
+ // AFTER. Keyed on `forest` only; the DOM work itself is untracked.
+ let scrollerEl = $state<HTMLDivElement | undefined>();
+ const flip = createTabFlip(() => scrollerEl);
+ $effect.pre(() => {
+ const next = forestFlipIds(forest);
+ untrack(() => flip.capture(next));
+ });
+ $effect(() => {
+ void forest;
+ untrack(() => flip.play());
+ });
- function commitRename(): void {
- const id = editingId;
- if (id !== null && onRename !== undefined) {
- const trimmed = editValue.trim();
- if (trimmed.length > 0) onRename(id, trimmed);
- }
- editingId = null;
- }
+ // Tab menu (right-click / touch-and-hold). Local UI state owned here.
+ let menu = $state<{ conversationId: string; x: number; y: number } | null>(null);
+ const menuTab = $derived(
+ menu === null ? null : (tabs.find((t) => t.conversationId === menu?.conversationId) ?? null),
+ );
+ const menuGroupParent = $derived.by(() => {
+ if (menuTab === null) return null;
+ const pid = visibleGroupParents(tabs).get(menuTab.conversationId) ?? null;
+ return pid === null ? null : (tabs.find((t) => t.conversationId === pid) ?? null);
+ });
+ const menuChildren = $derived(
+ menuTab === null ? [] : directGroupChildren(tabs, menuTab.conversationId),
+ );
+ const menuCandidates = $derived(
+ menuTab === null ? [] : groupParentCandidates(tabs, menuTab.conversationId),
+ );
- function cancelRename(): void {
- editingId = null;
- }
+ function openMenu(conversationId: string, x: number, y: number): void {
+ if (onSetTabGroup === undefined) return;
+ menu = { conversationId, x, y };
+ }
- // Click-to-copy the agent (conversation) id: clicking the ID badge copies the
- // FULL conversationId to the clipboard (the stable, useful id — the badge
- // only shows a short prefix) and highlights the badge text as feedback. The
- // highlight (text selection) is the indicator — no text is swapped, so the
- // badge keeps a stable width. If the clipboard write fails, the selection is
- // already in place so the user can Ctrl+C the text manually.
- async function copyId(conversationId: string, el: HTMLElement): Promise<void> {
- selectText(el);
- const clipboard = navigator.clipboard;
- if (clipboard === undefined) return;
- try {
- await clipboard.writeText(conversationId);
- } catch {
- // Selection already lets the user copy manually.
- }
- }
+ // 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 selectText(el: HTMLElement): void {
- const range = document.createRange();
- range.selectNodeContents(el);
- const selection = window.getSelection();
- selection?.removeAllRanges();
- selection?.addRange(range);
- }
+ // 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)}
+ <TabCard
+ {tab}
+ handle={handleFor(tab.conversationId)}
+ active={tab.conversationId === activeConversationId}
+ status={statusFor?.(tab.conversationId)}
+ {wide}
+ {subagentCount}
+ {onSelect}
+ onClose={requestClose}
+ {onRename}
+ onMenu={openMenu}
+ />
+{/snippet}
+
<div class="flex flex-col gap-2">
- <!-- Single-column vertical tab list. Fixed at 40% of the viewport height so a
- long tab set scrolls inside this region instead of growing the whole sidebar. -->
- <div class="flex h-[40vh] flex-col gap-1 overflow-y-auto pr-1">
- {#each tabs as tab (tab.conversationId)}
- <div
- class="flex items-center gap-1.5 rounded px-2 py-1.5 text-sm hover:bg-base-300"
- class:bg-base-300={tab.conversationId === activeConversationId}
- role="tab"
- tabindex="0"
- aria-selected={tab.conversationId === activeConversationId}
- title={tab.title}
- onclick={() => onSelect(tab.conversationId)}
- onkeydown={(e) => {
- if (e.key === "Enter") onSelect(tab.conversationId);
- }}
- >
- <button
- type="button"
- class="shrink-0 rounded bg-base-300 px-1 py-0.5 font-mono text-[10px] leading-none text-base-content/60 transition-colors hover:bg-primary hover:text-primary-content"
- data-copy-id={tab.conversationId}
- title="Click to copy conversation id"
- aria-label={`Copy conversation id ${tab.conversationId}`}
- onclick={(e) => {
- e.stopPropagation();
- void copyId(tab.conversationId, e.currentTarget);
- }}
- >
- {handles.get(tab.conversationId) ?? tab.conversationId}
- </button>
- {#if editingId === tab.conversationId}
- <input
- bind:this={editEl}
- bind:value={editValue}
- class="min-w-0 flex-1 rounded bg-base-100 px-1 py-0.5 text-left text-sm outline outline-1 outline-primary"
- onclick={(e) => e.stopPropagation()}
- onkeydown={(e) => {
- if (e.key === "Enter") {
- e.preventDefault();
- commitRename();
- } else if (e.key === "Escape") {
- e.preventDefault();
- cancelRename();
- }
- }}
- onblur={commitRename}
- />
- {:else}
- <span
- class="min-w-0 flex-1 cursor-pointer truncate text-left"
- role="button"
- tabindex="-1"
- title={tab.title}
- ondblclick={(e) => {
- e.stopPropagation();
- startRename(tab);
- }}
- >
- {tab.title}
- </span>
- {/if}
- {#if statusFor?.(tab.conversationId) === "queued"}
- <!-- Waiting for a concurrency slot — a ring (vs the dots of `active`). -->
- <span
- class="loading loading-ring loading-xs shrink-0 text-primary"
- aria-label="Queued"
- title="Waiting for a concurrency slot"
- ></span>
- {:else if statusFor?.(tab.conversationId) === "active"}
- <span class="loading loading-dots loading-xs shrink-0 text-primary"></span>
- {/if}
- <button
- class="btn btn-ghost btn-xs shrink-0"
- aria-label="Close tab"
- onclick={(e) => {
- e.stopPropagation();
- onClose(tab.conversationId);
- }}
- >
- &times;
- </button>
- </div>
- {/each}
- </div>
+ <!-- 2-column grid of same-size tab cards, fixed at 70% of the viewport height
+ so a long tab set scrolls inside this region instead of growing the
+ sidebar. Subagent groups span both columns; `dense` lets a later card
+ fill the half-row a group would otherwise leave empty. -->
+ <div
+ bind:this={scrollerEl}
+ class="relative h-[70vh] overflow-y-auto py-0.5"
+ style:padding-inline="{gutter}px"
+ data-tab-grid
+ onscroll={() => (menu = null)}
+ >
+ <div class="relative grid grid-flow-row-dense grid-cols-2 gap-1.5" role="tablist">
+ {#each forest.roots as node (node.tab.conversationId)}
+ {#if node.children.length > 0}
+ <TabGroupBox {node} level={1} maxDepth={forest.maxDepth} {card} />
+ {:else}
+ {@render card(node.tab, false, 0)}
+ {/if}
+ {/each}
+ </div>
+ </div>
- <button
- type="button"
- class="btn btn-ghost btn-sm w-full border border-base-300"
- class:btn-primary={activeConversationId === null}
- aria-label="New chat"
- onclick={() => onNewDraft()}
- >
- {#if activeConversationId === null}
- New Chat
- {:else}
- + New chat
- {/if}
- </button>
+ <button
+ type="button"
+ class="btn btn-ghost btn-sm w-full border border-base-300"
+ class:btn-primary={activeConversationId === null}
+ aria-label="New chat"
+ onclick={() => onNewDraft()}
+ >
+ {#if activeConversationId === null}
+ New Chat
+ {:else}
+ + New chat
+ {/if}
+ </button>
</div>
+
+{#if menu !== null && menuTab !== null && onSetTabGroup !== undefined}
+ {@const setGroup = onSetTabGroup}
+ {@const target = menuTab.conversationId}
+ {@const children = [...menuChildren]}
+ <TabMenu
+ tab={menuTab}
+ handle={handleFor(target)}
+ x={menu.x}
+ y={menu.y}
+ groupParent={menuGroupParent}
+ childCount={children.length}
+ candidates={menuCandidates}
+ {handleFor}
+ onAddToGroup={(parentId) => setGroup(target, parentId)}
+ onRemoveFromGroup={() => setGroup(target, null)}
+ onUngroupChildren={() => {
+ for (const id of children) setGroup(id, null);
+ }}
+ 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}
diff --git a/src/features/tabs/ui/TabMenu.svelte b/src/features/tabs/ui/TabMenu.svelte
new file mode 100644
index 0000000..513463f
--- /dev/null
+++ b/src/features/tabs/ui/TabMenu.svelte
@@ -0,0 +1,145 @@
+<script lang="ts">
+ import type { Tab } from "../tabs";
+
+ /**
+ * The tab context menu (right-click / touch-and-hold on a tab card): add the
+ * tab to a subagent group, remove it from its group, or release its own
+ * subagents. Purely presentational grouping. Fixed-positioned at the
+ * pointer, clamped into the viewport; closes on Escape / outside press /
+ * scroll / resize.
+ */
+ let {
+ tab,
+ handle,
+ x,
+ y,
+ groupParent,
+ childCount,
+ candidates,
+ handleFor,
+ onAddToGroup,
+ onRemoveFromGroup,
+ onUngroupChildren,
+ onDismiss,
+ }: {
+ tab: Tab;
+ handle: string;
+ /** Requested viewport position (pointer). */
+ x: number;
+ y: number;
+ /** The tab's current visible group parent, or null when top-level. */
+ groupParent: Tab | null;
+ /** Number of tabs directly grouped under this tab. */
+ childCount: number;
+ /** Tabs this tab may be moved under. */
+ candidates: readonly Tab[];
+ handleFor: (conversationId: string) => string;
+ onAddToGroup: (parentId: string) => void;
+ onRemoveFromGroup: () => void;
+ onUngroupChildren: () => void;
+ onDismiss: () => void;
+ } = $props();
+
+ let menuEl = $state<HTMLDivElement>();
+ let width = $state(0);
+ let height = $state(0);
+ const MARGIN = 8;
+
+ const left = $derived(
+ Math.max(MARGIN, Math.min(x, (globalThis.innerWidth ?? 0) - width - MARGIN)),
+ );
+ const top = $derived(
+ Math.max(MARGIN, Math.min(y, (globalThis.innerHeight ?? 0) - height - MARGIN)),
+ );
+
+ $effect(() => {
+ menuEl?.focus();
+ });
+
+ function act(fn: () => void): void {
+ fn();
+ onDismiss();
+ }
+</script>
+
+<svelte:window
+ onkeydown={(e) => {
+ if (e.key === "Escape") onDismiss();
+ }}
+ onpointerdown={(e) => {
+ if (menuEl !== undefined && !menuEl.contains(e.target as Node)) onDismiss();
+ }}
+ onresize={onDismiss}
+/>
+
+<div
+ bind:this={menuEl}
+ bind:clientWidth={width}
+ bind:clientHeight={height}
+ class="fixed z-50 flex w-64 max-w-[calc(100vw-1rem)] flex-col rounded-lg border border-base-content/15 bg-base-100 p-1 text-sm shadow-xl outline-none"
+ style:left="{left}px"
+ style:top="{top}px"
+ role="menu"
+ tabindex="-1"
+ aria-label={`Tab menu for ${tab.title}`}
+ oncontextmenu={(e) => e.preventDefault()}
+>
+ <div class="flex min-w-0 items-center gap-1.5 px-2 py-1 text-xs text-base-content/60">
+ <span class="shrink-0 rounded bg-base-300 px-1 py-0.5 font-mono text-[10px] leading-none">
+ {handle}
+ </span>
+ <span class="truncate">{tab.title}</span>
+ </div>
+
+ {#if groupParent !== null}
+ <button
+ type="button"
+ role="menuitem"
+ class="rounded px-2 py-1.5 text-left hover:bg-base-300"
+ onclick={() => act(onRemoveFromGroup)}
+ >
+ Remove from group
+ <span class="block truncate text-xs text-base-content/50">
+ Currently under {handleFor(groupParent.conversationId)} · {groupParent.title}
+ </span>
+ </button>
+ {/if}
+
+ {#if childCount > 0}
+ <button
+ type="button"
+ role="menuitem"
+ class="rounded px-2 py-1.5 text-left hover:bg-base-300"
+ onclick={() => act(onUngroupChildren)}
+ >
+ Ungroup {childCount}
+ {childCount === 1 ? "subagent" : "subagents"}
+ </button>
+ {/if}
+
+ <div class="mt-1 border-t border-base-content/10 px-2 pt-1.5 pb-1 text-xs text-base-content/60">
+ Add to group under…
+ </div>
+ {#if candidates.length === 0}
+ <div class="px-2 py-1.5 text-xs text-base-content/40">No other tabs</div>
+ {:else}
+ <div class="flex max-h-56 flex-col overflow-y-auto">
+ {#each candidates as candidate (candidate.conversationId)}
+ <button
+ type="button"
+ role="menuitem"
+ class="flex min-w-0 items-center gap-1.5 rounded px-2 py-1.5 text-left hover:bg-base-300"
+ data-group-candidate={candidate.conversationId}
+ onclick={() => act(() => onAddToGroup(candidate.conversationId))}
+ >
+ <span
+ class="shrink-0 rounded bg-base-300 px-1 py-0.5 font-mono text-[10px] leading-none text-base-content/60"
+ >
+ {handleFor(candidate.conversationId)}
+ </span>
+ <span class="truncate">{candidate.title}</span>
+ </button>
+ {/each}
+ </div>
+ {/if}
+</div>
diff --git a/src/features/tabs/ui/tab-flip.ts b/src/features/tabs/ui/tab-flip.ts
new file mode 100644
index 0000000..ac9daaf
--- /dev/null
+++ b/src/features/tabs/ui/tab-flip.ts
@@ -0,0 +1,374 @@
+import { gsap } from "gsap";
+import { isGroupFlipId, leavingFlipIds } from "../tab-flip";
+
+/**
+ * FLIP animation for the tab grid — the DOM EFFECT half (the pure id scheme
+ * lives in `../tab-flip.ts`).
+ *
+ * Driven by the owning component in two calls:
+ * - `capture(nextIds)` — BEFORE the DOM updates: measure every
+ * `[data-flip-id]` element (First), and build an inert GHOST for each
+ * element the next render drops (a closed tab / dissolved group box).
+ * - `play()` — AFTER the DOM updates: measure again (Last), then run three
+ * STAGGERED phases, each only when it has something to do —
+ * 1. SHRINK — ghosts collapse to 0 height (everything else holds still);
+ * 2. MOVE — cards slide from their old box to their new one, and each
+ * group box's tint morphs from its old rect to its new one;
+ * 3. GROW — new cards (and new group boxes' tints) grow from 0 height.
+ *
+ * PERFORMANCE: this is a hand-rolled FLIP, not GSAP's Flip plugin. Flip
+ * recorded computed styles per element and re-measured while building its
+ * timeline — ~430ms of main-thread work per close with ~40 nested tabs, which
+ * both delayed the click and swallowed the shrink phase. Here each change
+ * costs exactly two batched layout reads (one in `capture`, one in `play`);
+ * every write happens after its batch of reads. Each phase is ONE GSAP tween
+ * on a plain progress value whose `onUpdate` writes inline styles directly —
+ * GSAP never reads an element's computed style (per-element CSS tweens did,
+ * forcing a style recalc per moved card). Cards only TRANSLATE (plus a
+ * width tween when a card turns into / out of a double-width group parent);
+ * a group box never moves itself — only its absolutely-positioned, empty tint
+ * layer does (inset tween), so no card is ever double-translated.
+ *
+ * Like the sidebar panels (`layout/ui/panel-motion.ts`), shrink/grow change
+ * only a HEIGHT, clipped — contents keep their natural size. Grow is a
+ * `clip-path` reveal so the settled grid never shifts under it.
+ *
+ * Data-driven, not click-driven: it animates ANY change to the tab set (a
+ * summoned subagent, a close from another device, a regroup). Re-entrant —
+ * `capture` measures the in-flight VISUAL boxes, `play` clears in-flight
+ * motion before measuring the new layout. Disabled when the user prefers
+ * reduced motion (or the environment can't tell, e.g. jsdom).
+ */
+export interface TabFlip {
+ capture(nextIds: ReadonlySet<string>): void;
+ play(): void;
+}
+
+const SHRINK = 0.1;
+const MOVE = 0.1;
+const GROW = 0.1;
+/** Close: starts slow, finishes fast (accelerates away). */
+const SHRINK_EASE = "power2.in";
+/** Move: slow → fast → slow. */
+const MOVE_EASE = "power2.inOut";
+/** Open: starts fast, settles slowly (the mirror of close). */
+const GROW_EASE = "power2.out";
+const GHOST_ATTR = "data-tab-ghost";
+/** Fully clipped from the bottom edge up (0 visible height). */
+const CLIP_HIDDEN = "inset(0% 0% 100% 0%)";
+/** Every inline style this module may leave on a live element mid-flight. */
+const MOTION_PROPS = ["transform", "width", "clip-path", "top", "right", "bottom", "left"];
+
+interface Captured {
+ /** Visual box (incl. any in-flight motion) per flip id. Cards: the card; boxes: the tint. */
+ readonly rects: ReadonlyMap<string, DOMRect>;
+ /** Built but not yet started — their shrink starts in `play`. */
+ readonly ghosts: readonly HTMLElement[];
+}
+
+export function createTabFlip(getScroller: () => HTMLElement | undefined): TabFlip {
+ let captured: Captured | null = null;
+ /** Live elements carrying motion styles, so `play` can reset exactly those. */
+ const animating = new Set<HTMLElement>();
+ /** The running MOVE + GROW phase tweens (ghost shrinks run independently). */
+ let phases: gsap.core.Tween[] = [];
+
+ return {
+ capture(nextIds) {
+ captured = null;
+ const scroller = getScroller();
+ if (scroller === undefined || !motionAllowed()) return;
+ const els = flipElements(scroller);
+ if (els.length === 0) return;
+
+ // READS (one layout).
+ const rects = new Map<string, DOMRect>();
+ for (const el of els) rects.set(idOf(el), measureTarget(el).getBoundingClientRect());
+ const leaving = leavingFlipIds([...rects.keys()], nextIds);
+ const byId = new Map(els.map((el) => [idOf(el), el]));
+ const plans = leaving
+ .map((id) => byId.get(id))
+ .filter((el): el is HTMLElement => el !== undefined)
+ .map((el) => planGhost(scroller, el, rects.get(idOf(el))))
+ .filter((p): p is GhostPlan => p !== null);
+
+ // WRITES.
+ captured = { rects, ghosts: plans.map(insertGhost) };
+ },
+
+ play() {
+ const prev = captured;
+ captured = null;
+ const scroller = getScroller();
+ if (prev === null || scroller === undefined) return;
+
+ // Reset in-flight motion on live elements so the read below is the
+ // settled layout (their previous VISUAL boxes are already in `prev`).
+ for (const tween of phases) tween.kill();
+ phases = [];
+ for (const el of animating) clearMotion(el);
+ animating.clear();
+
+ // READS (one layout).
+ const next = flipElements(scroller).map((el) => {
+ const target = measureTarget(el);
+ return { target, id: idOf(el), rect: target.getBoundingClientRect() };
+ });
+
+ // Plan (pure arithmetic — no DOM access).
+ const entering: HTMLElement[] = [];
+ const movers: Mover[] = [];
+ for (const { target, id, rect } of next) {
+ const before = prev.rects.get(id);
+ if (before === undefined) entering.push(target);
+ else if (!sameBox(before, rect))
+ movers.push(planMove(target, isGroupFlipId(id), before, rect));
+ }
+ const moveAt = prev.ghosts.length > 0 ? SHRINK : 0;
+ const growAt = moveAt + (movers.length > 0 ? MOVE : 0);
+
+ // WRITES — first frame of every phase NOW (movers hold at their old
+ // boxes, new elements are fully clipped) so nothing flashes in between.
+ for (const m of movers) m.render(0);
+ for (const el of entering) el.style.clipPath = CLIP_HIDDEN;
+
+ // 1. SHRINK — one tween drives every ghost.
+ if (prev.ghosts.length > 0) {
+ const ghosts = prev.ghosts.map((g) => ({ g, h: Number.parseFloat(g.style.height) }));
+ gsap.to(
+ { p: 0 },
+ {
+ p: 1,
+ duration: SHRINK,
+ ease: SHRINK_EASE,
+ onUpdate(this: gsap.core.Tween) {
+ const p = this.targets<{ p: number }>()[0]?.p ?? 1;
+ for (const { g, h } of ghosts) g.style.height = `${h * (1 - p)}px`;
+ },
+ onComplete: () => {
+ for (const { g } of ghosts) g.remove();
+ },
+ },
+ );
+ }
+
+ // 2. MOVE — one tween drives every card slide + tint morph.
+ if (movers.length > 0) {
+ for (const m of movers) animating.add(m.el);
+ phases.push(
+ gsap.to(
+ { p: 0 },
+ {
+ p: 1,
+ duration: MOVE,
+ delay: moveAt,
+ ease: MOVE_EASE,
+ onUpdate(this: gsap.core.Tween) {
+ const p = this.targets<{ p: number }>()[0]?.p ?? 1;
+ for (const m of movers) m.render(p);
+ },
+ onComplete: () => {
+ for (const m of movers) {
+ clearMotion(m.el);
+ animating.delete(m.el);
+ }
+ },
+ },
+ ),
+ );
+ }
+
+ // 3. GROW — one tween reveals every new card / tint from its top edge.
+ if (entering.length > 0) {
+ for (const el of entering) animating.add(el);
+ phases.push(
+ gsap.to(
+ { p: 0 },
+ {
+ p: 1,
+ duration: GROW,
+ delay: growAt,
+ ease: GROW_EASE,
+ onUpdate(this: gsap.core.Tween) {
+ const p = this.targets<{ p: number }>()[0]?.p ?? 1;
+ const clip = `inset(0% 0% ${(1 - p) * 100}% 0%)`;
+ for (const el of entering) el.style.clipPath = clip;
+ },
+ onComplete: () => {
+ for (const el of entering) {
+ clearMotion(el);
+ animating.delete(el);
+ }
+ },
+ },
+ ),
+ );
+ }
+ },
+ };
+}
+
+interface Mover {
+ readonly el: HTMLElement;
+ /** Write the element's styles at move progress `p` (0 = old box, 1 = home). */
+ render(p: number): void;
+}
+
+/**
+ * A card slides (and, when it turned into / out of a double-width group
+ * parent, resizes) from its old box; a group tint morphs its insets from its
+ * old rect. Both end at their natural layout (`p = 1` ⇒ zero offsets).
+ */
+function planMove(el: HTMLElement, isTint: boolean, from: DOMRect, to: DOMRect): Mover {
+ if (isTint) {
+ const top = from.top - to.top;
+ const left = from.left - to.left;
+ const right = to.right - from.right;
+ const bottom = to.bottom - from.bottom;
+ return {
+ el,
+ render(p) {
+ const k = 1 - p;
+ el.style.top = `${top * k}px`;
+ el.style.left = `${left * k}px`;
+ el.style.right = `${right * k}px`;
+ el.style.bottom = `${bottom * k}px`;
+ },
+ };
+ }
+ const dx = from.left - to.left;
+ const dy = from.top - to.top;
+ const resized = Math.abs(from.width - to.width) >= 0.5;
+ return {
+ el,
+ render(p) {
+ const k = 1 - p;
+ el.style.transform = `translate(${dx * k}px, ${dy * k}px)`;
+ if (resized) el.style.width = `${to.width + (from.width - to.width) * k}px`;
+ },
+ };
+}
+
+function clearMotion(el: HTMLElement): void {
+ for (const prop of MOTION_PROPS) el.style.removeProperty(prop);
+}
+
+function motionAllowed(): boolean {
+ return (
+ typeof window.matchMedia === "function" &&
+ !window.matchMedia("(prefers-reduced-motion: reduce)").matches
+ );
+}
+
+function flipElements(scroller: HTMLElement): HTMLElement[] {
+ return [...scroller.querySelectorAll<HTMLElement>("[data-flip-id]")];
+}
+
+function idOf(el: HTMLElement): string {
+ return el.dataset.flipId ?? "";
+}
+
+/** What VISUALLY represents a flip element: a card itself, a group box's tint. */
+function measureTarget(el: HTMLElement): HTMLElement {
+ if (!isGroupFlipId(idOf(el))) return el;
+ return el.querySelector<HTMLElement>(":scope > [data-group-backdrop]") ?? el;
+}
+
+/** Whether two rects are the same box (sub-pixel tolerance). */
+function sameBox(a: DOMRect, b: DOMRect): boolean {
+ const close = (x: number, y: number) => Math.abs(x - y) < 0.5;
+ return (
+ close(a.left, b.left) &&
+ close(a.top, b.top) &&
+ close(a.width, b.width) &&
+ close(a.height, b.height)
+ );
+}
+
+interface GhostPlan {
+ readonly source: HTMLElement;
+ readonly host: HTMLElement;
+ readonly isGroup: boolean;
+ readonly left: number;
+ readonly top: number;
+ readonly width: number;
+ readonly height: number;
+}
+
+/**
+ * READ half of a ghost: where an inert copy of a leaving element must sit.
+ *
+ * Paint order matters (cards are `relative`, so everything here stacks in DOM
+ * order): a leaving CARD's ghost goes on the scroller, ON TOP of the grid. A
+ * leaving GROUP BOX copies only its tint and goes FIRST in the box's
+ * enclosing grid: above the enclosing box's own tint, but BELOW every card.
+ */
+function planGhost(
+ scroller: HTMLElement,
+ el: HTMLElement,
+ rect: DOMRect | undefined,
+): GhostPlan | null {
+ const isGroup = isGroupFlipId(idOf(el));
+ const source = measureTarget(el);
+ const host = isGroup ? el.parentElement : scroller;
+ if (host === null || rect === undefined || (isGroup && source === el)) return null;
+ const h = host.getBoundingClientRect();
+ return {
+ source,
+ host,
+ isGroup,
+ left: rect.left - h.left - host.clientLeft + host.scrollLeft,
+ top: rect.top - h.top - host.clientTop + host.scrollTop,
+ width: rect.width,
+ height: rect.height,
+ };
+}
+
+/**
+ * WRITE half of a ghost: a clipping frame (whose height later shrinks to 0)
+ * around a full-size copy — only the frame animates, the copy never resizes.
+ */
+function insertGhost(plan: GhostPlan): HTMLElement {
+ const copy = plan.source.cloneNode(!plan.isGroup) as HTMLElement;
+ // A ghost is never a tab/group to queries, tests or assistive tech.
+ for (const node of [copy, ...copy.querySelectorAll<HTMLElement>("*")]) {
+ for (const attr of [
+ "data-flip-id",
+ "data-conversation-id",
+ "data-group-backdrop",
+ "role",
+ "id",
+ ]) {
+ node.removeAttribute(attr);
+ }
+ }
+ Object.assign(copy.style, {
+ position: "absolute",
+ inset: "auto",
+ left: "0",
+ top: "0",
+ width: `${plan.width}px`,
+ height: `${plan.height}px`,
+ margin: "0",
+ transform: "none",
+ clipPath: "none",
+ });
+ const ghost = document.createElement("div");
+ ghost.setAttribute(GHOST_ATTR, "");
+ ghost.setAttribute("aria-hidden", "true");
+ ghost.inert = true;
+ Object.assign(ghost.style, {
+ position: "absolute",
+ left: `${plan.left}px`,
+ top: `${plan.top}px`,
+ width: `${plan.width}px`,
+ height: `${plan.height}px`,
+ overflow: "hidden",
+ pointerEvents: "none",
+ zIndex: plan.isGroup ? "auto" : "2",
+ });
+ ghost.appendChild(copy);
+ if (plan.isGroup) plan.host.prepend(ghost);
+ else plan.host.appendChild(ghost);
+ return ghost;
+}