diff options
Diffstat (limited to 'src/features/tabs/ui')
| -rw-r--r-- | src/features/tabs/ui/CloseSubagentsConfirm.svelte | 83 | ||||
| -rw-r--r-- | src/features/tabs/ui/TabCard.svelte | 234 | ||||
| -rw-r--r-- | src/features/tabs/ui/TabGroupBox.svelte | 90 | ||||
| -rw-r--r-- | src/features/tabs/ui/TabList.svelte | 375 | ||||
| -rw-r--r-- | src/features/tabs/ui/TabMenu.svelte | 145 | ||||
| -rw-r--r-- | src/features/tabs/ui/tab-flip.ts | 374 |
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); + }} + > + × + </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); - }} - > - × - </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; +} |
