summaryrefslogtreecommitdiffhomepage
path: root/src/features
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-10-21 21:06:12 +0900
committerAdam Malczewski <[email protected]>2026-10-21 21:06:12 +0900
commitbbf9728721bd25fc5b0532c16088e9e025dadb40 (patch)
treea57e270a79d8fbd8b5454af60f35f62a4f70b8d0 /src/features
parent98f0494e46d09aa7799fa3018705d39ae04ea4eb (diff)
downloaddispatch-web-bbf9728721bd25fc5b0532c16088e9e025dadb40.tar.gz
dispatch-web-bbf9728721bd25fc5b0532c16088e9e025dadb40.zip
feat(layout): GSAP height animation when sidebar views are added, closed or changed
Diffstat (limited to 'src/features')
-rw-r--r--src/features/layout/logic/panel-keys.test.ts73
-rw-r--r--src/features/layout/logic/panel-keys.ts118
-rw-r--r--src/features/layout/ui/TilePanels.svelte148
-rw-r--r--src/features/layout/ui/TileRoot.test.ts53
-rw-r--r--src/features/layout/ui/panel-motion.ts83
5 files changed, 443 insertions, 32 deletions
diff --git a/src/features/layout/logic/panel-keys.test.ts b/src/features/layout/logic/panel-keys.test.ts
new file mode 100644
index 0000000..b45671a
--- /dev/null
+++ b/src/features/layout/logic/panel-keys.test.ts
@@ -0,0 +1,73 @@
+import { describe, expect, it } from "vitest";
+import { initialPanelKeys, reconcilePanelKeys, swappedPanels } from "./panel-keys";
+
+describe("initialPanelKeys", () => {
+ it("numbers the panels in order and seeds past them", () => {
+ expect(initialPanelKeys(["tabs", "model"])).toEqual({
+ views: ["tabs", "model"],
+ keys: [0, 1],
+ seed: 2,
+ });
+ });
+});
+
+describe("reconcilePanelKeys", () => {
+ const start = initialPanelKeys(["tabs", "model", "usage"]); // keys 0 1 2
+
+ it("closing the MIDDLE panel drops the middle key (not the last)", () => {
+ const next = reconcilePanelKeys(start, ["tabs", "usage"]);
+ expect(next.keys).toEqual([0, 2]);
+ expect(next.seed).toBe(3);
+ });
+
+ it("adding a panel gives only the new one a fresh key", () => {
+ const next = reconcilePanelKeys(start, ["tabs", "model", "usage", "lsp"]);
+ expect(next.keys).toEqual([0, 1, 2, 3]);
+ expect(next.seed).toBe(4);
+ });
+
+ it("a dropdown swap keeps every key positional", () => {
+ const next = reconcilePanelKeys(start, ["tabs", "lsp", "usage"]);
+ expect(next.keys).toEqual([0, 1, 2]);
+ });
+
+ it("handles duplicate views and never reuses a key", () => {
+ const dup = initialPanelKeys(["a", "b", "a"]); // 0 1 2
+ const removed = reconcilePanelKeys(dup, ["a", "a"]);
+ expect(removed.keys).toEqual([0, 2]);
+ const added = reconcilePanelKeys(removed, ["a", "a", "a"]);
+ expect(added.keys).toEqual([0, 2, 3]);
+ expect(new Set(added.keys).size).toBe(3);
+ });
+
+ it("a ✕ hint picks the exact panel when identical views make it ambiguous", () => {
+ const twins = initialPanelKeys(["model", "usage", "usage"]); // 0 1 2
+ expect(reconcilePanelKeys(twins, ["model", "usage"]).keys).toEqual([0, 1]); // no hint
+ expect(reconcilePanelKeys(twins, ["model", "usage"], 1).keys).toEqual([0, 2]);
+ });
+
+ it("ignores a stale hint that does not describe the change", () => {
+ // The tree changed some other way (e.g. a remote edit) — fall back to LCS.
+ expect(reconcilePanelKeys(start, ["tabs", "model"], 0).keys).toEqual([0, 1]);
+ expect(reconcilePanelKeys(start, ["tabs", "model", "usage"], 1).keys).toEqual([0, 1, 2]);
+ });
+
+ it("an unrelated replacement of different length gets all-fresh keys", () => {
+ const next = reconcilePanelKeys(start, ["x"]);
+ expect(next.keys).toEqual([3]);
+ });
+});
+
+describe("swappedPanels", () => {
+ it("reports a surviving panel whose view changed", () => {
+ const prev = initialPanelKeys(["tabs", "model"]);
+ const next = reconcilePanelKeys(prev, ["tabs", "usage"]);
+ expect([...swappedPanels(prev, next)]).toEqual([[1, { from: "model", to: "usage" }]]);
+ });
+
+ it("does not report adds or removes", () => {
+ const prev = initialPanelKeys(["tabs", "model", "usage"]);
+ expect(swappedPanels(prev, reconcilePanelKeys(prev, ["tabs", "usage"])).size).toBe(0);
+ expect(swappedPanels(prev, reconcilePanelKeys(prev, [...prev.views, "lsp"])).size).toBe(0);
+ });
+});
diff --git a/src/features/layout/logic/panel-keys.ts b/src/features/layout/logic/panel-keys.ts
new file mode 100644
index 0000000..76a053f
--- /dev/null
+++ b/src/features/layout/logic/panel-keys.ts
@@ -0,0 +1,118 @@
+/**
+ * Stable panel keys — the PURE identity scheme behind the panel height
+ * animation.
+ *
+ * Panel leaves carry no required id, so the panels list can't be keyed by the
+ * tree. Keying by INDEX is wrong for removal: closing the middle of three
+ * panels would re-use the middle DOM node for the third view and animate the
+ * LAST panel out. This reconciles a stable numeric key per panel across tree
+ * updates, from the view ids alone:
+ *
+ * - same length → keys stay positional (a dropdown SWAP keeps its panel —
+ * the panel morphs its height to the new view instead of leaving).
+ * - length changed → the longest common subsequence of view ids keeps its
+ * keys; every unmatched new panel gets a fresh key (an ADD), every
+ * unmatched old key disappears (a REMOVE).
+ *
+ * Zero Svelte, zero DOM.
+ */
+
+export interface PanelKeys {
+ /** View id per panel, in order. */
+ readonly views: readonly string[];
+ /** Stable key per panel, aligned with `views`. */
+ readonly keys: readonly number[];
+ /** The next fresh key. */
+ readonly seed: number;
+}
+
+/** Keys for a freshly mounted panels list. */
+export function initialPanelKeys(views: readonly string[]): PanelKeys {
+ return { views: [...views], keys: views.map((_, i) => i), seed: views.length };
+}
+
+/**
+ * Reconcile `prev` against the next view list (see the module doc).
+ *
+ * `removedIndex` is an optional HINT from a local ✕ click: when the next list
+ * is exactly `prev` minus that index, that exact panel is the one removed.
+ * Without it, closing one of two identical adjacent views is ambiguous to the
+ * subsequence match (it would drop the later one).
+ */
+export function reconcilePanelKeys(
+ prev: PanelKeys,
+ nextViews: readonly string[],
+ removedIndex?: number,
+): PanelKeys {
+ if (removedIndex !== undefined && isRemovalAt(prev.views, nextViews, removedIndex)) {
+ return {
+ views: [...nextViews],
+ keys: prev.keys.filter((_, i) => i !== removedIndex),
+ seed: prev.seed,
+ };
+ }
+ if (nextViews.length === prev.views.length) {
+ return { views: [...nextViews], keys: [...prev.keys], seed: prev.seed };
+ }
+ const matched = lcsMatch(prev.views, nextViews);
+ let seed = prev.seed;
+ const keys = nextViews.map((_, j) => {
+ const i = matched.get(j);
+ if (i !== undefined) return prev.keys[i] ?? seed++;
+ return seed++;
+ });
+ return { views: [...nextViews], keys, seed };
+}
+
+/**
+ * The views whose panel SURVIVED but now mounts a different view (a swap):
+ * key → { from, to }. Adds/removes are not swaps.
+ */
+export function swappedPanels(
+ prev: PanelKeys,
+ next: PanelKeys,
+): Map<number, { from: string; to: string }> {
+ const before = new Map(prev.keys.map((k, i) => [k, prev.views[i] ?? ""]));
+ const out = new Map<number, { from: string; to: string }>();
+ next.keys.forEach((k, i) => {
+ const from = before.get(k);
+ const to = next.views[i] ?? "";
+ if (from !== undefined && from !== to) out.set(k, { from, to });
+ });
+ return out;
+}
+
+function isRemovalAt(prev: readonly string[], next: readonly string[], index: number): boolean {
+ if (index < 0 || index >= prev.length || next.length !== prev.length - 1) return false;
+ return next.every((v, j) => v === prev[j < index ? j : j + 1]);
+}
+
+/** LCS alignment: next index → prev index for every matched element. */
+function lcsMatch(a: readonly string[], b: readonly string[]): Map<number, number> {
+ const n = a.length;
+ const m = b.length;
+ // dp[i][j] = LCS length of a[i..] and b[j..]
+ const dp: number[][] = Array.from({ length: n + 1 }, () => new Array<number>(m + 1).fill(0));
+ for (let i = n - 1; i >= 0; i--) {
+ for (let j = m - 1; j >= 0; j--) {
+ const row = dp[i] as number[];
+ const below = dp[i + 1] as number[];
+ row[j] = a[i] === b[j] ? (below[j + 1] ?? 0) + 1 : Math.max(below[j] ?? 0, row[j + 1] ?? 0);
+ }
+ }
+ const out = new Map<number, number>();
+ let i = 0;
+ let j = 0;
+ while (i < n && j < m) {
+ if (a[i] === b[j]) {
+ out.set(j, i);
+ i++;
+ j++;
+ } else if ((dp[i + 1]?.[j] ?? 0) >= (dp[i]?.[j + 1] ?? 0)) {
+ i++;
+ } else {
+ j++;
+ }
+ }
+ return out;
+}
diff --git a/src/features/layout/ui/TilePanels.svelte b/src/features/layout/ui/TilePanels.svelte
index 9162b00..cc5fa89 100644
--- a/src/features/layout/ui/TilePanels.svelte
+++ b/src/features/layout/ui/TilePanels.svelte
@@ -8,9 +8,17 @@
*/
import type { LayoutOp, PanelsNode, Path } from "@dispatch/layout-contract";
import PlusIcon from "phosphor-svelte/lib/PlusIcon";
+ import { untrack } from "svelte";
import { viewRegistry } from "../logic/registry";
import { addPanelOp, firstPanelView, removePanelOp, swapPanelOp } from "../logic/panels-edit";
+ import {
+ initialPanelKeys,
+ type PanelKeys,
+ reconcilePanelKeys,
+ swappedPanels,
+ } from "../logic/panel-keys";
import { markerStyle } from "../logic/marker";
+ import { morphPanelHeight, panelGrowIn, panelShrinkOut } from "./panel-motion";
import TileLeaf from "./TileLeaf.svelte";
let {
@@ -38,45 +46,122 @@
// appear in the dropdowns. Reactive so late registrations re-render options.
const panelViews = $derived($viewRegistry.filter((v) => v.panelEligible));
const addView = $derived(firstPanelView(panelViews));
+
+ // Stable per-panel keys (pure reconcile, memoised across tree updates) so a
+ // closed panel is the one that animates out, not whichever is last.
+ // A local ✕ click hints WHICH panel left (consumed by the next tree change;
+ // ignored if that change isn't exactly this removal, e.g. a remote edit).
+ let keyMemo: PanelKeys | null = null;
+ let removeHint: number | undefined;
+ const keyed = $derived.by(() => {
+ const views = node.panels.map((p) => p.view);
+ keyMemo =
+ keyMemo === null ? initialPanelKeys(views) : reconcilePanelKeys(keyMemo, views, removeHint);
+ removeHint = undefined;
+ return keyMemo;
+ });
+
+ function removePanel(idx: number): void {
+ removeHint = idx;
+ onEdit([removePanelOp(path, idx)]);
+ }
+
+ // View SWAP: the panel BOX (the well) animates from the old view's height
+ // to the new one's. Measure before the DOM updates, animate after. (Adds/removes are
+ // the per-panel in:/out: transitions below.)
+ let columnEl = $state<HTMLDivElement | undefined>();
+ /** The keys the DOM currently shows (set after each update). */
+ let shownKeys: PanelKeys | null = null;
+ let before: { keys: PanelKeys; heights: Map<number, number> } | null = null;
+ $effect.pre(() => {
+ const next = keyed;
+ untrack(() => {
+ const heights = new Map<number, number>();
+ for (const el of panelEls()) {
+ const well = wellOf(el);
+ if (well !== null) heights.set(Number(el.dataset.panelKey), well.offsetHeight);
+ }
+ before = { keys: shownKeys ?? next, heights };
+ });
+ });
+ $effect(() => {
+ const now = keyed;
+ untrack(() => {
+ const prev = before;
+ before = null;
+ shownKeys = now;
+ if (prev === null) return;
+ for (const key of swappedPanels(prev.keys, now).keys()) {
+ const el = panelEls().find((e) => Number(e.dataset.panelKey) === key);
+ const well = el === undefined ? null : wellOf(el);
+ const from = prev.heights.get(key);
+ if (well !== null && from !== undefined) morphPanelHeight(well, from);
+ }
+ });
+ });
+
+ function wellOf(panel: HTMLElement): HTMLElement | null {
+ return panel.querySelector<HTMLElement>(":scope > [data-panel-well]");
+ }
+
+ function panelEls(): HTMLElement[] {
+ return columnEl === undefined
+ ? []
+ : [...columnEl.querySelectorAll<HTMLElement>(":scope > [data-panel-key]")];
+ }
</script>
<!-- `shrink-0`: this column is the sidebar's SCROLLED content, so it keeps its
natural height — if it shrank to the scroller (min-h-0 in a flex column),
its children overflowed it and the trailing margin was never scrollable. -->
+<!-- No flex `gap`: each panel's wrapper carries the gap as `pb-2` instead, so
+ it grows/shrinks WITH the panel's height animation (a flex gap would jump). -->
<div
- class="flex min-h-0 shrink-0 flex-col gap-2"
+ bind:this={columnEl}
+ class="flex min-h-0 shrink-0 flex-col"
style={renderMarker ? markerStyle(node.marker) : undefined}
+ data-panels
>
- {#each node.panels as panel, idx (idx)}
- <!-- `.inset-well` (app.css): recessed into the sidebar, like the chat's
- thinking/tool blocks. -->
- <div class="inset-well flex flex-col p-3">
- <div class="flex items-center gap-1">
- <select
- class="select select-bordered select-sm flex-1"
- aria-label="Select a view"
- value={panel.view}
- onchange={(e) => onEdit([swapPanelOp(path, idx, e.currentTarget.value)])}
- >
- <option value="" disabled>Select a view</option>
- {#each panelViews as view (view.id)}
- <option value={view.id}>{view.label}</option>
- {/each}
- </select>
- {#if idx > 0}
- <button
- type="button"
- class="btn btn-square btn-ghost btn-sm shrink-0"
- aria-label="Remove view"
- onclick={() => onEdit([removePanelOp(path, idx)])}
+ {#each node.panels as panel, idx (keyed.keys[idx])}
+ <!-- Add/close animate the WRAPPER's height (it carries the inter-panel gap,
+ so panels below slide smoothly); a view swap animates the WELL's
+ height. Either way only a height is tweened (clipped) — the contents
+ keep their natural size. `.inset-well` (app.css): recessed into the
+ sidebar, like the chat's thinking/tool blocks. -->
+ <div
+ class="shrink-0 pb-2"
+ data-panel-key={keyed.keys[idx]}
+ in:panelGrowIn
+ out:panelShrinkOut
+ >
+ <div class="inset-well flex flex-col p-3" data-panel-well>
+ <div class="flex items-center gap-1">
+ <select
+ class="select select-bordered select-sm flex-1"
+ aria-label="Select a view"
+ value={panel.view}
+ onchange={(e) => onEdit([swapPanelOp(path, idx, e.currentTarget.value)])}
>
- ✕
- </button>
- {/if}
- </div>
+ <option value="" disabled>Select a view</option>
+ {#each panelViews as view (view.id)}
+ <option value={view.id}>{view.label}</option>
+ {/each}
+ </select>
+ {#if idx > 0}
+ <button
+ type="button"
+ class="btn btn-square btn-ghost btn-sm shrink-0"
+ aria-label="Remove view"
+ onclick={() => removePanel(idx)}
+ >
+ ✕
+ </button>
+ {/if}
+ </div>
- <div class="mt-2">
- <TileLeaf node={panel} {ports} />
+ <div class="mt-2">
+ <TileLeaf node={panel} {ports} />
+ </div>
</div>
</div>
{/each}
@@ -85,8 +170,9 @@
<!-- `.raised-btn` (app.css): the mirror of the view wells above it —
pops out where they sink in; pressed, it sinks in like them. Height =
DaisyUI's default button size (`--size-field` × 10), as the `btn` it
- replaced; `shrink-0` so the column can never squash it. `mb-2` = the
- gap between views (`gap-2`), so it doesn't sit tight to the end. -->
+ replaced; `shrink-0` so the column can never squash it. The last panel's
+ `pb-2` is the gap above it; `mb-2` = the same gap below, so it
+ doesn't sit tight to the end. -->
<button
type="button"
class="raised-btn mb-2 h-[calc(var(--size-field,0.25rem)*10)] w-full shrink-0"
diff --git a/src/features/layout/ui/TileRoot.test.ts b/src/features/layout/ui/TileRoot.test.ts
index 2f95854..4b404a1 100644
--- a/src/features/layout/ui/TileRoot.test.ts
+++ b/src/features/layout/ui/TileRoot.test.ts
@@ -1,6 +1,7 @@
import type { LayoutTree, SplitNode } from "@dispatch/layout-contract";
import { render, screen } from "@testing-library/svelte";
import userEvent from "@testing-library/user-event";
+import { gsap } from "gsap";
import { describe, expect, it, vi } from "vitest";
import { createCollapseStore, registerView, unregisterView } from "../index";
import FixtureA from "./fixtures/FixtureA.svelte";
@@ -271,7 +272,7 @@ describe("TileRoot", () => {
const { container } = render(TileRoot, {
props: { tree: markedTestTree(), collapse: createCollapseStore({ viewportWidth: 1024 }) },
});
- const panels = container.querySelector("[data-testid='fixture-b']")?.closest(".gap-2");
+ const panels = container.querySelector("[data-testid='fixture-b']")?.closest("[data-panels]");
expect(panels?.getAttribute("style") ?? "").not.toContain("outline");
});
@@ -341,3 +342,53 @@ describe("TileRoot registry cleanup", () => {
}
});
});
+
+describe("TilePanels — panel identity + height animation", () => {
+ const wrappers = (): HTMLElement[] => [
+ ...document.querySelectorAll<HTMLElement>("[data-panels] > [data-panel-key]"),
+ ];
+
+ it("closing the MIDDLE panel removes that panel's DOM, keeping the others' nodes", async () => {
+ const { rerender } = render(TileRoot, {
+ props: { tree: panelsOnlyTree("fix-b", "fix-a", "fix-b"), collapse: createCollapseStore() },
+ });
+ const [first, , third] = wrappers();
+ await rerender({ tree: panelsOnlyTree("fix-b", "fix-b"), collapse: createCollapseStore() });
+ // Identity, not position: the third panel's node survived (index keying
+ // would have reused the middle node and dropped the last one).
+ expect(wrappers()).toEqual([first, third]);
+ expect(screen.queryByTestId("fixture-a")).toBeNull();
+ });
+
+ it("✕ on one of two IDENTICAL views removes exactly that panel", async () => {
+ const user = userEvent.setup();
+ const tree = panelsOnlyTree("fix-b", "fix-a", "fix-a");
+ const { rerender } = render(TileRoot, { props: { tree, collapse: createCollapseStore() } });
+ const [first, , third] = wrappers();
+ // Click the MIDDLE panel's ✕ (the first panel has none), then the owner
+ // applies the edit — the two fix-a panels are indistinguishable by data.
+ const [middleClose] = screen.getAllByRole("button", { name: "Remove view" });
+ await user.click(middleClose as HTMLElement);
+ await rerender({ tree: panelsOnlyTree("fix-b", "fix-a"), collapse: createCollapseStore() });
+ expect(wrappers()).toEqual([first, third]);
+ });
+
+ it("with motion allowed, a closed panel stays in flow while it shrinks out", async () => {
+ vi.stubGlobal(
+ "matchMedia",
+ vi.fn(() => ({ matches: false }) as unknown as MediaQueryList),
+ );
+ try {
+ const { rerender } = render(TileRoot, {
+ props: { tree: panelsOnlyTree("fix-b", "fix-a"), collapse: createCollapseStore() },
+ });
+ const closing = wrappers()[1];
+ await rerender({ tree: panelsOnlyTree("fix-b"), collapse: createCollapseStore() });
+ // jsdom's stubbed Element.animate never finishes, so the outro holds.
+ expect(closing?.isConnected).toBe(true);
+ expect(gsap.getTweensOf(closing as HTMLElement)).toHaveLength(1);
+ } finally {
+ vi.unstubAllGlobals();
+ }
+ });
+});
diff --git a/src/features/layout/ui/panel-motion.ts b/src/features/layout/ui/panel-motion.ts
new file mode 100644
index 0000000..7fc5edf
--- /dev/null
+++ b/src/features/layout/ui/panel-motion.ts
@@ -0,0 +1,83 @@
+import { gsap } from "gsap";
+import type { TransitionConfig } from "svelte/transition";
+
+/**
+ * GSAP height animations for sidebar panels — the DOM EFFECT half (stable
+ * panel identity lives in `../logic/panel-keys.ts`).
+ *
+ * Every animation changes ONLY the panel wrapper's height (clipped with
+ * `overflow: hidden`); the panel's contents keep their natural size the whole
+ * time — the wrapper reveals / hides them like a drawer, it never scales
+ * them. The wrapper's bottom padding is the gap between panels, so it grows /
+ * shrinks with the panel and the panels below slide smoothly.
+ *
+ * - `panelGrowIn` — Svelte `in:` transition: an added panel grows 0 → auto.
+ * - `panelShrinkOut` — Svelte `out:` transition: a closed panel shrinks to 0
+ * while staying in flow (Svelte keeps the node for the returned duration).
+ * - `morphPanelHeight` — a swapped view: the panel BOX (the well, so its
+ * edges move with it — no empty gap below a shrinking box) animates from
+ * the old view's height to the new one's.
+ *
+ * Svelte's `in:` does not run on first mount, so loading the page never
+ * animates. Disabled when the user prefers reduced motion (or the
+ * environment can't tell, e.g. jsdom) — then every change is instant.
+ */
+
+const DURATION = 0.35;
+const EASE = "power2.inOut";
+
+export function panelMotionAllowed(): boolean {
+ return (
+ typeof window !== "undefined" &&
+ typeof window.matchMedia === "function" &&
+ !window.matchMedia("(prefers-reduced-motion: reduce)").matches
+ );
+}
+
+export function panelGrowIn(node: HTMLElement): TransitionConfig {
+ if (!panelMotionAllowed()) return { duration: 0 };
+ gsap.killTweensOf(node);
+ gsap.fromTo(
+ node,
+ { height: 0, overflow: "hidden" },
+ {
+ height: "auto",
+ duration: DURATION,
+ ease: EASE,
+ clearProps: "height,overflow",
+ },
+ );
+ return { duration: DURATION * 1000 };
+}
+
+export function panelShrinkOut(node: HTMLElement): TransitionConfig {
+ if (!panelMotionAllowed()) return { duration: 0 };
+ // e.g. closed mid-grow / mid-swap: shrink from wherever it is.
+ gsap.killTweensOf([node, ...node.querySelectorAll(":scope > *")]);
+ gsap.to(node, {
+ height: 0,
+ overflow: "hidden",
+ duration: DURATION,
+ ease: EASE,
+ });
+ return { duration: DURATION * 1000 };
+}
+
+/**
+ * Animate `node` from `fromHeight` (its height before the view swapped) to
+ * its new natural height. Call AFTER the DOM shows the new view.
+ */
+export function morphPanelHeight(node: HTMLElement, fromHeight: number): void {
+ if (!panelMotionAllowed()) return;
+ gsap.killTweensOf(node);
+ gsap.fromTo(
+ node,
+ { height: fromHeight, overflow: "hidden" },
+ {
+ height: "auto",
+ duration: DURATION,
+ ease: EASE,
+ clearProps: "height,overflow",
+ },
+ );
+}