From a62c9e7f74a3e75d581f66271378ebf538cf7b36 Mon Sep 17 00:00:00 2001 From: Adam Leith Date: Wed, 1 Jul 2026 01:43:43 +0100 Subject: [PATCH 1/7] feat(skills): add quill-code skill MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Local dev loop for the @posthog/quill design system: edit quill in the sibling mono, build the whole quill workspace, pack a content-hashed tarball into .local-quill, repoint the pnpm override, and reinstall — so quill changes can be tested in this app before publishing. Co-Authored-By: Claude Opus 4.8 (1M context) --- .claude/skills/quill-code/SKILL.md | 84 +++++++++++++++++++ .../skills/quill-code/scripts/sync-quill.sh | 49 +++++++++++ 2 files changed, 133 insertions(+) create mode 100644 .claude/skills/quill-code/SKILL.md create mode 100755 .claude/skills/quill-code/scripts/sync-quill.sh diff --git a/.claude/skills/quill-code/SKILL.md b/.claude/skills/quill-code/SKILL.md new file mode 100644 index 0000000000..5a9afef1f3 --- /dev/null +++ b/.claude/skills/quill-code/SKILL.md @@ -0,0 +1,84 @@ +--- +name: quill-code +description: Edit the @posthog/quill design system locally and consume the change in this repo (posthog-code) before it is published to npm. Use when changing quill components/primitives/tokens, when a quill change must be tested inside the Code app, or when the user mentions quill, the design system, the .local-quill tarball, or the @posthog/quill pnpm override. +--- + +# quill-code + +`@posthog/quill` is **not** in this repo. It is a published catalog dependency whose +source lives in the main PostHog monorepo at `../posthog/packages/quill`. To test an +unpublished quill change inside this repo (posthog-code), you build quill, pack it to a +tarball, and point a pnpm `overrides` entry at that tarball. This is a **temporary +local-dev** state — revert before merging (see below). + +## Quill layout (where to edit) + +`../posthog/packages/quill` is the **workspace** (`@posthog/quill-workspace`). It +contains sub-packages, each a layer of the design system: + +- `packages/primitives/src` — base components (Card, Badge, Button, Progress, …) +- `packages/components/src` — composed components (DataTable, DateTimePicker, Metric) +- `packages/blocks/src` — **product-level blocks** (e.g. `ExperimentCard`). Add the + file here and export it from `packages/blocks/src/index.ts`. +- `packages/quill/src` — the **aggregate** that re-exports all layers as `@posthog/quill`. + +A new export in any sub-package flows to `@posthog/quill` automatically on build. + +## The loop (every quill change) + +1. Edit/add the component in the right sub-package (above) and export it from that + package's `src/index.ts`. +2. Re-sync into this repo: + + ```bash + .claude/skills/quill-code/scripts/sync-quill.sh + ``` + + This builds the **whole quill workspace** (recursive `pnpm build` at the workspace + root — it rebuilds the sub-packages BEFORE the aggregate bundles them), packs it + into `.local-quill/` as a content-hashed `posthog-quill-local-.tgz`, rewrites + the override line in `pnpm-workspace.yaml`, deletes stale tarballs, and runs + `pnpm install`. + + > Building only the aggregate (`packages/quill/packages/quill`) re-bundles the + > sub-packages' **stale** `dist/`, so edits to primitives/components/blocks are + > silently dropped. Always build at the workspace root — the script does this. +3. Verify in the Code app (`pnpm dev`, or the `test-electron-app` skill). Repeat from 1. + +After every quill edit you **must** re-run the sync — the app consumes the tarball, not +the quill source, so unsynced edits are invisible here. + +If quill lives elsewhere, set `QUILL_DIR=/abs/path/to/posthog/packages/quill/packages/quill`. + +## Why a tarball, not `link:` + +`link:` symlinks into the mono's `node_modules` and drags in its **React 18** types, +colliding with this repo's **React 19** (dual-React → broken typecheck + +invalid-hook-call at runtime). The tarball is copied into this repo's store and deduped +against React 19. The filename is **content-hashed** because pnpm pins a tarball by +integrity, so a stable filename gets cached stale across re-syncs. + +## The override (what the script rewrites) + +In `pnpm-workspace.yaml`, under `overrides:`: + +```yaml +'@posthog/quill': file:./.local-quill/posthog-quill-local-.tgz +``` + +There is also a permanent pin you should leave alone: + +```yaml +'@posthog/quill>@base-ui/react': ^1.3.0 # quill ships a broken catalog: dep; do not remove +``` + +## Reverting (before merge) + +The override is local-dev only. Once the quill change is published to npm: + +1. Bump the catalog version in `pnpm-workspace.yaml` (`'@posthog/quill': 0.3.0-beta.x`) + to the published version. +2. Restore the override line to point back at the catalog, or remove the `file:` override. +3. `pnpm install`. + +Do not commit a `file:./.local-quill/...` override or the `.local-quill/` tarballs. diff --git a/.claude/skills/quill-code/scripts/sync-quill.sh b/.claude/skills/quill-code/scripts/sync-quill.sh new file mode 100755 index 0000000000..6e12ef3806 --- /dev/null +++ b/.claude/skills/quill-code/scripts/sync-quill.sh @@ -0,0 +1,49 @@ +#!/usr/bin/env bash +# Build local @posthog/quill, pack it to a content-hashed tarball, point the +# pnpm override at it, and reinstall. See SKILL.md for the why. +set -euo pipefail + +# code repo root = dir containing pnpm-workspace.yaml, walking up from this script +CODE_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && while [ ! -f pnpm-workspace.yaml ] && [ "$PWD" != / ]; do cd ..; done && pwd)" +[ -f "$CODE_ROOT/pnpm-workspace.yaml" ] || { echo "could not find pnpm-workspace.yaml above $0" >&2; exit 1; } + +QUILL_DIR="${QUILL_DIR:-$CODE_ROOT/../posthog/packages/quill/packages/quill}" +[ -d "$QUILL_DIR" ] || { echo "quill source not found: $QUILL_DIR (set QUILL_DIR=...)" >&2; exit 1; } + +# The quill workspace root (@posthog/quill-workspace) is two levels up from the +# aggregate package. We MUST build there: its `pnpm build` runs the recursive +# `pnpm -r --filter '@posthog/quill-*' --filter '@posthog/quill' build`, which +# rebuilds the sub-packages (primitives, components, blocks, ...) BEFORE the +# aggregate bundles them. Building inside QUILL_DIR alone only re-bundles the +# sub-packages' STALE dist, so edits to blocks/primitives/etc. are silently lost. +QUILL_WS="${QUILL_WS:-$(cd "$QUILL_DIR/../.." && pwd)}" + +DEST="$CODE_ROOT/.local-quill" +WS="$CODE_ROOT/pnpm-workspace.yaml" + +echo "==> building quill workspace in $QUILL_WS" +( cd "$QUILL_WS" && pnpm build ) + +echo "==> packing tarball -> $DEST" +mkdir -p "$DEST" +( cd "$QUILL_DIR" && npm pack --pack-destination "$DEST" >/dev/null ) + +RAW="$(ls -t "$DEST"/posthog-quill-*.tgz | grep -v -- '-local-' | head -1)" +[ -n "$RAW" ] || { echo "npm pack produced no posthog-quill-*.tgz" >&2; exit 1; } + +HASH="$(md5 -q "$RAW" | cut -c1-8)" +HASHED="posthog-quill-local-$HASH.tgz" +cp "$RAW" "$DEST/$HASHED" +rm -f "$RAW" +# drop stale local tarballs so the pnpm store can't resolve an old integrity +find "$DEST" -name 'posthog-quill-local-*.tgz' ! -name "$HASHED" -delete + +echo "==> pointing override at .local-quill/$HASHED" +# single override line: '@posthog/quill': file:./.local-quill/... +sed -i '' -E "s#('@posthog/quill': file:\./\.local-quill/)[^']*#\1$HASHED#" "$WS" +grep -q "$HASHED" "$WS" || { echo "failed to rewrite override line in $WS" >&2; exit 1; } + +echo "==> pnpm install" +( cd "$CODE_ROOT" && pnpm install ) + +echo "==> done. @posthog/quill now resolves to .local-quill/$HASHED" From 83d62d79e85248e7d98282c18dcbe070b7097086 Mon Sep 17 00:00:00 2001 From: Adam Leith Date: Sun, 12 Jul 2026 01:56:10 +0100 Subject: [PATCH 2/7] =?UTF-8?q?feat(browser-tabs):=20tab-owned=20split=20p?= =?UTF-8?q?anes=20=E2=80=94=20one=20strip,=20tabs=20contain=20panes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Chrome-split-view model: the single title-bar strip stays; each TAB owns a PaneLayoutNode tree of panes, and panes carry the identity (canvas/task/channel/appView). Multi-pane tabs show a layout glyph on their pill; dragging tab B onto a content edge/center zone merges it into the active tab (its pill disappears); each pane of a multi-pane tab gets a hover close-X. - shared: v2 schemas + transforms (openOrFocusTab pane-dedup, setPaneTarget, mergeTabIntoTab, closePane, ensureSnapshotIntegrity, decidePaneNavigation with PUSH-only dedup), insertNodeInLayout subtree splice. - workspace-server: browser_panes table + migration 0021 (one pane backfilled per tab, heals v1-dogfood profiles), v2 repository/service/procedures with renderer-minted ids and never-empty-strip blank backfill. - ui: per-pane memory-history routers (registry + sessionStorage location persistence), AppShell chrome outside the route tree bound to the focused pane's router, PaneChrome reconciles each pane's location via setPaneTarget (history tabId-stamping is gone — tab switches are pure store mutations, browser-like), PaneTreeRenderer renders the active tab's layout, DnD merge drops. Co-Authored-By: Claude Fable 5 --- apps/code/src/renderer/di/container.ts | 4 +- .../core/src/browser-tabs/browserTabsStore.ts | 2 +- .../src/routers/browser-tabs.router.ts | 34 +- .../shared/src/browser-pane-layout.test.ts | 345 +++ packages/shared/src/browser-pane-layout.ts | 272 +++ packages/shared/src/browser-tabs-schemas.ts | 115 +- packages/shared/src/browser-tabs.test.ts | 1848 +++++++---------- packages/shared/src/browser-tabs.ts | 938 ++++++--- packages/shared/src/index.ts | 35 +- .../ui/src/features/browser-tabs/AGENTS.md | 425 ++-- .../features/browser-tabs/BrowserTabStrip.tsx | 659 ++---- .../features/browser-tabs/BrowserTabsDnd.tsx | 102 +- .../features/browser-tabs/PaneLayoutGlyph.tsx | 66 + .../ui/src/features/browser-tabs/appViews.tsx | 45 + .../browser-tabs/browserTabsClient.ts | 16 +- .../browser-tabs/displayOrder.test.ts | 9 +- .../browser-tabs/panes/BrowserPane.tsx | 107 + .../browser-tabs/panes/PaneDropZones.tsx | 76 + .../browser-tabs/panes/PaneTreeRenderer.tsx | 146 ++ .../browser-tabs/panes/RootDropZones.tsx | 63 + .../browser-tabs/panes/paneDragStore.ts | 18 + .../ui/src/features/browser-tabs/tabHref.ts | 57 + .../features/browser-tabs/tabsSync.test.ts | 12 +- packages/ui/src/router/RouterDevtools.tsx | 28 +- packages/ui/src/router/createAppRouter.ts | 127 ++ .../ui/src/router/paneLocationPersistence.ts | 50 + packages/ui/src/router/paneRouterRegistry.ts | 87 + packages/ui/src/router/router.ts | 40 - packages/ui/src/router/routerRef.ts | 44 +- packages/ui/src/router/routes/__root.tsx | 723 +++---- .../ui/src/router/useFocusedPaneRouter.ts | 30 + .../ui/src/router/usePaneHistoryControls.ts | 26 + packages/ui/src/shell/App.tsx | 99 +- packages/ui/src/shell/AppShell.tsx | 476 +++++ .../db/migrations/0021_tab_owned_panes.sql | 44 + .../src/db/migrations/meta/0021_snapshot.json | 1098 ++++++++++ .../src/db/migrations/meta/_journal.json | 7 + .../repositories/browser-tabs-repository.ts | 82 +- packages/workspace-server/src/db/schema.ts | 49 +- .../src/services/browser-tabs/schemas.ts | 68 +- .../src/services/browser-tabs/service.test.ts | 68 +- .../src/services/browser-tabs/service.ts | 183 +- 42 files changed, 5735 insertions(+), 2988 deletions(-) create mode 100644 packages/shared/src/browser-pane-layout.test.ts create mode 100644 packages/shared/src/browser-pane-layout.ts create mode 100644 packages/ui/src/features/browser-tabs/PaneLayoutGlyph.tsx create mode 100644 packages/ui/src/features/browser-tabs/appViews.tsx create mode 100644 packages/ui/src/features/browser-tabs/panes/BrowserPane.tsx create mode 100644 packages/ui/src/features/browser-tabs/panes/PaneDropZones.tsx create mode 100644 packages/ui/src/features/browser-tabs/panes/PaneTreeRenderer.tsx create mode 100644 packages/ui/src/features/browser-tabs/panes/RootDropZones.tsx create mode 100644 packages/ui/src/features/browser-tabs/panes/paneDragStore.ts create mode 100644 packages/ui/src/features/browser-tabs/tabHref.ts create mode 100644 packages/ui/src/router/createAppRouter.ts create mode 100644 packages/ui/src/router/paneLocationPersistence.ts create mode 100644 packages/ui/src/router/paneRouterRegistry.ts delete mode 100644 packages/ui/src/router/router.ts create mode 100644 packages/ui/src/router/useFocusedPaneRouter.ts create mode 100644 packages/ui/src/router/usePaneHistoryControls.ts create mode 100644 packages/ui/src/shell/AppShell.tsx create mode 100644 packages/workspace-server/src/db/migrations/0021_tab_owned_panes.sql create mode 100644 packages/workspace-server/src/db/migrations/meta/0021_snapshot.json diff --git a/apps/code/src/renderer/di/container.ts b/apps/code/src/renderer/di/container.ts index 807f63b398..5ae4923614 100644 --- a/apps/code/src/renderer/di/container.ts +++ b/apps/code/src/renderer/di/container.ts @@ -194,8 +194,8 @@ const browserTabsClient: BrowserTabsClient = { getPrimaryWindowId: () => trpcClient.browserTabs.getPrimaryWindowId.query(), openOrFocus: (input) => trpcClient.browserTabs.openOrFocus.mutate(input), newBlankTab: (input) => trpcClient.browserTabs.newBlankTab.mutate(input), - setTabTarget: (input) => trpcClient.browserTabs.setTabTarget.mutate(input), - close: (tabId) => trpcClient.browserTabs.close.mutate({ tabId }), + setPaneTarget: (input) => trpcClient.browserTabs.setPaneTarget.mutate(input), + close: (input) => trpcClient.browserTabs.close.mutate(input), setActiveTab: (input) => trpcClient.browserTabs.setActiveTab.mutate(input), onSnapshotChange: (sub) => trpcClient.browserTabs.onSnapshotChange.subscribe(undefined, sub), diff --git a/packages/core/src/browser-tabs/browserTabsStore.ts b/packages/core/src/browser-tabs/browserTabsStore.ts index a3b3a5ba94..7a190e0dc7 100644 --- a/packages/core/src/browser-tabs/browserTabsStore.ts +++ b/packages/core/src/browser-tabs/browserTabsStore.ts @@ -21,7 +21,7 @@ function snapshotsEqual(a: TabsSnapshot, b: TabsSnapshot): boolean { } export const browserTabsStore = createStore((set) => ({ - snapshot: { windows: [], tabs: [] }, + snapshot: { windows: [], tabs: [], panes: [] }, // Skip redundant writes: every tab mutation's onSuccess re-applies the // authoritative snapshot even when an optimistic write already set an equal // value. Without this guard that echo re-renders every subscriber and re-runs diff --git a/packages/host-router/src/routers/browser-tabs.router.ts b/packages/host-router/src/routers/browser-tabs.router.ts index 8fc00b8f51..d5143c9f94 100644 --- a/packages/host-router/src/routers/browser-tabs.router.ts +++ b/packages/host-router/src/routers/browser-tabs.router.ts @@ -3,13 +3,17 @@ import { publicProcedure, router } from "@posthog/host-trpc/trpc"; import { BROWSER_TABS_SERVICE } from "@posthog/workspace-server/di/tokens"; import { browserTabsSnapshotOutput, + closePaneInput, closeTabInput, closeTabsInput, + mergeTabIntoTabInput, newBlankTabInput, openOrFocusTabInput, setActiveTabInput, + setFocusedPaneInput, + setPaneSizesInput, + setPaneTargetInput, setTabOrderInput, - setTabTargetInput, } from "@posthog/workspace-server/services/browser-tabs/schemas"; import type { IBrowserTabsService } from "@posthog/workspace-server/services/browser-tabs/service"; import { z } from "zod"; @@ -36,15 +40,15 @@ export const browserTabsRouter = router({ .output(browserTabsSnapshotOutput) .mutation(({ ctx, input }) => svc(ctx.container).newBlankTab(input)), - setTabTarget: publicProcedure - .input(setTabTargetInput) + setPaneTarget: publicProcedure + .input(setPaneTargetInput) .output(browserTabsSnapshotOutput) - .mutation(({ ctx, input }) => svc(ctx.container).setTabTarget(input)), + .mutation(({ ctx, input }) => svc(ctx.container).setPaneTarget(input)), close: publicProcedure .input(closeTabInput) .output(browserTabsSnapshotOutput) - .mutation(({ ctx, input }) => svc(ctx.container).close(input.tabId)), + .mutation(({ ctx, input }) => svc(ctx.container).close(input)), closeMany: publicProcedure .input(closeTabsInput) @@ -53,6 +57,16 @@ export const browserTabsRouter = router({ svc(ctx.container).closeMany(input.tabIds, input.focusTabId), ), + closePane: publicProcedure + .input(closePaneInput) + .output(browserTabsSnapshotOutput) + .mutation(({ ctx, input }) => svc(ctx.container).closePane(input)), + + mergeTabIntoTab: publicProcedure + .input(mergeTabIntoTabInput) + .output(browserTabsSnapshotOutput) + .mutation(({ ctx, input }) => svc(ctx.container).mergeTabIntoTab(input)), + setOrder: publicProcedure .input(setTabOrderInput) .output(browserTabsSnapshotOutput) @@ -63,6 +77,16 @@ export const browserTabsRouter = router({ .output(browserTabsSnapshotOutput) .mutation(({ ctx, input }) => svc(ctx.container).setActiveTab(input)), + setFocusedPane: publicProcedure + .input(setFocusedPaneInput) + .output(browserTabsSnapshotOutput) + .mutation(({ ctx, input }) => svc(ctx.container).setFocusedPane(input)), + + setPaneSizes: publicProcedure + .input(setPaneSizesInput) + .output(browserTabsSnapshotOutput) + .mutation(({ ctx, input }) => svc(ctx.container).setPaneSizes(input)), + onSnapshotChange: publicProcedure.subscription(async function* (opts) { for await (const snapshot of svc(opts.ctx.container).snapshotChangeEvents( opts.signal, diff --git a/packages/shared/src/browser-pane-layout.test.ts b/packages/shared/src/browser-pane-layout.test.ts new file mode 100644 index 0000000000..822a938728 --- /dev/null +++ b/packages/shared/src/browser-pane-layout.test.ts @@ -0,0 +1,345 @@ +import { describe, expect, it } from "vitest"; +import { + collectLeafPaneIds, + insertNodeInLayout, + insertPaneInLayout, + normalizeLayout, + pathToPane, + removePaneFromLayout, + setSplitSizesAtPath, +} from "./browser-pane-layout"; +import type { PaneLayoutNode } from "./browser-tabs-schemas"; + +const leaf = (paneId: string): PaneLayoutNode => ({ type: "leaf", paneId }); + +function split( + direction: "row" | "column", + children: PaneLayoutNode[], + sizes?: number[], +): PaneLayoutNode { + return { + type: "split", + direction, + children, + sizes: sizes ?? children.map(() => 1 / children.length), + }; +} + +/** Narrow to a split so sizes/children can be asserted. */ +function asSplit(node: PaneLayoutNode | null) { + if (!node || node.type !== "split") { + throw new Error(`expected a split, got ${node?.type ?? "null"}`); + } + return node; +} + +function expectSizes(node: PaneLayoutNode | null, expected: number[]): void { + const s = asSplit(node); + expect(s.sizes).toHaveLength(expected.length); + s.sizes.forEach((size, i) => { + expect(size).toBeCloseTo(expected[i], 10); + }); +} + +describe("collectLeafPaneIds", () => { + it("returns leaves in depth-first display order", () => { + const tree = split("row", [ + leaf("p1"), + split("column", [leaf("p2"), leaf("p3")]), + leaf("p4"), + ]); + expect(collectLeafPaneIds(tree)).toEqual(["p1", "p2", "p3", "p4"]); + }); +}); + +describe("insertPaneInLayout", () => { + it.each([ + ["right", "row", ["p1", "new"]], + ["left", "row", ["new", "p1"]], + ["bottom", "column", ["p1", "new"]], + ["top", "column", ["new", "p1"]], + ] as const)( + "wraps a leaf in a two-child split on a %s drop", + (direction, axis, order) => { + const result = insertPaneInLayout(leaf("p1"), "p1", direction, "new"); + expect(result).toEqual( + split( + axis, + order.map((id) => leaf(id)), + [0.5, 0.5], + ), + ); + }, + ); + + it("splices into a same-axis parent as an n-ary sibling with an equal share", () => { + const root = split("row", [leaf("p1"), leaf("p2")], [0.5, 0.5]); + const result = insertPaneInLayout(root, "p1", "right", "new"); + expect(collectLeafPaneIds(result)).toEqual(["p1", "new", "p2"]); + expect(asSplit(result).children.every((c) => c.type === "leaf")).toBe(true); + expectSizes(result, [1 / 3, 1 / 3, 1 / 3]); + }); + + it("scales existing siblings down proportionally when splicing", () => { + const root = split("row", [leaf("p1"), leaf("p2")], [0.6, 0.4]); + const result = insertPaneInLayout(root, "p2", "right", "new"); + expect(collectLeafPaneIds(result)).toEqual(["p1", "p2", "new"]); + expectSizes(result, [0.4, 0.4 * (2 / 3), 1 / 3]); + }); + + it("wraps the target leaf when the drop axis crosses the parent split", () => { + const root = split("row", [leaf("p1"), leaf("p2")]); + const result = insertPaneInLayout(root, "p2", "bottom", "new"); + expect(result).toEqual( + split("row", [ + leaf("p1"), + split("column", [leaf("p2"), leaf("new")], [0.5, 0.5]), + ]), + ); + }); + + it("root drop on the matching axis splices as a last (or first) sibling", () => { + const root = split("row", [leaf("p1"), leaf("p2")], [0.5, 0.5]); + const atEnd = insertPaneInLayout(root, null, "right", "new"); + expect(collectLeafPaneIds(atEnd)).toEqual(["p1", "p2", "new"]); + expectSizes(atEnd, [1 / 3, 1 / 3, 1 / 3]); + const atStart = insertPaneInLayout(root, null, "left", "new"); + expect(collectLeafPaneIds(atStart)).toEqual(["new", "p1", "p2"]); + }); + + it("root drop on the crossing axis wraps the whole tree", () => { + const root = split("row", [leaf("p1"), leaf("p2")]); + const result = insertPaneInLayout(root, null, "bottom", "new"); + expect(result).toEqual(split("column", [root, leaf("new")], [0.5, 0.5])); + }); + + it("root drop on a leaf root wraps it", () => { + const result = insertPaneInLayout(leaf("p1"), null, "right", "new"); + expect(result).toEqual(split("row", [leaf("p1"), leaf("new")], [0.5, 0.5])); + }); + + it("returns the tree unchanged for an unknown target", () => { + const root = split("row", [leaf("p1"), leaf("p2")]); + expect(insertPaneInLayout(root, "nope", "right", "new")).toBe(root); + }); +}); + +describe("insertNodeInLayout", () => { + it("keeps a cross-axis subtree intact when wrapping", () => { + const subtree = split("column", [leaf("q1"), leaf("q2")]); + const result = insertNodeInLayout(leaf("p1"), "p1", "right", subtree); + expect(result).toEqual(split("row", [leaf("p1"), subtree], [0.5, 0.5])); + }); + + it("flattens a same-axis subtree when wrapping", () => { + const subtree = split("row", [leaf("q1"), leaf("q2")], [0.5, 0.5]); + const result = insertNodeInLayout(leaf("p1"), "p1", "right", subtree); + expect(collectLeafPaneIds(result)).toEqual(["p1", "q1", "q2"]); + expect(asSplit(result).children.every((c) => c.type === "leaf")).toBe(true); + expectSizes(result, [0.5, 0.25, 0.25]); + }); + + it("splices a same-axis subtree into a same-axis parent, sharing its slot", () => { + const root = split("row", [leaf("p1"), leaf("p2")], [0.5, 0.5]); + const subtree = split("row", [leaf("q1"), leaf("q2")], [0.5, 0.5]); + const result = insertNodeInLayout(root, "p2", "right", subtree); + expect(collectLeafPaneIds(result)).toEqual(["p1", "p2", "q1", "q2"]); + expect(asSplit(result).children.every((c) => c.type === "leaf")).toBe(true); + expectSizes(result, [1 / 3, 1 / 3, 1 / 6, 1 / 6]); + }); + + it("root drop on the matching axis splices the subtree as a sibling", () => { + const root = split("row", [leaf("p1"), leaf("p2")], [0.5, 0.5]); + const subtree = split("column", [leaf("q1"), leaf("q2")]); + const result = insertNodeInLayout(root, null, "right", subtree); + expect(collectLeafPaneIds(result)).toEqual(["p1", "p2", "q1", "q2"]); + expect(asSplit(result).children[2]).toEqual(subtree); + expectSizes(result, [1 / 3, 1 / 3, 1 / 3]); + }); + + it("inserts before the target on a left/top drop", () => { + const root = split("row", [leaf("p1"), leaf("p2")], [0.5, 0.5]); + const result = insertNodeInLayout(root, "p1", "left", leaf("q1")); + expect(collectLeafPaneIds(result)).toEqual(["q1", "p1", "p2"]); + }); + + it("returns the tree unchanged for an unknown target", () => { + const root = split("row", [leaf("p1"), leaf("p2")]); + expect(insertNodeInLayout(root, "nope", "right", leaf("q1"))).toBe(root); + }); +}); + +describe("removePaneFromLayout", () => { + it("returns null when the removed leaf was the root", () => { + expect(removePaneFromLayout(leaf("p1"), "p1")).toBeNull(); + }); + + it("collapses a split left with one child into that child", () => { + const root = split("row", [leaf("p1"), leaf("p2")]); + expect(removePaneFromLayout(root, "p2")).toEqual(leaf("p1")); + }); + + it("merges the removed pane's size into the survivors proportionally", () => { + const root = split( + "row", + [leaf("p1"), leaf("p2"), leaf("p3")], + [0.5, 0.3, 0.2], + ); + const result = removePaneFromLayout(root, "p2"); + expect(collectLeafPaneIds(asSplit(result))).toEqual(["p1", "p3"]); + expectSizes(result, [0.5 / 0.7, 0.2 / 0.7]); + }); + + it("collapses a deep nested split and keeps the parent intact", () => { + const root = split("row", [ + leaf("p1"), + split("column", [leaf("p2"), leaf("p3")]), + ]); + const result = removePaneFromLayout(root, "p3"); + expect(result).toEqual(split("row", [leaf("p1"), leaf("p2")], [0.5, 0.5])); + }); + + it("flattens a same-direction split exposed by the removal", () => { + // Removing p2 collapses the row into column[p3,p4], which nests + // same-direction under the root column → hoisted into it. + const root = split("column", [ + leaf("p1"), + split("row", [leaf("p2"), split("column", [leaf("p3"), leaf("p4")])]), + ]); + const result = removePaneFromLayout(root, "p2"); + expect(collectLeafPaneIds(asSplit(result))).toEqual(["p1", "p3", "p4"]); + expect(asSplit(result).children.every((c) => c.type === "leaf")).toBe(true); + expect(asSplit(result).direction).toBe("column"); + expectSizes(result, [0.5, 0.25, 0.25]); + }); + + it("returns the tree unchanged for an unknown pane", () => { + const root = split("row", [leaf("p1"), leaf("p2")]); + expect(removePaneFromLayout(root, "nope")).toBe(root); + }); +}); + +describe("setSplitSizesAtPath", () => { + it("sets (and renormalises) the sizes of the split at the path", () => { + const root = split("row", [leaf("p1"), leaf("p2")], [0.5, 0.5]); + const result = setSplitSizesAtPath(root, [], [3, 1]); + expectSizes(result, [0.75, 0.25]); + }); + + it("resizes a nested split without touching the parent", () => { + const root = split("row", [ + leaf("p1"), + split("column", [leaf("p2"), leaf("p3")], [0.5, 0.5]), + ]); + const result = setSplitSizesAtPath(root, [1], [1, 3]); + expectSizes(result, [0.5, 0.5]); + expectSizes(asSplit(result).children[1], [0.25, 0.75]); + }); + + it.each([ + ["wrong-length sizes", [] as number[], [1, 1, 1]], + ["non-positive sizes", [] as number[], [1, 0]], + ["negative sizes", [] as number[], [2, -1]], + ["an out-of-range path", [5], [1, 1]], + ["a path through a leaf", [0, 0], [1, 1]], + ])("is a no-op for %s", (_name, path, sizes) => { + const root = split("row", [leaf("p1"), leaf("p2")], [0.5, 0.5]); + expect(setSplitSizesAtPath(root, path, sizes)).toBe(root); + }); + + it("is a no-op when the root path resolves to a leaf", () => { + const root = leaf("p1"); + expect(setSplitSizesAtPath(root, [], [1])).toBe(root); + }); +}); + +describe("pathToPane", () => { + const tree = split("row", [ + leaf("p1"), + split("column", [leaf("p2"), leaf("p3")]), + ]); + + it.each([ + ["p1", [0]], + ["p2", [1, 0]], + ["p3", [1, 1]], + ] as const)("finds %s at %j", (paneId, path) => { + expect(pathToPane(tree, paneId)).toEqual(path); + }); + + it("returns [] for the root leaf itself", () => { + expect(pathToPane(leaf("p1"), "p1")).toEqual([]); + }); + + it("returns null for a pane not in the tree", () => { + expect(pathToPane(tree, "nope")).toBeNull(); + }); +}); + +describe("normalizeLayout", () => { + it("flattens same-direction nesting, scaling grandchild shares", () => { + const tree = split( + "row", + [leaf("p1"), split("row", [leaf("p2"), leaf("p3")], [0.5, 0.5])], + [0.5, 0.5], + ); + const result = normalizeLayout(tree); + expect(collectLeafPaneIds(asSplit(result))).toEqual(["p1", "p2", "p3"]); + expect(asSplit(result).children.every((c) => c.type === "leaf")).toBe(true); + expectSizes(result, [0.5, 0.25, 0.25]); + }); + + it("renormalises sizes that do not sum to 1", () => { + const result = normalizeLayout( + split("row", [leaf("p1"), leaf("p2")], [2, 2]), + ); + expectSizes(result, [0.5, 0.5]); + }); + + it("replaces invalid sizes (wrong length / non-positive) with equal shares", () => { + const wrongLength = normalizeLayout( + split("row", [leaf("p1"), leaf("p2")], [1]), + ); + expectSizes(wrongLength, [0.5, 0.5]); + const nonPositive = normalizeLayout( + split("row", [leaf("p1"), leaf("p2")], [0, 1]), + ); + expectSizes(nonPositive, [0.5, 0.5]); + }); + + it("collapses a single-child split into its child", () => { + expect(normalizeLayout(split("row", [leaf("p1")], [1]))).toEqual( + leaf("p1"), + ); + }); + + it("returns null for an empty split", () => { + expect(normalizeLayout(split("row", [], []))).toBeNull(); + }); + + it("passes a leaf through with the same reference", () => { + const node = leaf("p1"); + expect(normalizeLayout(node)).toBe(node); + }); + + it("keeps a canonical tree's shape", () => { + const tree = split( + "row", + [leaf("p1"), split("column", [leaf("p2"), leaf("p3")], [0.5, 0.5])], + [0.5, 0.5], + ); + expect(normalizeLayout(tree)).toEqual(tree); + }); + + it("passes a canonical SPLIT through with the same reference", () => { + // Healing decides whether to re-persist by reference equality, so an + // already-canonical split must come back identical — otherwise every load + // of a split layout rewrites the database. + const tree = split( + "row", + [leaf("p1"), split("column", [leaf("p2"), leaf("p3")], [0.5, 0.5])], + [0.3, 0.7], + ); + expect(normalizeLayout(tree)).toBe(tree); + }); +}); diff --git a/packages/shared/src/browser-pane-layout.ts b/packages/shared/src/browser-pane-layout.ts new file mode 100644 index 0000000000..18ffb2c5d8 --- /dev/null +++ b/packages/shared/src/browser-pane-layout.ts @@ -0,0 +1,272 @@ +import type { + PaneLayoutNode, + SplitDropDirection, +} from "./browser-tabs-schemas"; + +/** + * Pure geometry math for the pane layout tree ({@link PaneLayoutNode}). + * Snapshot-level pane transforms (which also move panes and focus) live in + * `browser-tabs.ts`; this module never sees tabs or panes rows, only the tree. + * + * Canonical form (maintained by every function here, enforceable via + * {@link normalizeLayout}): splits hold >= 2 children, `sizes` is parallel to + * `children`, positive, summing to 1, and a split never directly contains a + * child split with the same direction (same-direction children are flattened + * into the parent, VS Code style). + */ + +const EQUAL = (n: number): number[] => Array.from({ length: n }, () => 1 / n); + +function renormalize(sizes: number[], count: number): number[] { + if (sizes.length !== count || sizes.some((s) => !(s > 0))) { + return EQUAL(count); + } + const total = sizes.reduce((a, b) => a + b, 0); + if (total <= 0) return EQUAL(count); + // Already normalized → keep the input's identity, so canonical trees pass + // through normalizeLayout unchanged (healing relies on reference equality + // to decide whether to re-persist). + if (Math.abs(total - 1) < 1e-9) return sizes; + return sizes.map((s) => s / total); +} + +function directionOf(drop: SplitDropDirection): "row" | "column" { + return drop === "left" || drop === "right" ? "row" : "column"; +} + +/** Leaf pane ids in depth-first (display) order. */ +export function collectLeafPaneIds(node: PaneLayoutNode): string[] { + if (node.type === "leaf") return [node.paneId]; + return node.children.flatMap(collectLeafPaneIds); +} + +/** + * Insert an arbitrary subtree next to `targetPaneId` on the `direction` side — + * `targetPaneId: null` targets the layout root (content-area edge drops). + * If the insertion axis matches the surrounding split, the node is spliced in + * as a sibling (n-ary); otherwise the target is wrapped in a new two-child + * split. The inserted subtree takes an equal share of the affected split; + * existing siblings scale down proportionally. The result is normalized, so a + * same-direction split node flattens into its new parent (its children share + * the inserted slot proportionally). Unknown target → tree returned unchanged. + */ +export function insertNodeInLayout( + root: PaneLayoutNode, + targetPaneId: string | null, + direction: SplitDropDirection, + node: PaneLayoutNode, +): PaneLayoutNode { + const axis = directionOf(direction); + const before = direction === "left" || direction === "top"; + + const wrap = (target: PaneLayoutNode): PaneLayoutNode => ({ + type: "split", + direction: axis, + children: before ? [node, target] : [target, node], + sizes: [0.5, 0.5], + }); + + const inserted = (() => { + if (targetPaneId === null) { + // Root drop: splice into a same-axis root split, else wrap the tree. + if (root.type === "split" && root.direction === axis) { + return spliceSibling(root, before ? 0 : root.children.length, node); + } + return wrap(root); + } + + const insert = (current: PaneLayoutNode): PaneLayoutNode | null => { + if (current.type === "leaf") { + return current.paneId === targetPaneId ? wrap(current) : null; + } + for (let i = 0; i < current.children.length; i++) { + const child = current.children[i]; + // Target leaf directly under a same-axis split → splice as a sibling + // instead of nesting a redundant same-direction split. + if ( + child.type === "leaf" && + child.paneId === targetPaneId && + current.direction === axis + ) { + return spliceSibling(current, before ? i : i + 1, node); + } + const replaced = insert(child); + if (replaced) { + const children = current.children.map((c, j) => + j === i ? replaced : c, + ); + return { ...current, children }; + } + } + return null; + }; + + return insert(root); + })(); + + if (inserted == null) return root; + // The inserted node may itself be a split on the insertion axis (merging a + // multi-pane tab) — normalize flattens that same-direction nesting. + return normalizeLayout(inserted) ?? root; +} + +/** + * Insert a new pane next to `targetPaneId` on the `direction` side. The + * single-leaf specialisation of {@link insertNodeInLayout}. + */ +export function insertPaneInLayout( + root: PaneLayoutNode, + targetPaneId: string | null, + direction: SplitDropDirection, + newPaneId: string, +): PaneLayoutNode { + return insertNodeInLayout(root, targetPaneId, direction, { + type: "leaf", + paneId: newPaneId, + }); +} + +/** Splice a new child into a split at `index`, giving it an equal share. */ +function spliceSibling( + split: Extract, + index: number, + child: PaneLayoutNode, +): PaneLayoutNode { + const n = split.children.length; + const sizes = renormalize(split.sizes, n); + const share = 1 / (n + 1); + const scaled = sizes.map((s) => s * (1 - share)); + const children = [...split.children]; + children.splice(index, 0, child); + scaled.splice(index, 0, share); + return { ...split, children, sizes: scaled }; +} + +/** + * Remove a pane's leaf. Its size merges into the remaining siblings + * (proportionally); a split left with one child collapses into that child. + * Returns null when the tree is now empty (the removed leaf was the root). + * Unknown pane → tree returned unchanged. + */ +export function removePaneFromLayout( + root: PaneLayoutNode, + paneId: string, +): PaneLayoutNode | null { + if (root.type === "leaf") { + return root.paneId === paneId ? null : root; + } + const removed = root.children + .map((child) => removePaneFromLayout(child, paneId)) + .map((child, i) => ({ child, i })) + .filter((x): x is { child: PaneLayoutNode; i: number } => x.child !== null); + if (removed.length === root.children.length) { + // Nothing removed below, but a child may have been rewritten. + const children = removed.map((x) => x.child); + const changed = children.some((c, i) => c !== root.children[i]); + return changed ? normalizeLayout({ ...root, children }) : root; + } + if (removed.length === 0) return null; + if (removed.length === 1) return removed[0].child; + const orig = renormalize(root.sizes, root.children.length); + const sizes = renormalize( + removed.map((x) => orig[x.i]), + removed.length, + ); + return normalizeLayout({ + ...root, + children: removed.map((x) => x.child), + sizes, + }); +} + +/** + * Set the sizes of the split addressed by `path` (child indices from the + * root). Validated: the path must resolve to a split and `sizes` must be + * positive and parallel to its children, else the tree is returned unchanged. + * Resize is a local low-contention gesture, so path addressing beats minting + * split-node ids. + */ +export function setSplitSizesAtPath( + root: PaneLayoutNode, + path: number[], + sizes: number[], +): PaneLayoutNode { + if (path.length === 0) { + if ( + root.type !== "split" || + sizes.length !== root.children.length || + sizes.some((s) => !(s > 0)) + ) { + return root; + } + return { ...root, sizes: renormalize(sizes, sizes.length) }; + } + const [head, ...rest] = path; + if (root.type !== "split" || head < 0 || head >= root.children.length) { + return root; + } + const child = setSplitSizesAtPath(root.children[head], rest, sizes); + if (child === root.children[head]) return root; + return { + ...root, + children: root.children.map((c, i) => (i === head ? child : c)), + }; +} + +/** Path (child indices from the root) to the split node containing `paneId`'s + * leaf, or to any node — used by the renderer to address resizes. Returns null + * when the pane is not in the tree. */ +export function pathToPane( + root: PaneLayoutNode, + paneId: string, +): number[] | null { + if (root.type === "leaf") { + return root.paneId === paneId ? [] : null; + } + for (let i = 0; i < root.children.length; i++) { + const sub = pathToPane(root.children[i], paneId); + if (sub !== null) return [i, ...sub]; + } + return null; +} + +/** + * Canonicalise a tree: drop empty splits, collapse single-child splits, + * flatten same-direction nesting, renormalise sizes. Null when nothing + * remains. The healing primitive behind `ensureSnapshotIntegrity`. + */ +export function normalizeLayout(node: PaneLayoutNode): PaneLayoutNode | null { + if (node.type === "leaf") return node; + const sizes = renormalize(node.sizes, node.children.length); + const flattened: { child: PaneLayoutNode; size: number }[] = []; + let changed = node.sizes !== sizes; + node.children.forEach((raw, i) => { + const child = normalizeLayout(raw); + if (child === null) { + changed = true; + return; + } + if (child !== raw) changed = true; + if (child.type === "split" && child.direction === node.direction) { + // Same-direction nesting: hoist grandchildren, scaling their share. + changed = true; + child.children.forEach((grand, j) => { + flattened.push({ child: grand, size: sizes[i] * child.sizes[j] }); + }); + return; + } + flattened.push({ child, size: sizes[i] }); + }); + if (flattened.length === 0) return null; + if (flattened.length === 1) return flattened[0].child; + if (!changed) return node; + return { + type: "split", + direction: node.direction, + children: flattened.map((x) => x.child), + sizes: renormalize( + flattened.map((x) => x.size), + flattened.length, + ), + }; +} diff --git a/packages/shared/src/browser-tabs-schemas.ts b/packages/shared/src/browser-tabs-schemas.ts index c7f36d3329..9b95edc709 100644 --- a/packages/shared/src/browser-tabs-schemas.ts +++ b/packages/shared/src/browser-tabs-schemas.ts @@ -3,39 +3,99 @@ import { z } from "zod"; /** * Persisted browser-tab domain shapes for the Channels canvas surface. * - * A tab stores references only (which canvas, which channel, which window, - * and where in the strip). Display — label, icon, channel-hover — is resolved - * at render time from the dashboard/channel records, never denormalised here. + * v2 model — one tab strip per window; each TAB owns a split-pane layout: + * + * window → tabs (strip units, each with a `layout` tree + focused pane) + * → panes (content units carrying the identity: canvas / task / + * channel / app view) + * + * A pane stores references only (which canvas, which channel). Display — + * label, icon, channel-hover — is resolved at render time from the + * dashboard/channel records, never denormalised here. A tab's label/icon + * derive from its FOCUSED pane's identity. * * `scrollState` is reserved for a later follow-up (scroll restoration needs a * sandbox postMessage contract). It is persisted as opaque JSON so adding it * needs no migration. */ -export const browserTabSchema = z.object({ + +/** Where a dragged tab was dropped relative to a pane (or the layout root). */ +export const splitDropDirectionSchema = z.enum([ + "left", + "right", + "top", + "bottom", +]); +export type SplitDropDirection = z.infer; + +/** + * Recursive split layout for a tab's panes. A leaf references a pane row by + * id; a split lays its children out along `direction` ("row" = side by side, + * "column" = stacked) with `sizes` as fractions parallel to `children` + * (renormalised on heal). Splits always hold >= 2 children and never nest a + * same-direction split (both canonicalised by `normalizeLayout`). + * + * The explicit `z.ZodType` annotation is required: `z.lazy` + * cannot infer a recursive type, and the annotation also keeps the tRPC-wire + * inference on the client identical to this type (same concern as + * `scrollState` below). + */ +export type PaneLayoutNode = + | { type: "leaf"; paneId: string } + | { + type: "split"; + direction: "row" | "column"; + children: PaneLayoutNode[]; + sizes: number[]; + }; + +export const paneLayoutNodeSchema: z.ZodType = z.lazy(() => + z.union([ + z.object({ type: z.literal("leaf"), paneId: z.string() }), + z.object({ + type: z.literal("split"), + direction: z.enum(["row", "column"]), + children: z.array(paneLayoutNodeSchema).min(2), + sizes: z.array(z.number()), + }), + ]), +); + +/** + * A pane: one content area inside a tab's split layout. Carries the tab + * system's identity fields — what the pane shows. A single-pane tab (the + * common case) has exactly one of these behind a bare-leaf layout. + */ +export const browserPaneSchema = z.object({ id: z.string(), + /** Tab whose layout tree this pane's leaf lives in. */ + tabId: z.string(), + /** + * Denormalised from the owning tab so window-scoped queries (identity dedup + * on open) need no tab join. Invariant (healed): matches the tab's window. + */ windowId: z.string(), - /** Canvas this tab shows. Null for a task tab or a blank tab. */ + /** Canvas this pane shows. Null for a task pane or a blank pane. */ dashboardId: z.string().nullable(), - /** Task this tab shows. Null for a canvas tab or a blank tab. */ + /** Task this pane shows. Null for a canvas pane or a blank pane. */ taskId: z.string().nullable().default(null), channelId: z.string().nullable().default(null), /** - * Channel sub-section this tab fronts (`artifacts` / `history` / - * `context`). Null = the channel home, or a non-channel tab (canvas / task / - * blank). Pairs with `channelId`: the two together identify a channel tab. + * Channel sub-section this pane fronts (`artifacts` / `history` / + * `context`). Null = the channel home, or a non-channel pane (canvas / task / + * blank). Pairs with `channelId`: the two together identify a channel pane. */ channelSection: z.string().nullable().default(null), /** - * Top-level app page this tab shows (`inbox` / `agents` / `skills` / + * Top-level app page this pane shows (`inbox` / `agents` / `skills` / * `mcp-servers` / `command-center` / `home`). Null for a canvas / task / - * channel / blank tab. These pages have no channel, task, or dashboard id, so - * this is what lets them be a real tab target (label + restore-on-refocus). + * channel / blank pane. These pages have no channel, task, or dashboard id, + * so this is what lets them be a real pane target (label + + * restore-on-refocus). */ appView: z.string().nullable().default(null), - /** Gap-spaced ordering key within a window. Reindexed on collision. */ - position: z.number(), /** - * Reserved/unwired. Opaque per-tab state for future scroll restoration etc. + * Reserved/unwired. Opaque per-pane state for future scroll restoration etc. * Plain `z.unknown()` (not `.default(null)`) so the inferred shape matches * the tRPC-wire inference on the client — keeps the renderer facade type and * the transport type identical. @@ -44,6 +104,30 @@ export const browserTabSchema = z.object({ createdAt: z.number(), lastActiveAt: z.number(), }); +export type BrowserPane = z.infer; + +/** + * A strip unit. The tab owns a pane layout: a bare leaf for the common + * single-pane tab, a split tree after a merge. Identity lives on the panes; + * the strip pill renders the focused pane's identity (plus a layout glyph + * when split). + */ +export const browserTabSchema = z.object({ + id: z.string(), + windowId: z.string(), + /** Root of this tab's pane layout. A single-pane tab is a bare leaf. */ + layout: paneLayoutNodeSchema, + /** + * Pane that owns focus within this tab: keyboard shortcuts, the default + * navigation target, the global back/forward buttons, and the pill's + * label/icon all act on this pane. Invariant (healed): a leaf of `layout`. + */ + focusedPaneId: z.string(), + /** Gap-spaced ordering key within a window. Reindexed on collision. */ + position: z.number(), + createdAt: z.number(), + lastActiveAt: z.number(), +}); export type BrowserTab = z.infer; export const windowBoundsSchema = z.object({ @@ -68,5 +152,6 @@ export type BrowserWindow = z.infer; export const tabsSnapshotSchema = z.object({ windows: z.array(browserWindowSchema), tabs: z.array(browserTabSchema), + panes: z.array(browserPaneSchema), }); export type TabsSnapshot = z.infer; diff --git a/packages/shared/src/browser-tabs.test.ts b/packages/shared/src/browser-tabs.test.ts index 955f63a8a7..c83a0c0d0b 100644 --- a/packages/shared/src/browser-tabs.test.ts +++ b/packages/shared/src/browser-tabs.test.ts @@ -1,1314 +1,880 @@ import { describe, expect, it } from "vitest"; import { activeTabIsBlank, + BLANK_PANE_IDENTITY, + closePane, closeTab, closeTabs, - decideTabNavigation, + decidePaneNavigation, + ensureSnapshotIntegrity, + focusedPaneOfTab, + mergeTabIntoTab, newBlankTab, openOrFocusTab, + type PaneIdentity, POSITION_GAP, - primaryWindow, primaryWindowHasNoTabs, + setFocusedPane, + setPaneSizes, + setPaneTarget, setTabOrder, - setTabTarget, setWindowActiveTab, + tabPanes, } from "./browser-tabs"; -import type { TabsSnapshot } from "./browser-tabs-schemas"; +import type { + BrowserPane, + BrowserTab, + BrowserWindow, + PaneLayoutNode, + TabsSnapshot, +} from "./browser-tabs-schemas"; + +const NOW = 1_700_000_000_000; +const now = () => NOW; + +/** Deterministic id factory: minted-1, minted-2, … per call site. */ +function idFactory(prefix = "minted") { + let n = 0; + return () => `${prefix}-${++n}`; +} -let idCounter = 0; -const makeId = () => `tab-${++idCounter}`; -let clock = 0; -const now = () => ++clock; +const identity = (over: Partial = {}): PaneIdentity => ({ + ...BLANK_PANE_IDENTITY, + ...over, +}); -function snapshot(partial?: Partial): TabsSnapshot { +function win(id: string, over: Partial = {}): BrowserWindow { + return { id, isPrimary: false, bounds: null, activeTabId: null, ...over }; +} + +function pane( + id: string, + tabId: string, + windowId: string, + over: Partial = {}, +): BrowserPane { return { - windows: [{ id: "w1", isPrimary: true, bounds: null, activeTabId: null }], - tabs: [], - ...partial, + id, + tabId, + windowId, + ...BLANK_PANE_IDENTITY, + scrollState: null, + createdAt: NOW, + lastActiveAt: NOW, + ...over, }; } -function open( - s: TabsSnapshot, +function tab( + id: string, windowId: string, - dashboardId: string, - channelId: string | null = "c1", -) { - return openOrFocusTab(s, { + position: number, + over: Partial = {}, +): BrowserTab { + return { + id, windowId, - dashboardId, - taskId: null, - channelId, - makeId, - now, - }); + layout: { type: "leaf", paneId: `${id}-pane` }, + focusedPaneId: `${id}-pane`, + position, + createdAt: NOW, + lastActiveAt: NOW, + ...over, + }; } -describe("openOrFocusTab", () => { - it("opens a new tab and makes it active", () => { - const r = open(snapshot(), "w1", "dash-a"); - expect(r.opened).toBe(true); - expect(r.snapshot.tabs).toHaveLength(1); - expect(r.snapshot.windows[0].activeTabId).toBe(r.tabId); - expect(r.snapshot.tabs[0].position).toBe(POSITION_GAP); - }); +/** + * A primary window with two single-pane tabs: t1 (pane t1-pane, showing + * dashboard d1) active, and t2 (pane t2-pane, showing task k1). + */ +function baseSnapshot(): TabsSnapshot { + return { + windows: [win("w1", { isPrimary: true, activeTabId: "t1" })], + tabs: [tab("t1", "w1", 1000), tab("t2", "w1", 2000)], + panes: [ + pane("t1-pane", "t1", "w1", { dashboardId: "d1" }), + pane("t2-pane", "t2", "w1", { taskId: "k1" }), + ], + }; +} - it("dedups within a window: focuses the existing tab instead of opening", () => { - const first = open(snapshot(), "w1", "dash-a"); - const second = open(first.snapshot, "w1", "dash-a"); - expect(second.opened).toBe(false); - expect(second.tabId).toBe(first.tabId); - expect(second.snapshot.tabs).toHaveLength(1); - }); +function activeTabId(s: TabsSnapshot, windowId = "w1"): string | null { + const w = s.windows.find((x) => x.id === windowId); + return w ? w.activeTabId : null; +} + +const split = ( + direction: "row" | "column", + children: PaneLayoutNode[], + sizes?: number[], +): PaneLayoutNode => ({ + type: "split", + direction, + children, + sizes: sizes ?? children.map(() => 1 / children.length), +}); + +const leaf = (paneId: string): PaneLayoutNode => ({ type: "leaf", paneId }); - it("allows the same canvas in two different windows", () => { - const twoWindows = snapshot({ - windows: [ - { id: "w1", isPrimary: true, bounds: null, activeTabId: null }, - { id: "w2", isPrimary: false, bounds: null, activeTabId: null }, - ], +describe("openOrFocusTab", () => { + it("focuses the tab whose pane already shows the identity", () => { + const result = openOrFocusTab(baseSnapshot(), { + windowId: "w1", + ...identity({ taskId: "k1" }), + makeId: idFactory(), + now, }); - const a = open(twoWindows, "w1", "dash-a"); - const b = open(a.snapshot, "w2", "dash-a"); - expect(b.opened).toBe(true); - expect(b.snapshot.tabs).toHaveLength(2); + expect(result.opened).toBe(false); + expect(result.tabId).toBe("t2"); + expect(result.paneId).toBe("t2-pane"); + expect(activeTabId(result.snapshot)).toBe("t2"); + expect(result.snapshot.tabs).toHaveLength(2); }); - it("treats a channel's sections as distinct tabs but dedups the same one", () => { - const history = openOrFocusTab(snapshot(), { + it("focuses the pane within a multi-pane tab on dedup", () => { + let s = baseSnapshot(); + // t1 owns two panes; the deduped one is NOT currently focused. + s = { + ...s, + tabs: s.tabs.map((t) => + t.id === "t1" + ? { + ...t, + layout: split("row", [leaf("t1-pane"), leaf("extra")]), + focusedPaneId: "t1-pane", + } + : t, + ), + panes: [...s.panes, pane("extra", "t1", "w1", { taskId: "k9" })], + }; + const result = openOrFocusTab(s, { windowId: "w1", - dashboardId: null, - taskId: null, - channelId: "c1", - channelSection: "history", - makeId, + ...identity({ taskId: "k9" }), + makeId: idFactory(), now, }); - const artifacts = openOrFocusTab(history.snapshot, { + expect(result.opened).toBe(false); + expect(result.tabId).toBe("t1"); + expect(result.paneId).toBe("extra"); + const t1 = result.snapshot.tabs.find((t) => t.id === "t1"); + expect(t1?.focusedPaneId).toBe("extra"); + }); + + it("appends a new single-pane tab when the identity is not open", () => { + const result = openOrFocusTab(baseSnapshot(), { windowId: "w1", - dashboardId: null, - taskId: null, - channelId: "c1", - channelSection: "artifacts", - makeId, + ...identity({ dashboardId: "d2" }), + makeId: idFactory(), now, }); - expect(artifacts.opened).toBe(true); - expect(artifacts.snapshot.tabs).toHaveLength(2); - const historyAgain = openOrFocusTab(artifacts.snapshot, { + expect(result.opened).toBe(true); + const created = result.snapshot.tabs.find((t) => t.id === result.tabId); + expect(created).toBeDefined(); + expect(created?.layout).toEqual(leaf(result.paneId)); + expect(created?.focusedPaneId).toBe(result.paneId); + expect(created?.position).toBe(2000 + POSITION_GAP); + const createdPane = result.snapshot.panes.find( + (p) => p.id === result.paneId, + ); + expect(createdPane?.dashboardId).toBe("d2"); + expect(createdPane?.tabId).toBe(result.tabId); + expect(activeTabId(result.snapshot)).toBe(result.tabId); + }); + + it("honours renderer-minted tab and pane ids", () => { + const result = openOrFocusTab(baseSnapshot(), { windowId: "w1", - dashboardId: null, - taskId: null, - channelId: "c1", - channelSection: "history", - makeId, + ...identity({ dashboardId: "d2" }), + tabId: "my-tab", + paneId: "my-pane", + makeId: idFactory(), now, }); - expect(historyAgain.opened).toBe(false); - expect(historyAgain.tabId).toBe(history.tabId); + expect(result.tabId).toBe("my-tab"); + expect(result.paneId).toBe("my-pane"); }); - it("appends new tabs after existing ones", () => { - const a = open(snapshot(), "w1", "dash-a"); - const b = open(a.snapshot, "w1", "dash-b"); - const positions = b.snapshot.tabs - .map((t) => t.position) - .sort((x, y) => x - y); - expect(positions).toEqual([POSITION_GAP, POSITION_GAP * 2]); + it("allows the same identity in another window", () => { + let s = baseSnapshot(); + s = { ...s, windows: [...s.windows, win("w2")] }; + const result = openOrFocusTab(s, { + windowId: "w2", + ...identity({ taskId: "k1" }), + makeId: idFactory(), + now, + }); + expect(result.opened).toBe(true); + expect(result.snapshot.tabs).toHaveLength(3); }); }); -describe("closeTab", () => { - it("focuses the neighbouring tab when the active tab closes", () => { - let s = snapshot(); - const a = open(s, "w1", "dash-a"); - const b = open(a.snapshot, "w1", "dash-b"); - s = b.snapshot; // active = b - const r = closeTab(s, b.tabId); - expect(r.snapshot.tabs).toHaveLength(1); - expect(r.nextActiveTabId).toBe(a.tabId); - expect(r.snapshot.windows[0].activeTabId).toBe(a.tabId); +describe("newBlankTab", () => { + it("appends a focused blank single-pane tab", () => { + const result = newBlankTab(baseSnapshot(), { + windowId: "w1", + makeId: idFactory(), + now, + }); + expect(result.opened).toBe(true); + const created = result.snapshot.panes.find((p) => p.id === result.paneId); + expect(created?.dashboardId).toBeNull(); + expect(created?.taskId).toBeNull(); + expect(activeTabId(result.snapshot)).toBe(result.tabId); }); +}); - it("closes a secondary window when its last tab closes", () => { - const s = snapshot({ - windows: [ - { id: "w1", isPrimary: true, bounds: null, activeTabId: null }, - { id: "w2", isPrimary: false, bounds: null, activeTabId: null }, - ], +describe("setPaneTarget", () => { + it("points the pane at the identity and focuses pane, tab, and window", () => { + let s = baseSnapshot(); + s = setWindowActiveTab(s, "w1", "t2"); + const next = setPaneTarget(s, { + paneId: "t1-pane", + ...identity({ channelId: "c1", channelSection: "artifacts" }), + now: () => NOW + 5, }); - const t = open(s, "w2", "dash-a"); - const r = closeTab(t.snapshot, t.tabId); - expect(r.closedWindowId).toBe("w2"); - expect(r.snapshot.windows.map((w) => w.id)).toEqual(["w1"]); + const p = next.panes.find((x) => x.id === "t1-pane"); + expect(p?.channelId).toBe("c1"); + expect(p?.channelSection).toBe("artifacts"); + expect(p?.dashboardId).toBeNull(); + expect(p?.lastActiveAt).toBe(NOW + 5); + expect(activeTabId(next)).toBe("t1"); + expect(next.tabs.find((t) => t.id === "t1")?.focusedPaneId).toBe("t1-pane"); }); - it("shows the landing (null active) when the primary's last tab closes", () => { - const t = open(snapshot(), "w1", "dash-a"); - const r = closeTab(t.snapshot, t.tabId); - expect(r.closedWindowId).toBeNull(); - expect(r.snapshot.windows[0].activeTabId).toBeNull(); - expect(r.snapshot.tabs).toHaveLength(0); + it("is a no-op for an unknown pane", () => { + const s = baseSnapshot(); + expect(setPaneTarget(s, { paneId: "nope", ...identity(), now })).toBe(s); }); }); -describe("newBlankTab", () => { - it("appends a focused blank tab with no canvas", () => { - const existing = open(snapshot(), "w1", "dash-a"); - const r = newBlankTab(existing.snapshot, { windowId: "w1", makeId, now }); - expect(r.snapshot.tabs).toHaveLength(2); - const blank = r.snapshot.tabs.find((t) => t.id === r.tabId); - expect(blank?.dashboardId).toBeNull(); - expect(r.snapshot.windows[0].activeTabId).toBe(r.tabId); +describe("setWindowActiveTab", () => { + it("activates a tab in its window", () => { + const next = setWindowActiveTab(baseSnapshot(), "w1", "t2"); + expect(activeTabId(next)).toBe("t2"); }); -}); -describe("setTabTarget", () => { - it("points an existing tab at a canvas and focuses it (in-tab nav)", () => { - const blank = newBlankTab(snapshot(), { windowId: "w1", makeId, now }); - const next = setTabTarget(blank.snapshot, { - tabId: blank.tabId, - dashboardId: "dash-x", - taskId: null, - channelId: "c1", - now, - }); - const tab = next.tabs.find((t) => t.id === blank.tabId); - expect(tab?.dashboardId).toBe("dash-x"); - expect(tab?.channelId).toBe("c1"); - expect(next.tabs).toHaveLength(1); // replaced contents, no new tab - expect(next.windows[0].activeTabId).toBe(blank.tabId); + it("ignores a tab id that no longer exists (stale replay)", () => { + const s = baseSnapshot(); + expect(setWindowActiveTab(s, "w1", "ghost")).toBe(s); }); - it("points an existing tab at a task (tasks are first-class targets)", () => { - const blank = newBlankTab(snapshot(), { windowId: "w1", makeId, now }); - const next = setTabTarget(blank.snapshot, { - tabId: blank.tabId, - dashboardId: null, - taskId: "task-9", - channelId: "c1", - now, - }); - const tab = next.tabs.find((t) => t.id === blank.tabId); - expect(tab?.taskId).toBe("task-9"); - expect(tab?.dashboardId).toBeNull(); + it("ignores a tab that lives in another window", () => { + let s = baseSnapshot(); + s = { + ...s, + windows: [...s.windows, win("w2")], + tabs: [...s.tabs, tab("t3", "w2", 1000)], + panes: [...s.panes, pane("t3-pane", "t3", "w2")], + }; + expect(setWindowActiveTab(s, "w1", "t3")).toBe(s); }); - it("is a no-op for an unknown tab id", () => { - const s = snapshot(); - expect( - setTabTarget(s, { - tabId: "nope", - dashboardId: "d", - taskId: null, - channelId: null, - now, - }), - ).toBe(s); + it("clears focus with null", () => { + const next = setWindowActiveTab(baseSnapshot(), "w1", null); + expect(activeTabId(next)).toBeNull(); }); }); -describe("decideTabNavigation", () => { - const base = { - historyTabId: null as string | null, - serverActiveTabId: null as string | null, - activeTab: null as { - id: string; - dashboardId: string | null; - taskId: string | null; - } | null, - routeDashboardId: null as string | null, - routeTaskId: null as string | null, - routeChannelId: null as string | null, +describe("setFocusedPane", () => { + const multiPane = (): TabsSnapshot => { + const s = baseSnapshot(); + return { + ...s, + tabs: s.tabs.map((t) => + t.id === "t1" + ? { + ...t, + layout: split("row", [leaf("t1-pane"), leaf("extra")]), + } + : t, + ), + panes: [...s.panes, pane("extra", "t1", "w1")], + }; }; - it("activates the tagged tab on a switch / back-forward replay", () => { - expect( - decideTabNavigation({ - ...base, - historyTabId: "tab-b", - serverActiveTabId: "tab-a", - }), - ).toEqual({ type: "activate", tabId: "tab-b" }); + it("focuses a pane of the tab", () => { + const next = setFocusedPane(multiPane(), "t1", "extra"); + expect(next.tabs.find((t) => t.id === "t1")?.focusedPaneId).toBe("extra"); }); - it("back to the previous tab activates it (history entry tagged with that tab)", () => { - // After switching A→B, pressing back lands on A's entry: historyTabId=A - // while the server still thinks B is active → activate A. - expect( - decideTabNavigation({ - ...base, - historyTabId: "tab-a", - serverActiveTabId: "tab-b", - }), - ).toEqual({ type: "activate", tabId: "tab-a" }); + it("is a no-op when the pane belongs to another tab", () => { + const s = multiPane(); + expect(setFocusedPane(s, "t1", "t2-pane")).toBe(s); }); - it("is a noop when the tagged tab is already active", () => { - expect( - decideTabNavigation({ - ...base, - historyTabId: "tab-a", - serverActiveTabId: "tab-a", - }), - ).toEqual({ type: "noop" }); + it("is a no-op when already focused", () => { + const s = multiPane(); + expect(setFocusedPane(s, "t1", "t1-pane")).toBe(s); }); +}); - it("replaces the active tab's canvas on an untagged in-tab nav", () => { - expect( - decideTabNavigation({ - ...base, - serverActiveTabId: "tab-a", - activeTab: { id: "tab-a", dashboardId: "old", taskId: null }, - routeDashboardId: "new", - routeChannelId: "c1", - }), - ).toEqual({ - type: "replace", - tabId: "tab-a", - dashboardId: "new", - taskId: null, - channelId: "c1", - channelSection: null, - appView: null, - stampTabId: "tab-a", - }); - }); +describe("closeTab", () => { + const deps = () => ({ makeId: idFactory(), now }); - it("replaces even when the entry is tagged with the active tab (inherited tag)", () => { - // A plain navigate (sidebar) inherits the active tab's tag, so an in-tab nav - // arrives tagged with the active tab. It must still replace, not noop. - expect( - decideTabNavigation({ - ...base, - historyTabId: "tab-a", - serverActiveTabId: "tab-a", - activeTab: { id: "tab-a", dashboardId: "old", taskId: null }, - routeDashboardId: "new", - routeChannelId: "c1", - }), - ).toEqual({ - type: "replace", - tabId: "tab-a", - dashboardId: "new", - taskId: null, - channelId: "c1", - channelSection: null, - appView: null, - stampTabId: "tab-a", - }); + it("removes the tab and its panes, focusing the neighbour", () => { + const { snapshot, nextActiveTabId } = closeTab( + baseSnapshot(), + "t1", + deps(), + ); + expect(snapshot.tabs.map((t) => t.id)).toEqual(["t2"]); + expect(snapshot.panes.map((p) => p.id)).toEqual(["t2-pane"]); + expect(nextActiveTabId).toBe("t2"); + expect(activeTabId(snapshot)).toBe("t2"); }); - it("opens a tab when an untagged canvas nav has no active tab", () => { - expect( - decideTabNavigation({ - ...base, - routeDashboardId: "d1", - routeChannelId: "c1", - }), - ).toEqual({ - type: "open", - dashboardId: "d1", - taskId: null, - channelId: "c1", - channelSection: null, - appView: null, - stampTabId: null, - }); + it("keeps the active tab when closing an inactive one", () => { + const { snapshot, nextActiveTabId } = closeTab( + baseSnapshot(), + "t2", + deps(), + ); + expect(nextActiveTabId).toBe("t1"); + expect(activeTabId(snapshot)).toBe("t1"); }); - it("replaces the active tab when navigating between channel sections", () => { - // In-tab nav from a channel's history to its artifacts: same tab, new section. - expect( - decideTabNavigation({ - ...base, - serverActiveTabId: "tab-a", - activeTab: { - id: "tab-a", - dashboardId: null, - taskId: null, - channelId: "c1", - channelSection: "history", - }, - routeChannelId: "c1", - routeChannelSection: "artifacts", - }), - ).toEqual({ - type: "replace", - tabId: "tab-a", - dashboardId: null, - taskId: null, - channelId: "c1", - channelSection: "artifacts", - appView: null, - stampTabId: "tab-a", - }); + it("removes every pane of a multi-pane tab", () => { + let s = baseSnapshot(); + s = { + ...s, + tabs: s.tabs.map((t) => + t.id === "t1" + ? { ...t, layout: split("row", [leaf("t1-pane"), leaf("extra")]) } + : t, + ), + panes: [...s.panes, pane("extra", "t1", "w1")], + }; + const { snapshot } = closeTab(s, "t1", deps()); + expect(snapshot.panes.map((p) => p.id)).toEqual(["t2-pane"]); }); - it("opens a channel-section tab when there is no active tab", () => { - expect( - decideTabNavigation({ - ...base, - routeChannelId: "c1", - routeChannelSection: "history", - }), - ).toEqual({ - type: "open", - dashboardId: null, - taskId: null, - channelId: "c1", - channelSection: "history", - appView: null, - stampTabId: null, + it("backfills a blank tab when closing the primary window's last tab", () => { + let s = baseSnapshot(); + s = closeTab(s, "t2", deps()).snapshot; + const result = closeTab(s, "t1", { + makeId: idFactory(), + now, + blankTabId: "blank-tab", + blankPaneId: "blank-pane", }); + expect(result.snapshot.tabs.map((t) => t.id)).toEqual(["blank-tab"]); + expect(result.snapshot.panes.map((p) => p.id)).toEqual(["blank-pane"]); + expect(result.nextActiveTabId).toBe("blank-tab"); + expect(activeTabId(result.snapshot)).toBe("blank-tab"); + expect(activeTabIsBlank(result.snapshot)).toBe(true); }); - it("only stamps when the active tab already shows the route channel section", () => { - expect( - decideTabNavigation({ - ...base, - serverActiveTabId: "tab-a", - activeTab: { - id: "tab-a", - dashboardId: null, - taskId: null, - channelId: "c1", - channelSection: "history", - }, - routeChannelId: "c1", - routeChannelSection: "history", - }), - ).toEqual({ type: "stamp", stampTabId: "tab-a" }); - }); - - it("only stamps when the active tab already shows the route canvas", () => { - expect( - decideTabNavigation({ - ...base, - serverActiveTabId: "tab-a", - activeTab: { id: "tab-a", dashboardId: "same", taskId: null }, - routeDashboardId: "same", - }), - ).toEqual({ type: "stamp", stampTabId: "tab-a" }); + it("closes a secondary window with its last tab", () => { + let s = baseSnapshot(); + s = { + ...s, + windows: [...s.windows, win("w2", { activeTabId: "t3" })], + tabs: [...s.tabs, tab("t3", "w2", 1000)], + panes: [...s.panes, pane("t3-pane", "t3", "w2")], + }; + const result = closeTab(s, "t3", deps()); + expect(result.closedWindowId).toBe("w2"); + expect(result.snapshot.windows.map((w) => w.id)).toEqual(["w1"]); + expect(result.snapshot.tabs.map((t) => t.id)).toEqual(["t1", "t2"]); }); - it("is a noop on a blank/landing route (no canvas)", () => { - expect( - decideTabNavigation({ - ...base, - serverActiveTabId: "tab-a", - activeTab: { id: "tab-a", dashboardId: null, taskId: null }, - routeDashboardId: null, - }), - ).toEqual({ type: "noop" }); + it("is a no-op for an unknown tab", () => { + const s = baseSnapshot(); + expect(closeTab(s, "ghost", deps()).snapshot).toBe(s); }); }); -describe("decideTabNavigation: dedup against existing tabs (windowTabs)", () => { - const identity = { - dashboardId: null, - taskId: null, - channelId: null, - channelSection: null, - appView: null, - }; - - it("activates the existing tab instead of replacing the active tab's target (would-be duplicate)", () => { - // A rapid switch whose history stamp was lost arrives looking like an in-tab - // nav: active tab A, but the route identifies tab B (already open). Without - // the dedup this replaces A's target → two tabs on c2/artifacts. With it, B - // is focused. - expect( - decideTabNavigation({ - historyTabId: "tab-a", - serverActiveTabId: "tab-a", - windowTabs: [ - { id: "tab-a", ...identity, channelId: "c1" }, - { - id: "tab-b", - ...identity, - channelId: "c2", - channelSection: "artifacts", - }, - ], - activeTab: { - id: "tab-a", - dashboardId: null, - taskId: null, - channelId: "c1", - }, - routeDashboardId: null, - routeTaskId: null, - routeChannelId: "c2", - routeChannelSection: "artifacts", - }), - ).toEqual({ type: "activate", tabId: "tab-b" }); - }); - - it("activates an existing matching tab instead of opening a duplicate (no active tab)", () => { - expect( - decideTabNavigation({ - historyTabId: null, - serverActiveTabId: null, - windowTabs: [{ id: "tab-b", ...identity, channelId: "c2" }], - activeTab: null, - routeDashboardId: null, - routeTaskId: null, - routeChannelId: "c2", - }), - ).toEqual({ type: "activate", tabId: "tab-b" }); - }); - - it("does NOT jump when the active tab already shows the route, even if a duplicate exists", () => { - // Two tabs share an identity (a pre-existing duplicate). The active one - // already shows the route → stamp/noop. Jumping to the other duplicate - // would ping-pong between them forever (Maximum update depth exceeded). - expect( - decideTabNavigation({ - historyTabId: "tab-x", - serverActiveTabId: "tab-x", - windowTabs: [ - { - id: "tab-x", - ...identity, - channelId: "c1", - channelSection: "artifacts", - }, - { - id: "tab-y", - ...identity, - channelId: "c1", - channelSection: "artifacts", - }, - ], - activeTab: { - id: "tab-x", - dashboardId: null, - taskId: null, - channelId: "c1", - channelSection: "artifacts", - }, - routeDashboardId: null, - routeTaskId: null, - routeChannelId: "c1", - routeChannelSection: "artifacts", - }), - ).toEqual({ type: "stamp", stampTabId: "tab-x" }); - }); - - it("fills a blank active tab (fresh + tab) even when the route is open elsewhere", () => { - // Cmd+T lands the new blank tab on #me. If a #me tab is already open, the - // dedup must NOT steal the navigation to it — that would strand the blank - // tab forever. The blank active tab means "fill me". - expect( - decideTabNavigation({ - historyTabId: "tab-blank", - serverActiveTabId: "tab-blank", - windowTabs: [ - { id: "tab-blank", ...identity }, - { id: "tab-me", ...identity, channelId: "me-ch" }, - ], - activeTab: { id: "tab-blank", dashboardId: null, taskId: null }, - routeDashboardId: null, - routeTaskId: null, - routeChannelId: "me-ch", - }), - ).toEqual({ - type: "replace", - tabId: "tab-blank", - dashboardId: null, - taskId: null, - channelId: "me-ch", - channelSection: null, - appView: null, - stampTabId: "tab-blank", +describe("closeTabs", () => { + it("closes in bulk and focuses the anchor when the active tab died", () => { + let s = baseSnapshot(); + s = { + ...s, + tabs: [...s.tabs, tab("t3", "w1", 3000)], + panes: [...s.panes, pane("t3-pane", "t3", "w1")], + }; + const next = closeTabs(s, ["t1", "t2"], "t3", { + makeId: idFactory(), + now, }); + expect(next.tabs.map((t) => t.id)).toEqual(["t3"]); + expect(activeTabId(next)).toBe("t3"); }); - it("still replaces for a genuine in-tab nav to a target no other tab holds", () => { - expect( - decideTabNavigation({ - historyTabId: "tab-a", - serverActiveTabId: "tab-a", - windowTabs: [{ id: "tab-a", ...identity, channelId: "c1" }], - activeTab: { - id: "tab-a", - dashboardId: null, - taskId: null, - channelId: "c1", - }, - routeDashboardId: null, - routeTaskId: null, - routeChannelId: "c1", - routeChannelSection: "artifacts", - }), - ).toEqual({ - type: "replace", - tabId: "tab-a", - dashboardId: null, - taskId: null, - channelId: "c1", - channelSection: "artifacts", - appView: null, - stampTabId: "tab-a", - }); + it("returns the snapshot for an empty id list", () => { + const s = baseSnapshot(); + expect(closeTabs(s, [], null, { makeId: idFactory(), now })).toBe(s); }); }); -function openChannel(s: TabsSnapshot, windowId: string, channelId: string) { - return openOrFocusTab(s, { - windowId, - dashboardId: null, - taskId: null, - channelId, - makeId, - now, - }); -} +describe("closePane", () => { + const withSplit = (): TabsSnapshot => { + const s = baseSnapshot(); + return { + ...s, + tabs: s.tabs.map((t) => + t.id === "t1" + ? { + ...t, + layout: split("row", [leaf("t1-pane"), leaf("extra")]), + focusedPaneId: "extra", + } + : t, + ), + panes: [...s.panes, pane("extra", "t1", "w1", { taskId: "k9" })], + }; + }; -describe("activeTabIsBlank", () => { - it("is true when the active tab has no canvas, task, or channel", () => { - const t = newBlankTab(snapshot(), { windowId: "w1", makeId, now }); - expect(activeTabIsBlank(t.snapshot)).toBe(true); + it("removes the pane, collapses the layout, and refocuses", () => { + const next = closePane(withSplit(), "t1", "extra"); + const t1 = next.tabs.find((t) => t.id === "t1"); + expect(t1?.layout).toEqual(leaf("t1-pane")); + expect(t1?.focusedPaneId).toBe("t1-pane"); + expect(next.panes.some((p) => p.id === "extra")).toBe(false); }); - it("is false when the active tab points at a canvas", () => { - const t = open(snapshot(), "w1", "dash-a"); - expect(activeTabIsBlank(t.snapshot)).toBe(false); + it("keeps focus when a non-focused pane closes", () => { + const next = closePane(withSplit(), "t1", "t1-pane"); + const t1 = next.tabs.find((t) => t.id === "t1"); + expect(t1?.layout).toEqual(leaf("extra")); + expect(t1?.focusedPaneId).toBe("extra"); }); - it("is false when the active tab is a channel tab (channel home)", () => { - const t = openChannel(snapshot(), "w1", "c1"); - expect(activeTabIsBlank(t.snapshot)).toBe(false); + it("is a no-op on a tab's only pane", () => { + const s = baseSnapshot(); + expect(closePane(s, "t2", "t2-pane")).toBe(s); }); - it("is false when there is no active tab", () => { - expect(activeTabIsBlank(snapshot())).toBe(false); + it("is a no-op when the pane belongs to another tab", () => { + const s = withSplit(); + expect(closePane(s, "t1", "t2-pane")).toBe(s); }); }); -describe("primaryWindowHasNoTabs", () => { - it("is true when the primary window's last tab was closed", () => { - const opened = open(snapshot(), "w1", "dash-a"); - const closed = closeTab(opened.snapshot, opened.tabId); - expect(primaryWindowHasNoTabs(closed.snapshot)).toBe(true); - }); - - it("is false while the primary window still has a tab", () => { - const t = open(snapshot(), "w1", "dash-a"); - expect(primaryWindowHasNoTabs(t.snapshot)).toBe(false); +describe("mergeTabIntoTab", () => { + it("splices the source pane next to the target pane and drops the source pill", () => { + const next = mergeTabIntoTab(baseSnapshot(), { + windowId: "w1", + sourceTabId: "t2", + targetTabId: "t1", + targetPaneId: "t1-pane", + direction: "right", + now, + }); + expect(next.tabs.map((t) => t.id)).toEqual(["t1"]); + const t1 = next.tabs[0]; + expect(t1.layout).toEqual( + split("row", [leaf("t1-pane"), leaf("t2-pane")], [0.5, 0.5]), + ); + expect(t1.focusedPaneId).toBe("t2-pane"); + expect(next.panes.find((p) => p.id === "t2-pane")?.tabId).toBe("t1"); + expect(activeTabId(next)).toBe("t1"); }); - it("ignores tabs that belong to other windows", () => { - const s = snapshot({ - windows: [ - { id: "w1", isPrimary: true, bounds: null, activeTabId: null }, - { id: "w2", isPrimary: false, bounds: null, activeTabId: null }, - ], + it("targets the layout root when targetPaneId is null", () => { + const next = mergeTabIntoTab(baseSnapshot(), { + windowId: "w1", + sourceTabId: "t2", + targetTabId: "t1", + targetPaneId: null, + direction: "bottom", + now, }); - const onlyInSecondary = open(s, "w2", "dash-a"); - expect(primaryWindowHasNoTabs(onlyInSecondary.snapshot)).toBe(true); + expect(next.tabs[0].layout).toEqual( + split("column", [leaf("t1-pane"), leaf("t2-pane")], [0.5, 0.5]), + ); }); -}); -describe("closeTabs", () => { - /** Open n dashboards in w1, returning the snapshot and ordered tab ids. */ - function openMany(n: number) { - let s = snapshot(); - const ids: string[] = []; - for (let i = 0; i < n; i++) { - const r = open(s, "w1", `dash-${i}`); - s = r.snapshot; - ids.push(r.tabId); + it("merges a multi-pane source, flattening same-axis nesting", () => { + let s = baseSnapshot(); + s = { + ...s, + tabs: s.tabs.map((t) => + t.id === "t2" + ? { + ...t, + layout: split("row", [leaf("t2-pane"), leaf("t2-b")]), + focusedPaneId: "t2-b", + } + : t, + ), + panes: [...s.panes, pane("t2-b", "t2", "w1")], + }; + const next = mergeTabIntoTab(s, { + windowId: "w1", + sourceTabId: "t2", + targetTabId: "t1", + targetPaneId: "t1-pane", + direction: "right", + now, + }); + const t1 = next.tabs[0]; + expect(t1.layout.type).toBe("split"); + if (t1.layout.type === "split") { + expect(t1.layout.direction).toBe("row"); + expect(t1.layout.children.every((c) => c.type === "leaf")).toBe(true); + expect(t1.layout.children).toHaveLength(3); } - return { s, ids }; - } - - it("is a noop for an empty or unknown id list", () => { - const { s } = openMany(2); - expect(closeTabs(s, [])).toBe(s); - expect(closeTabs(s, ["nope"]).tabs).toHaveLength(2); - }); - - it("removes the given tabs and keeps the rest", () => { - const { s, ids } = openMany(4); - const r = closeTabs(s, [ids[1], ids[2]]); - expect(r.tabs.map((t) => t.id)).toEqual([ids[0], ids[3]]); - expect(r.windows).toHaveLength(1); + expect(t1.focusedPaneId).toBe("t2-b"); + expect(next.panes.every((p) => p.tabId === "t1")).toBe(true); + }); + + it.each([ + ["source === target", { sourceTabId: "t1", targetTabId: "t1" }], + ["an unknown source", { sourceTabId: "ghost", targetTabId: "t1" }], + [ + "a target pane outside the target tab", + { sourceTabId: "t2", targetTabId: "t1", targetPaneId: "t2-pane" }, + ], + ] as const)("is a no-op for %s", (_name, over) => { + const s = baseSnapshot(); + const next = mergeTabIntoTab(s, { + windowId: "w1", + targetPaneId: "t1-pane", + direction: "right", + now, + ...over, + }); + expect(next).toBe(s); }); - it("keeps the active tab focused when it survives", () => { - const { s, ids } = openMany(3); - const focused = closeTabs(setFocus(s, ids[0]), [ids[1], ids[2]]); - expect(focused.windows[0].activeTabId).toBe(ids[0]); + it("is a no-op across windows", () => { + let s = baseSnapshot(); + s = { + ...s, + windows: [...s.windows, win("w2")], + tabs: [...s.tabs, tab("t3", "w2", 1000)], + panes: [...s.panes, pane("t3-pane", "t3", "w2")], + }; + expect( + mergeTabIntoTab(s, { + windowId: "w1", + sourceTabId: "t3", + targetTabId: "t1", + targetPaneId: null, + direction: "right", + now, + }), + ).toBe(s); }); +}); - it("focuses the anchor when the active tab is closed", () => { - const { s, ids } = openMany(4); - // Active is ids[1]; "close others" on anchor ids[0] closes 1,2,3 → the - // anchor takes focus even though a stored-order neighbour differs. - const r = closeTabs(setFocus(s, ids[1]), [ids[1], ids[2], ids[3]], ids[0]); - expect(r.windows[0].activeTabId).toBe(ids[0]); +describe("setTabOrder", () => { + it("applies the given order with gap-spaced positions", () => { + const next = setTabOrder(baseSnapshot(), "w1", ["t2", "t1"]); + const positions = new Map(next.tabs.map((t) => [t.id, t.position])); + expect(positions.get("t2")).toBe(POSITION_GAP); + expect(positions.get("t1")).toBe(2 * POSITION_GAP); }); - it("falls back to closeTab's neighbour when no anchor is given", () => { - const { s, ids } = openMany(4); - // Active ids[1]; closing 1,2 leaves [0,3]; the survivor at the old slot is 3. - const r = closeTabs(setFocus(s, ids[1]), [ids[1], ids[2]]); - expect(r.windows[0].activeTabId).toBe(ids[3]); + it("keeps object identity for tabs whose position is unchanged", () => { + const s = baseSnapshot(); + const next = setTabOrder(s, "w1", ["t1", "t2"]); + expect(next).toBe(s); }); - it("ignores an anchor when the active tab survived", () => { - const { s, ids } = openMany(4); - // Active ids[0] survives; anchor must not steal focus from it. - const r = closeTabs(setFocus(s, ids[0]), [ids[2], ids[3]], ids[1]); - expect(r.windows[0].activeTabId).toBe(ids[0]); + it("appends unlisted tabs after the listed ones in their old order", () => { + let s = baseSnapshot(); + s = { + ...s, + tabs: [...s.tabs, tab("t3", "w1", 3000)], + panes: [...s.panes, pane("t3-pane", "t3", "w1")], + }; + const next = setTabOrder(s, "w1", ["t3"]); + const ordered = next.tabs + .filter((t) => t.windowId === "w1") + .sort((a, b) => a.position - b.position) + .map((t) => t.id); + expect(ordered).toEqual(["t3", "t1", "t2"]); }); +}); - it("lands the primary window on channels when all tabs close", () => { - const { s, ids } = openMany(2); - const r = closeTabs(s, ids); - expect(r.tabs).toHaveLength(0); - expect(r.windows[0].activeTabId).toBeNull(); +describe("setPaneSizes", () => { + it("updates the addressed split in the tab's layout", () => { + let s = baseSnapshot(); + s = { + ...s, + tabs: s.tabs.map((t) => + t.id === "t1" + ? { ...t, layout: split("row", [leaf("t1-pane"), leaf("extra")]) } + : t, + ), + panes: [...s.panes, pane("extra", "t1", "w1")], + }; + const next = setPaneSizes(s, "t1", [], [3, 1]); + const layout = next.tabs.find((t) => t.id === "t1")?.layout; + expect(layout?.type).toBe("split"); + if (layout?.type === "split") { + expect(layout.sizes[0]).toBeCloseTo(0.75, 10); + } }); - it("drops an emptied secondary window", () => { - const base = snapshot({ - windows: [ - { id: "w1", isPrimary: true, bounds: null, activeTabId: null }, - { id: "w2", isPrimary: false, bounds: null, activeTabId: null }, - ], - }); - const a = open(base, "w2", "dash-a"); - const b = open(a.snapshot, "w2", "dash-b"); - const r = closeTabs(b.snapshot, [a.tabId, b.tabId]); - expect(r.windows.map((w) => w.id)).toEqual(["w1"]); + it("is a no-op for an unknown tab or invalid sizes", () => { + const s = baseSnapshot(); + expect(setPaneSizes(s, "ghost", [], [1, 1])).toBe(s); + expect(setPaneSizes(s, "t1", [], [1, 1])).toBe(s); // leaf layout }); +}); - function setFocus(s: TabsSnapshot, tabId: string): TabsSnapshot { - return { +describe("helpers", () => { + it("tabPanes returns panes in layout order", () => { + let s = baseSnapshot(); + s = { ...s, - windows: s.windows.map((w) => - w.id === "w1" ? { ...w, activeTabId: tabId } : w, + tabs: s.tabs.map((t) => + t.id === "t1" + ? { ...t, layout: split("row", [leaf("extra"), leaf("t1-pane")]) } + : t, ), + panes: [...s.panes, pane("extra", "t1", "w1")], }; - } -}); - -describe("setTabOrder", () => { - function openThree() { - let s = snapshot(); - const ids: string[] = []; - for (const d of ["a", "b", "c"]) { - const r = open(s, "w1", `dash-${d}`); - s = r.snapshot; - ids.push(r.tabId); - } - return { s, ids }; - } - - function orderOf(s: TabsSnapshot): string[] { - return s.tabs - .filter((t) => t.windowId === "w1") - .sort((a, b) => a.position - b.position) - .map((t) => t.id); - } - - it("persists the given order with clean gap positions", () => { - const { s, ids } = openThree(); - const next = setTabOrder(s, "w1", [ids[2], ids[0], ids[1]]); - expect(orderOf(next)).toEqual([ids[2], ids[0], ids[1]]); - expect( - next.tabs - .filter((t) => t.windowId === "w1") - .sort((a, b) => a.position - b.position) - .map((t) => t.position), - ).toEqual([POSITION_GAP, 2 * POSITION_GAP, 3 * POSITION_GAP]); + expect(tabPanes(s, "t1").map((p) => p.id)).toEqual(["extra", "t1-pane"]); }); - it("ignores unknown ids and appends unlisted tabs in old order", () => { - const { s, ids } = openThree(); - const next = setTabOrder(s, "w1", ["nope", ids[1]]); - expect(orderOf(next)).toEqual([ids[1], ids[0], ids[2]]); + it("focusedPaneOfTab resolves the focused pane", () => { + const s = baseSnapshot(); + const t1 = s.tabs[0]; + expect(focusedPaneOfTab(s, t1)?.id).toBe("t1-pane"); }); - it("leaves other windows' tabs untouched", () => { - const base = snapshot({ - windows: [ - { id: "w1", isPrimary: true, bounds: null, activeTabId: null }, - { id: "w2", isPrimary: false, bounds: null, activeTabId: null }, - ], - }); - const other = open(base, "w2", "dash-z"); - const r = open(other.snapshot, "w1", "dash-a"); - const next = setTabOrder(r.snapshot, "w1", [r.tabId]); - const w2tab = next.tabs.find((t) => t.windowId === "w2"); - expect(w2tab?.position).toBe(POSITION_GAP); + it("activeTabIsBlank is false for a tab showing content", () => { + expect(activeTabIsBlank(baseSnapshot())).toBe(false); }); -}); -describe("primaryWindow", () => { - it("prefers the primary window, falling back to the first", () => { - const s = snapshot({ - windows: [ - { id: "w2", isPrimary: false, bounds: null, activeTabId: null }, - { id: "w1", isPrimary: true, bounds: null, activeTabId: null }, - ], + it("activeTabIsBlank is true when the focused pane is blank", () => { + const result = newBlankTab(baseSnapshot(), { + windowId: "w1", + makeId: idFactory(), + now, }); - expect(primaryWindow(s)?.id).toBe("w1"); + expect(activeTabIsBlank(result.snapshot)).toBe(true); }); -}); -function openAppView(s: TabsSnapshot, windowId: string, appView: string) { - return openOrFocusTab(s, { - windowId, - dashboardId: null, - taskId: null, - channelId: null, - appView, - makeId, - now, - }); -} - -describe("setWindowActiveTab", () => { - it("focuses a tab that exists in the window", () => { - const a = open(snapshot(), "w1", "dash-a"); - const b = open(a.snapshot, "w1", "dash-b"); - const next = setWindowActiveTab(b.snapshot, "w1", a.tabId); - expect(next.windows[0].activeTabId).toBe(a.tabId); + it("primaryWindowHasNoTabs reflects an empty window", () => { + const s = baseSnapshot(); + expect(primaryWindowHasNoTabs(s)).toBe(false); + expect(primaryWindowHasNoTabs({ ...s, tabs: [], panes: [] })).toBe(true); }); +}); - it("clears focus with null (landing state)", () => { - const a = open(snapshot(), "w1", "dash-a"); - const next = setWindowActiveTab(a.snapshot, "w1", null); - expect(next.windows[0].activeTabId).toBeNull(); - }); +describe("ensureSnapshotIntegrity", () => { + const deps = () => ({ makeId: idFactory(), now }); - it("ignores a tab id that does not exist (dead history tag)", () => { - const a = open(snapshot(), "w1", "dash-a"); - const next = setWindowActiveTab(a.snapshot, "w1", "closed-long-ago"); - expect(next).toBe(a.snapshot); - expect(next.windows[0].activeTabId).toBe(a.tabId); + it("returns the same reference for a canonical snapshot", () => { + const s = baseSnapshot(); + expect(ensureSnapshotIntegrity(s, deps())).toBe(s); }); - it("ignores a tab that belongs to another window", () => { - const base = snapshot({ - windows: [ - { id: "w1", isPrimary: true, bounds: null, activeTabId: null }, - { id: "w2", isPrimary: false, bounds: null, activeTabId: null }, - ], - }); - const foreign = open(base, "w2", "dash-z"); - const next = setWindowActiveTab(foreign.snapshot, "w1", foreign.tabId); - expect(next).toBe(foreign.snapshot); - expect(next.windows[0].activeTabId).toBeNull(); + it("creates a primary window (with a blank tab) from an empty snapshot", () => { + const next = ensureSnapshotIntegrity( + { windows: [], tabs: [], panes: [] }, + deps(), + ); + expect(next.windows).toHaveLength(1); + expect(next.windows[0].isPrimary).toBe(true); + expect(next.tabs).toHaveLength(1); + expect(next.panes).toHaveLength(1); + expect(next.windows[0].activeTabId).toBe(next.tabs[0].id); + }); + + it("promotes the first window when none is primary", () => { + const s = baseSnapshot(); + const next = ensureSnapshotIntegrity( + { + ...s, + windows: s.windows.map((w) => ({ ...w, isPrimary: false })), + }, + deps(), + ); + expect(next.windows[0].isPrimary).toBe(true); }); - it("ignores an unknown window", () => { - const a = open(snapshot(), "w1", "dash-a"); - expect(setWindowActiveTab(a.snapshot, "w-nope", null)).toBe(a.snapshot); + it("moves a tab in a dead window to the primary", () => { + const s = baseSnapshot(); + const broken: TabsSnapshot = { + ...s, + tabs: [...s.tabs, tab("t3", "ghost-window", 1000)], + panes: [...s.panes, pane("t3-pane", "t3", "ghost-window")], + }; + const next = ensureSnapshotIntegrity(broken, deps()); + const t3 = next.tabs.find((t) => t.id === "t3"); + expect(t3?.windowId).toBe("w1"); + expect(next.panes.find((p) => p.id === "t3-pane")?.windowId).toBe("w1"); }); - it("keeps snapshot identity when the tab is already active", () => { - const a = open(snapshot(), "w1", "dash-a"); - expect(setWindowActiveTab(a.snapshot, "w1", a.tabId)).toBe(a.snapshot); + it("drops a pane whose tab does not exist", () => { + const s = baseSnapshot(); + const broken: TabsSnapshot = { + ...s, + panes: [...s.panes, pane("orphan", "ghost-tab", "w1")], + }; + const next = ensureSnapshotIntegrity(broken, deps()); + expect(next.panes.some((p) => p.id === "orphan")).toBe(false); }); - it("a tab closed then re-activated by a stale id never dangles", () => { - // The persistence-bug shape: close a tab, then a back/forward replay tries - // to focus its id. The active tab must survive untouched — a dangling - // activeTabId makes every later navigation open a new tab. - const a = open(snapshot(), "w1", "dash-a"); - const b = open(a.snapshot, "w1", "dash-b"); - const closed = closeTab(b.snapshot, b.tabId).snapshot; - const next = setWindowActiveTab(closed, "w1", b.tabId); - expect(next).toBe(closed); - expect(next.windows[0].activeTabId).toBe(a.tabId); - expect(next.tabs.some((t) => t.id === next.windows[0].activeTabId)).toBe( - true, + it("prunes layout leaves without a pane row", () => { + const s = baseSnapshot(); + const broken: TabsSnapshot = { + ...s, + tabs: s.tabs.map((t) => + t.id === "t1" + ? { ...t, layout: split("row", [leaf("t1-pane"), leaf("ghost")]) } + : t, + ), + }; + const next = ensureSnapshotIntegrity(broken, deps()); + expect(next.tabs.find((t) => t.id === "t1")?.layout).toEqual( + leaf("t1-pane"), ); }); -}); - -describe("decideTabNavigation: dead history tags (back/forward over closed tabs)", () => { - const base = { - historyTabId: null as string | null, - serverActiveTabId: null as string | null, - activeTab: null as { - id: string; - dashboardId: string | null; - taskId: string | null; - } | null, - routeDashboardId: null as string | null, - routeTaskId: null as string | null, - routeChannelId: null as string | null, - }; - it("does NOT activate a tagged tab that no longer exists — replays in the active tab", () => { - // Back onto an entry whose tab was closed: fall through to the route and - // replace the active tab, instead of persisting a dangling activeTabId. - expect( - decideTabNavigation({ - ...base, - historyTabId: "tab-closed", - windowTabIds: ["tab-a", "tab-b"], - serverActiveTabId: "tab-a", - activeTab: { id: "tab-a", dashboardId: "old", taskId: null }, - routeDashboardId: "from-history", - routeChannelId: "c1", - }), - ).toEqual({ - type: "replace", - tabId: "tab-a", - dashboardId: "from-history", - taskId: null, - channelId: "c1", - channelSection: null, - appView: null, - stampTabId: "tab-a", - }); + it("grafts a pane row missing from its tab's layout", () => { + const s = baseSnapshot(); + const broken: TabsSnapshot = { + ...s, + panes: [...s.panes, pane("lost", "t1", "w1", { taskId: "k5" })], + }; + const next = ensureSnapshotIntegrity(broken, deps()); + const t1 = next.tabs.find((t) => t.id === "t1"); + expect(t1?.layout).toEqual( + split("row", [leaf("t1-pane"), leaf("lost")], [0.5, 0.5]), + ); }); - it("opens a tab for a dead tag when nothing is active", () => { - expect( - decideTabNavigation({ - ...base, - historyTabId: "tab-closed", - windowTabIds: [], - serverActiveTabId: null, - routeDashboardId: "d1", - routeChannelId: "c1", - }), - ).toEqual({ - type: "open", - dashboardId: "d1", - taskId: null, - channelId: "c1", - channelSection: null, - appView: null, - stampTabId: null, - }); + it("synthesizes a blank pane for a paneless tab", () => { + const s = baseSnapshot(); + const broken: TabsSnapshot = { + ...s, + panes: s.panes.filter((p) => p.tabId !== "t1"), + }; + const next = ensureSnapshotIntegrity(broken, deps()); + const t1 = next.tabs.find((t) => t.id === "t1"); + expect(t1).toBeDefined(); + const t1Panes = next.panes.filter((p) => p.tabId === "t1"); + expect(t1Panes).toHaveLength(1); + expect(t1?.layout).toEqual(leaf(t1Panes[0].id)); + expect(t1?.focusedPaneId).toBe(t1Panes[0].id); + }); + + it("heals an invalid focusedPaneId to the first leaf", () => { + const s = baseSnapshot(); + const broken: TabsSnapshot = { + ...s, + tabs: s.tabs.map((t) => + t.id === "t1" ? { ...t, focusedPaneId: "ghost" } : t, + ), + }; + const next = ensureSnapshotIntegrity(broken, deps()); + expect(next.tabs.find((t) => t.id === "t1")?.focusedPaneId).toBe("t1-pane"); }); - it("re-stamps the entry with the live active tab when the route already matches", () => { - expect( - decideTabNavigation({ - ...base, - historyTabId: "tab-closed", - windowTabIds: ["tab-a"], - serverActiveTabId: "tab-a", - activeTab: { id: "tab-a", dashboardId: "same", taskId: null }, - routeDashboardId: "same", - routeChannelId: null, - }), - ).toEqual({ type: "stamp", stampTabId: "tab-a" }); + it("drops an empty secondary window and backfills an empty primary", () => { + const s = baseSnapshot(); + const broken: TabsSnapshot = { + windows: [...s.windows, win("w2")], + tabs: [], + panes: [], + }; + const next = ensureSnapshotIntegrity(broken, deps()); + expect(next.windows.map((w) => w.id)).toEqual(["w1"]); + expect(next.tabs).toHaveLength(1); + expect(next.windows[0].activeTabId).toBe(next.tabs[0].id); }); - it("still activates a live tagged tab (windowTabIds provided)", () => { - expect( - decideTabNavigation({ - ...base, - historyTabId: "tab-b", - windowTabIds: ["tab-a", "tab-b"], - serverActiveTabId: "tab-a", - }), - ).toEqual({ type: "activate", tabId: "tab-b" }); + it("heals a dangling activeTabId to the most recently active tab", () => { + const s = baseSnapshot(); + const broken: TabsSnapshot = { + ...s, + windows: s.windows.map((w) => ({ ...w, activeTabId: "ghost" })), + tabs: s.tabs.map((t) => + t.id === "t2" ? { ...t, lastActiveAt: NOW + 10 } : t, + ), + }; + const next = ensureSnapshotIntegrity(broken, deps()); + expect(next.windows[0].activeTabId).toBe("t2"); }); - it("trusts the tag when windowTabIds is omitted (legacy callers)", () => { - expect( - decideTabNavigation({ - ...base, - historyTabId: "tab-b", - serverActiveTabId: "tab-a", - }), - ).toEqual({ type: "activate", tabId: "tab-b" }); + it("reindexes colliding tab positions stably", () => { + const s = baseSnapshot(); + const broken: TabsSnapshot = { + ...s, + tabs: s.tabs.map((t) => ({ ...t, position: 1000 })), + }; + const next = ensureSnapshotIntegrity(broken, deps()); + const positions = next.tabs.map((t) => t.position); + expect(new Set(positions).size).toBe(positions.length); }); }); -describe("decideTabNavigation: app-view tabs (Inbox, Command center, …)", () => { - const base = { - historyTabId: null as string | null, - serverActiveTabId: null as string | null, - activeTab: null, - routeDashboardId: null as string | null, - routeTaskId: null as string | null, - routeChannelId: null as string | null, - }; +describe("decidePaneNavigation", () => { + const d1 = identity({ dashboardId: "d1" }); + const k1 = identity({ taskId: "k1" }); - it("replaces the active tab in place on an untagged nav to an app view", () => { - // The reported bug: clicking a nav item (Inbox, Command center, …) must - // navigate IN the current tab, not open a new one. + it("noops on a blank route", () => { expect( - decideTabNavigation({ - ...base, - serverActiveTabId: "tab-a", - activeTab: { - id: "tab-a", - dashboardId: "dash-1", - taskId: null, - }, - routeAppView: "inbox", + decidePaneNavigation({ + paneIdentity: d1, + routeIdentity: identity(), + otherOpenPanes: [], + historyAction: "PUSH", }), - ).toEqual({ - type: "replace", - tabId: "tab-a", - dashboardId: null, - taskId: null, - channelId: null, - channelSection: null, - appView: "inbox", - stampTabId: "tab-a", - }); + ).toEqual({ type: "noop" }); }); - it("a blank tab absorbs the first app view clicked (new-tab page keeps the URL)", () => { + it("noops when the pane already shows the route", () => { expect( - decideTabNavigation({ - ...base, - serverActiveTabId: "tab-blank", - activeTab: { - id: "tab-blank", - dashboardId: null, - taskId: null, - }, - routeAppView: "command-center", + decidePaneNavigation({ + paneIdentity: d1, + routeIdentity: d1, + otherOpenPanes: [], + historyAction: "PUSH", }), - ).toEqual({ - type: "replace", - tabId: "tab-blank", - dashboardId: null, - taskId: null, - channelId: null, - channelSection: null, - appView: "command-center", - stampTabId: "tab-blank", - }); + ).toEqual({ type: "noop" }); }); - it("only stamps when the active tab already shows the app view", () => { + it("activates the tab of another pane already showing the route on PUSH", () => { expect( - decideTabNavigation({ - ...base, - serverActiveTabId: "tab-a", - activeTab: { - id: "tab-a", - dashboardId: null, - taskId: null, - appView: "inbox", - }, - routeAppView: "inbox", + decidePaneNavigation({ + paneIdentity: d1, + routeIdentity: k1, + otherOpenPanes: [{ tabId: "t2", paneId: "t2-pane", identity: k1 }], + historyAction: "PUSH", }), - ).toEqual({ type: "stamp", stampTabId: "tab-a" }); - }); - - it("switching between app views replaces in place (no duplicate tab)", () => { + ).toEqual({ type: "activateTab", tabId: "t2", paneId: "t2-pane" }); + }); + + it.each(["BACK", "FORWARD", "GO", "REPLACE", null] as const)( + "replaces in place on %s even when another pane matches", + (historyAction) => { + expect( + decidePaneNavigation({ + paneIdentity: d1, + routeIdentity: k1, + otherOpenPanes: [{ tabId: "t2", paneId: "t2-pane", identity: k1 }], + historyAction, + }), + ).toEqual({ type: "replacePane" }); + }, + ); + + it("fills a blank pane instead of deduping away from it", () => { expect( - decideTabNavigation({ - ...base, - serverActiveTabId: "tab-a", - activeTab: { - id: "tab-a", - dashboardId: null, - taskId: null, - appView: "inbox", - }, - routeAppView: "skills", + decidePaneNavigation({ + paneIdentity: identity(), + routeIdentity: k1, + otherOpenPanes: [{ tabId: "t2", paneId: "t2-pane", identity: k1 }], + historyAction: "PUSH", }), - ).toEqual({ - type: "replace", - tabId: "tab-a", - dashboardId: null, - taskId: null, - channelId: null, - channelSection: null, - appView: "skills", - stampTabId: "tab-a", - }); + ).toEqual({ type: "replacePane" }); }); - it("opens a tab for an app view when nothing is active", () => { + it("replaces in place for an ordinary navigation", () => { expect( - decideTabNavigation({ - ...base, - routeAppView: "agents", + decidePaneNavigation({ + paneIdentity: d1, + routeIdentity: k1, + otherOpenPanes: [], + historyAction: "PUSH", }), - ).toEqual({ - type: "open", - dashboardId: null, - taskId: null, - channelId: null, - channelSection: null, - appView: "agents", - stampTabId: null, - }); - }); -}); - -describe("openOrFocusTab: app-view identity", () => { - it("dedups the same app view instead of opening a second tab", () => { - const first = openAppView(snapshot(), "w1", "inbox"); - const second = openAppView(first.snapshot, "w1", "inbox"); - expect(second.opened).toBe(false); - expect(second.tabId).toBe(first.tabId); - expect(second.snapshot.tabs).toHaveLength(1); - }); - - it("treats different app views as distinct tabs", () => { - const inbox = openAppView(snapshot(), "w1", "inbox"); - const skills = openAppView(inbox.snapshot, "w1", "skills"); - expect(skills.opened).toBe(true); - expect(skills.snapshot.tabs).toHaveLength(2); - }); - - it("an app-view tab and a blank tab are distinct identities", () => { - const blank = newBlankTab(snapshot(), { windowId: "w1", makeId, now }); - const inbox = openAppView(blank.snapshot, "w1", "inbox"); - expect(inbox.opened).toBe(true); - expect(inbox.snapshot.tabs).toHaveLength(2); - }); -}); - -describe("setTabTarget: app views", () => { - it("points a blank tab at an app view and back to blank", () => { - const blank = newBlankTab(snapshot(), { windowId: "w1", makeId, now }); - const withView = setTabTarget(blank.snapshot, { - tabId: blank.tabId, - dashboardId: null, - taskId: null, - channelId: null, - appView: "command-center", - now, - }); - expect(withView.tabs[0].appView).toBe("command-center"); - expect(activeTabIsBlank(withView)).toBe(false); - - const backToBlank = setTabTarget(withView, { - tabId: blank.tabId, - dashboardId: null, - taskId: null, - channelId: null, - now, - }); - expect(backToBlank.tabs[0].appView).toBeNull(); - expect(activeTabIsBlank(backToBlank)).toBe(true); - }); - - it("clears the app view when the tab navigates to a canvas", () => { - const inbox = openAppView(snapshot(), "w1", "inbox"); - const next = setTabTarget(inbox.snapshot, { - tabId: inbox.tabId, - dashboardId: "dash-1", - taskId: null, - channelId: "c1", - now, - }); - const tab = next.tabs.find((t) => t.id === inbox.tabId); - expect(tab?.appView).toBeNull(); - expect(tab?.dashboardId).toBe("dash-1"); - }); -}); - -describe("regression: the reported tab-persistence bugs", () => { - // Three tabs; the third is active (this is the persisted boot state in the - // report: "my third tab is taking the URL of any URL I try on tab 1/2"). - function threeTabs() { - const t1 = open(snapshot(), "w1", "dash-1"); - const t2 = open(t1.snapshot, "w1", "dash-2"); - const t3 = open(t2.snapshot, "w1", "dash-3"); - return { s: t3.snapshot, ids: [t1.tabId, t2.tabId, t3.tabId] as const }; - } - - it("a navigation after a tab switch writes to the switched tab, not the previous one", () => { - const { s, ids } = threeTabs(); - const [t1, , t3] = ids; - expect(s.windows[0].activeTabId).toBe(t3); - - // User clicks tab 1 in the strip → the entry is tagged t1 → activate. - const clickTab1 = decideTabNavigation({ - historyTabId: t1, - windowTabIds: ids, - serverActiveTabId: t3, - activeTab: null, - routeDashboardId: "dash-1", - routeTaskId: null, - routeChannelId: "c1", - }); - expect(clickTab1).toEqual({ type: "activate", tabId: t1 }); - - // The strip applies the focus to its mirror synchronously (the fix): the - // next decision must see t1 active, NOT the stale t3. - const afterSwitch = setWindowActiveTab(s, "w1", t1); - expect(afterSwitch.windows[0].activeTabId).toBe(t1); - - // User clicks a nav item (untagged navigation). It must replace t1. - const activeTab = afterSwitch.tabs.find((t) => t.id === t1); - const navToInbox = decideTabNavigation({ - historyTabId: null, - windowTabIds: ids, - serverActiveTabId: t1, - activeTab: activeTab - ? { - id: activeTab.id, - dashboardId: activeTab.dashboardId, - taskId: activeTab.taskId, - channelId: activeTab.channelId, - channelSection: activeTab.channelSection, - appView: activeTab.appView, - } - : null, - routeDashboardId: null, - routeTaskId: null, - routeChannelId: null, - routeAppView: "inbox", - }); - expect(navToInbox.type).toBe("replace"); - if (navToInbox.type !== "replace") throw new Error("unreachable"); - expect(navToInbox.tabId).toBe(t1); - - // Apply the write: only t1 changed; t3 keeps its canvas. - const applied = setTabTarget(afterSwitch, { - tabId: navToInbox.tabId, - dashboardId: navToInbox.dashboardId, - taskId: navToInbox.taskId, - channelId: navToInbox.channelId, - channelSection: navToInbox.channelSection, - appView: navToInbox.appView, - now, - }); - expect(applied.tabs.find((t) => t.id === t1)?.appView).toBe("inbox"); - expect(applied.tabs.find((t) => t.id === t3)?.dashboardId).toBe("dash-3"); - expect(applied.tabs.find((t) => t.id === ids[1])?.dashboardId).toBe( - "dash-2", - ); - }); - - it("switching tabs never rewrites the target tab's contents", () => { - const { s, ids } = threeTabs(); - const [t1, t2] = ids; - // Switch t3 → t2 → t1: pure focus changes. - let cur = setWindowActiveTab(s, "w1", t2); - cur = setWindowActiveTab(cur, "w1", t1); - expect(cur.tabs.find((t) => t.id === t1)?.dashboardId).toBe("dash-1"); - expect(cur.tabs.find((t) => t.id === t2)?.dashboardId).toBe("dash-2"); - expect(cur.tabs.find((t) => t.id === ids[2])?.dashboardId).toBe("dash-3"); - // Tab records are untouched by focus changes — same array identity. - expect(cur.tabs).toBe(s.tabs); - }); - - it("back over a closed tab's entry cannot dangle focus and flood new tabs", () => { - const { s, ids } = threeTabs(); - const [t1, , t3] = ids; - // Close t1, then replay a history entry tagged with it. - const closed = closeTabs(s, [t1]); - const live = closed.tabs.map((t) => t.id); - const decision = decideTabNavigation({ - historyTabId: t1, - windowTabIds: live, - serverActiveTabId: closed.windows[0].activeTabId, - activeTab: (() => { - const active = closed.tabs.find( - (t) => t.id === closed.windows[0].activeTabId, - ); - return active - ? { - id: active.id, - dashboardId: active.dashboardId, - taskId: active.taskId, - channelId: active.channelId, - channelSection: active.channelSection, - appView: active.appView, - } - : null; - })(), - routeDashboardId: "dash-1", - routeTaskId: null, - routeChannelId: "c1", - }); - // Never "activate" the dead id — the route replays in the active tab. - expect(decision.type).toBe("replace"); - if (decision.type !== "replace") throw new Error("unreachable"); - expect(live).toContain(decision.tabId); - expect(decision.tabId).toBe(t3); - - // And even a hostile setActiveTab with the dead id is a validated no-op. - expect(setWindowActiveTab(closed, "w1", t1)).toBe(closed); - }); - - it("a new blank tab stays blank until the user navigates, then keeps that URL", () => { - const withTabs = open(snapshot(), "w1", "dash-1"); - const blank = newBlankTab(withTabs.snapshot, { - windowId: "w1", - makeId, - now, - }); - expect(activeTabIsBlank(blank.snapshot)).toBe(true); - - // The landing route is a noop — nothing may rewrite the blank tab. - const onLanding = decideTabNavigation({ - historyTabId: blank.tabId, - windowTabIds: blank.snapshot.tabs.map((t) => t.id), - serverActiveTabId: blank.tabId, - activeTab: { - id: blank.tabId, - dashboardId: null, - taskId: null, - channelId: null, - channelSection: null, - appView: null, - }, - routeDashboardId: null, - routeTaskId: null, - routeChannelId: null, - routeAppView: null, - }); - expect(onLanding).toEqual({ type: "noop" }); - - // First click (Command center) replaces the blank tab in place… - const firstNav = decideTabNavigation({ - historyTabId: blank.tabId, - windowTabIds: blank.snapshot.tabs.map((t) => t.id), - serverActiveTabId: blank.tabId, - activeTab: { - id: blank.tabId, - dashboardId: null, - taskId: null, - channelId: null, - channelSection: null, - appView: null, - }, - routeDashboardId: null, - routeTaskId: null, - routeChannelId: null, - routeAppView: "command-center", - }); - expect(firstNav.type).toBe("replace"); - if (firstNav.type !== "replace") throw new Error("unreachable"); - expect(firstNav.tabId).toBe(blank.tabId); - - const applied = setTabTarget(blank.snapshot, { - tabId: firstNav.tabId, - dashboardId: firstNav.dashboardId, - taskId: firstNav.taskId, - channelId: firstNav.channelId, - channelSection: firstNav.channelSection, - appView: firstNav.appView, - now, - }); - // …and the other tab keeps its canvas untouched. - expect(applied.tabs.find((t) => t.id === blank.tabId)?.appView).toBe( - "command-center", - ); - expect(applied.tabs.find((t) => t.id === withTabs.tabId)?.dashboardId).toBe( - "dash-1", - ); - }); -}); - -describe("activeTabIsBlank: app views", () => { - it("is false when the active tab shows an app view", () => { - const t = openAppView(snapshot(), "w1", "inbox"); - expect(activeTabIsBlank(t.snapshot)).toBe(false); + ).toEqual({ type: "replacePane" }); }); }); diff --git a/packages/shared/src/browser-tabs.ts b/packages/shared/src/browser-tabs.ts index 4d2b0d61b9..be913ce74d 100644 --- a/packages/shared/src/browser-tabs.ts +++ b/packages/shared/src/browser-tabs.ts @@ -1,4 +1,26 @@ -import type { BrowserTab, TabsSnapshot } from "./browser-tabs-schemas"; +import { + collectLeafPaneIds, + insertNodeInLayout, + insertPaneInLayout, + normalizeLayout, + removePaneFromLayout, + setSplitSizesAtPath, +} from "./browser-pane-layout"; +import type { + BrowserPane, + BrowserTab, + PaneLayoutNode, + SplitDropDirection, + TabsSnapshot, +} from "./browser-tabs-schemas"; + +/** + * Pure snapshot transforms for the v2 tab model: window → tabs → panes. + * A tab is a strip unit owning a pane layout; a pane is a content unit + * carrying the identity (canvas / task / channel / app view). Layout tree + * geometry lives in `browser-pane-layout.ts`; this module moves rows and + * focus around it. + */ /** Spacing between adjacent tab positions, leaving room to insert without reindex. */ export const POSITION_GAP = 1000; @@ -9,7 +31,9 @@ type IdFactory = () => string; export type OpenTabResult = { snapshot: TabsSnapshot; tabId: string; - /** False when an existing tab was focused (dedup) rather than created. */ + /** The pane showing the target (the new tab's pane, or the deduped one). */ + paneId: string; + /** False when an existing pane was focused (dedup) rather than created. */ opened: boolean; }; @@ -21,6 +45,60 @@ export type CloseTabResult = { closedWindowId: string | null; }; +/** + * Everything that identifies a pane's contents: a canvas, a task, a channel + * sub-section (channel + section), or a top-level app page. Two panes with the + * same identity are the same page, so dedup and in-pane-nav comparisons key on + * all five — a channel's `history` and `artifacts`, or two channels' + * artifacts, are distinct pages. + */ +export type PaneIdentity = { + dashboardId: string | null; + taskId: string | null; + channelId: string | null; + channelSection: string | null; + appView: string | null; +}; + +export const BLANK_PANE_IDENTITY: PaneIdentity = { + dashboardId: null, + taskId: null, + channelId: null, + channelSection: null, + appView: null, +}; + +export function paneIdentityOf(pane: { + dashboardId: string | null; + taskId: string | null; + channelId?: string | null; + channelSection?: string | null; + appView?: string | null; +}): PaneIdentity { + return { + dashboardId: pane.dashboardId, + taskId: pane.taskId, + channelId: pane.channelId ?? null, + channelSection: pane.channelSection ?? null, + appView: pane.appView ?? null, + }; +} + +export function samePaneIdentity(a: PaneIdentity, b: PaneIdentity): boolean { + return ( + a.dashboardId === b.dashboardId && + a.taskId === b.taskId && + a.channelId === b.channelId && + a.channelSection === b.channelSection && + a.appView === b.appView + ); +} + +/** A blank pane is a fresh `+` target: no canvas, task, channel, or app page. */ +export function paneIsBlank(identity: PaneIdentity): boolean { + return samePaneIdentity(identity, BLANK_PANE_IDENTITY); +} + function tabsInWindow(snapshot: TabsSnapshot, windowId: string): BrowserTab[] { return snapshot.tabs .filter((t) => t.windowId === windowId) @@ -32,24 +110,44 @@ export function primaryWindow(snapshot: TabsSnapshot) { return snapshot.windows.find((w) => w.isPrimary) ?? snapshot.windows[0]; } +/** A tab's panes in layout (depth-first display) order. */ +export function tabPanes(snapshot: TabsSnapshot, tabId: string): BrowserPane[] { + const tab = snapshot.tabs.find((t) => t.id === tabId); + if (!tab) return []; + const byId = new Map( + snapshot.panes.filter((p) => p.tabId === tabId).map((p) => [p.id, p]), + ); + return collectLeafPaneIds(tab.layout) + .map((id) => byId.get(id)) + .filter((p): p is BrowserPane => p !== undefined); +} + +/** The pane a tab's pill and default navigation act on. Null only pre-heal. */ +export function focusedPaneOfTab( + snapshot: TabsSnapshot, + tab: BrowserTab, +): BrowserPane | null { + return ( + snapshot.panes.find( + (p) => p.id === tab.focusedPaneId && p.tabId === tab.id, + ) ?? null + ); +} + /** - * True when the primary window's active tab is a blank "+" tab: no canvas, - * task, or channel. The blank tab parks at the channels index (`/website`), - * whose route would otherwise redirect to the first channel — callers use this - * to suppress that redirect so the blank tab (and the in-flight navigation - * leaving it) isn't hijacked to `channels[0]`. + * True when the primary window's active tab is showing a blank focused pane: + * no canvas, task, or channel. The blank pane parks at the channels index + * (`/website`), whose route would otherwise redirect to the first channel — + * callers use this to suppress that redirect so the blank pane (and the + * in-flight navigation leaving it) isn't hijacked to `channels[0]`. */ export function activeTabIsBlank(snapshot: TabsSnapshot): boolean { const w = primaryWindow(snapshot); if (!w?.activeTabId) return false; const t = snapshot.tabs.find((x) => x.id === w.activeTabId); - return ( - !!t && - t.dashboardId == null && - t.taskId == null && - t.channelId == null && - t.appView == null - ); + if (!t) return false; + const pane = focusedPaneOfTab(snapshot, t); + return !!pane && paneIsBlank(paneIdentityOf(pane)); } /** @@ -80,9 +178,9 @@ function setActiveTab( * Focus a tab in a window, validating the target: the tab must exist and live * in that window, otherwise the snapshot is returned unchanged. A `null` tabId * clears focus (the landing state). This is the persistence-safe primitive — - * history entries can carry ids of tabs closed since (back/forward replay), and - * blindly persisting such an id leaves the window with a dangling activeTabId, - * after which every navigation looks like "no active tab" and opens a new tab. + * a desynced mirror can carry ids of tabs closed since, and blindly persisting + * such an id leaves the window with a dangling activeTabId, after which every + * navigation looks like "no active tab" and opens a new tab. */ export function setWindowActiveTab( snapshot: TabsSnapshot, @@ -98,88 +196,76 @@ export function setWindowActiveTab( return setActiveTab(snapshot, windowId, tabId); } -/** What a tab points at: a canvas, a task, or neither (blank). */ -export type TabTarget = { - dashboardId: string | null; - taskId: string | null; -}; - /** - * Everything that identifies a tab's contents: a canvas, a task, or a channel - * sub-section (channel + section). Two tabs with the same identity are the same - * page, so dedup and in-tab-nav comparisons key on all four — a channel's - * `history` and `artifacts`, or two channels' artifacts, are distinct pages. + * Focus a pane within its tab. Validated: the pane must exist and belong to + * `tabId`, else the snapshot is returned unchanged. */ -export type TabIdentity = { - dashboardId: string | null; - taskId: string | null; - channelId: string | null; - channelSection: string | null; - appView: string | null; -}; - -function sameIdentity(a: TabIdentity, b: TabIdentity): boolean { - return ( - a.dashboardId === b.dashboardId && - a.taskId === b.taskId && - a.channelId === b.channelId && - a.channelSection === b.channelSection && - a.appView === b.appView - ); +export function setFocusedPane( + snapshot: TabsSnapshot, + tabId: string, + paneId: string, +): TabsSnapshot { + const pane = snapshot.panes.find((p) => p.id === paneId); + if (!pane || pane.tabId !== tabId) return snapshot; + const tab = snapshot.tabs.find((t) => t.id === tabId); + if (!tab || tab.focusedPaneId === paneId) return snapshot; + return { + ...snapshot, + tabs: snapshot.tabs.map((t) => + t.id === tabId ? { ...t, focusedPaneId: paneId } : t, + ), + }; } /** - * Open a target (canvas or task) in a window, deduping within that window: if a - * tab for the same target already exists in the window it is focused, otherwise - * a new tab is appended. Duplicates across different windows are allowed. + * Open a target in a window, deduping across every pane in that window: if a + * pane already shows the same identity, its tab is activated and the pane + * focused; otherwise a new single-pane tab is appended. Duplicates across + * different windows are allowed. `tabId`/`paneId` let the renderer mint the + * ids so an optimistic local apply and the persisted state agree. */ export function openOrFocusTab( snapshot: TabsSnapshot, - input: TabTarget & { + input: PaneIdentity & { windowId: string; - channelId: string | null; - channelSection?: string | null; - appView?: string | null; + tabId?: string; + paneId?: string; makeId: IdFactory; now: Clock; }, ): OpenTabResult { - const { windowId, dashboardId, taskId, channelId, makeId, now } = input; - const channelSection = input.channelSection ?? null; - const appView = input.appView ?? null; - const existing = snapshot.tabs.find( - (t) => - t.windowId === windowId && - sameIdentity(t, { - dashboardId, - taskId, - channelId, - channelSection, - appView, - }), + const { windowId, makeId, now } = input; + const identity = paneIdentityOf(input); + const existing = snapshot.panes.find( + (p) => + p.windowId === windowId && samePaneIdentity(paneIdentityOf(p), identity), ); if (existing) { const ts = now(); const withActivity: TabsSnapshot = { ...snapshot, + panes: snapshot.panes.map((p) => + p.id === existing.id ? { ...p, lastActiveAt: ts } : p, + ), tabs: snapshot.tabs.map((t) => - t.id === existing.id ? { ...t, lastActiveAt: ts } : t, + t.id === existing.tabId + ? { ...t, focusedPaneId: existing.id, lastActiveAt: ts } + : t, ), }; return { - snapshot: setActiveTab(withActivity, windowId, existing.id), - tabId: existing.id, + snapshot: setActiveTab(withActivity, windowId, existing.tabId), + tabId: existing.tabId, + paneId: existing.id, opened: false, }; } return appendTab(snapshot, { windowId, - dashboardId, - taskId, - channelId, - channelSection, - appView, + identity, + tabId: input.tabId, + paneId: input.paneId, makeId, now, }); @@ -187,104 +273,122 @@ export function openOrFocusTab( function appendTab( snapshot: TabsSnapshot, - input: TabTarget & { + input: { windowId: string; - channelId: string | null; - channelSection?: string | null; - appView?: string | null; + identity: PaneIdentity; + tabId?: string; + paneId?: string; makeId: IdFactory; now: Clock; }, ): OpenTabResult { - const { windowId, dashboardId, taskId, channelId, makeId, now } = input; + const { windowId, identity, makeId, now } = input; const siblings = tabsInWindow(snapshot, windowId); const lastPos = siblings.length ? siblings[siblings.length - 1].position : 0; const ts = now(); + const paneId = input.paneId ?? makeId(); + const tabId = input.tabId ?? makeId(); + const pane: BrowserPane = { + id: paneId, + tabId, + windowId, + ...identity, + scrollState: null, + createdAt: ts, + lastActiveAt: ts, + }; const tab: BrowserTab = { - id: makeId(), + id: tabId, windowId, - dashboardId, - taskId, - channelId, - channelSection: input.channelSection ?? null, - appView: input.appView ?? null, + layout: { type: "leaf", paneId }, + focusedPaneId: paneId, position: lastPos + POSITION_GAP, - scrollState: null, createdAt: ts, lastActiveAt: ts, }; - const withTab: TabsSnapshot = { ...snapshot, tabs: [...snapshot.tabs, tab] }; + const withTab: TabsSnapshot = { + ...snapshot, + tabs: [...snapshot.tabs, tab], + panes: [...snapshot.panes, pane], + }; return { - snapshot: setActiveTab(withTab, windowId, tab.id), - tabId: tab.id, + snapshot: setActiveTab(withTab, windowId, tabId), + tabId, + paneId, opened: true, }; } /** - * Append a blank tab (no target) and focus it. The strip shows it as an empty - * placeholder; navigating while it is active replaces its contents via - * {@link setTabTarget}. + * Append a blank tab (single blank pane) and focus it. The strip shows it as + * an empty placeholder; navigating while it is active fills it via + * {@link setPaneTarget}. */ export function newBlankTab( snapshot: TabsSnapshot, - input: { windowId: string; makeId: IdFactory; now: Clock }, + input: { + windowId: string; + tabId?: string; + paneId?: string; + makeId: IdFactory; + now: Clock; + }, ): OpenTabResult { return appendTab(snapshot, { windowId: input.windowId, - dashboardId: null, - taskId: null, - channelId: null, + identity: BLANK_PANE_IDENTITY, + tabId: input.tabId, + paneId: input.paneId, makeId: input.makeId, now: input.now, }); } /** - * Point an existing tab at a target (canvas or task) — the in-tab navigation - * primitive. Used when the user navigates while a tab is active, so the target - * replaces the tab's contents instead of opening a new tab. Also focuses it. + * Point an existing pane at a target — the in-pane navigation primitive. Used + * when a pane's router location changes, so the target replaces the pane's + * contents instead of opening a new tab. Also focuses the pane within its tab + * and activates the tab (a navigating pane is by construction in the active, + * rendered tab; re-asserting both heals any drift). */ -export function setTabTarget( +export function setPaneTarget( snapshot: TabsSnapshot, - input: TabTarget & { - tabId: string; - channelId: string | null; - channelSection?: string | null; - appView?: string | null; - now: Clock; - }, + input: PaneIdentity & { paneId: string; now: Clock }, ): TabsSnapshot { - const tab = snapshot.tabs.find((t) => t.id === input.tabId); - if (!tab) return snapshot; + const pane = snapshot.panes.find((p) => p.id === input.paneId); + if (!pane) return snapshot; + const identity = paneIdentityOf(input); const ts = input.now(); const withTarget: TabsSnapshot = { ...snapshot, + panes: snapshot.panes.map((p) => + p.id === input.paneId ? { ...p, ...identity, lastActiveAt: ts } : p, + ), tabs: snapshot.tabs.map((t) => - t.id === input.tabId - ? { - ...t, - dashboardId: input.dashboardId, - taskId: input.taskId, - channelId: input.channelId, - channelSection: input.channelSection ?? null, - appView: input.appView ?? null, - lastActiveAt: ts, - } + t.id === pane.tabId + ? { ...t, focusedPaneId: pane.id, lastActiveAt: ts } : t, ), }; - return setActiveTab(withTarget, tab.windowId, input.tabId); + return setWindowActiveTab(withTarget, pane.windowId, pane.tabId); } /** - * Close a tab. Focus moves to the nearest sibling. Closing the last tab of a - * secondary window signals that the window should close; closing the last tab - * of the primary window leaves it on the channels landing (activeTabId null). + * Close a tab (all its panes). Focus moves to the nearest sibling. Closing the + * last tab of a secondary window signals that the window should close; closing + * the last tab of the primary window backfills a fresh blank tab so the strip + * is never empty. `blankTabId`/`blankPaneId` let the renderer mint the + * backfill ids for optimistic-apply agreement. */ export function closeTab( snapshot: TabsSnapshot, tabId: string, + deps: { + makeId: IdFactory; + now: Clock; + blankTabId?: string; + blankPaneId?: string; + }, ): CloseTabResult { const tab = snapshot.tabs.find((t) => t.id === tabId); if (!tab) { @@ -295,28 +399,35 @@ export function closeTab( const idx = siblings.findIndex((t) => t.id === tabId); const remaining = siblings.filter((t) => t.id !== tabId); - const removedTabs = snapshot.tabs.filter((t) => t.id !== tabId); + const removed: TabsSnapshot = { + ...snapshot, + tabs: snapshot.tabs.filter((t) => t.id !== tabId), + panes: snapshot.panes.filter((p) => p.tabId !== tabId), + }; if (remaining.length === 0) { if (window && !window.isPrimary) { // Drop the window too. return { snapshot: { + ...removed, windows: snapshot.windows.filter((w) => w.id !== tab.windowId), - tabs: removedTabs, }, nextActiveTabId: null, closedWindowId: tab.windowId, }; } - // Primary window → channels landing. + // Primary window → never-empty strip: backfill a blank tab. + const backfilled = newBlankTab(removed, { + windowId: tab.windowId, + tabId: deps.blankTabId, + paneId: deps.blankPaneId, + makeId: deps.makeId, + now: deps.now, + }); return { - snapshot: setActiveTab( - { ...snapshot, tabs: removedTabs }, - tab.windowId, - null, - ), - nextActiveTabId: null, + snapshot: backfilled.snapshot, + nextActiveTabId: backfilled.tabId, closedWindowId: null, }; } @@ -324,9 +435,10 @@ export function closeTab( // Focus the tab that took the closed slot, else the new last one. const next = remaining[Math.min(idx, remaining.length - 1)]; const wasActive = window?.activeTabId === tabId; - const base: TabsSnapshot = { ...snapshot, tabs: removedTabs }; return { - snapshot: wasActive ? setActiveTab(base, tab.windowId, next.id) : base, + snapshot: wasActive + ? setActiveTab(removed, tab.windowId, next.id) + : removed, nextActiveTabId: wasActive ? next.id : (window?.activeTabId ?? null), closedWindowId: null, }; @@ -335,8 +447,8 @@ export function closeTab( /** * Close several tabs at once — the bulk primitive behind "close other tabs" / * "close tabs to the right/left". Composes {@link closeTab} so the per-window - * succession rules (survivor focus, secondary-window drop, primary lands on - * channels) live in exactly one place. + * succession rules (survivor focus, secondary-window drop, primary backfill) + * live in exactly one place. * * `focusTabId` is the bulk close's anchor (the right-clicked tab, which always * survives these operations). When a window's active tab is among those closed, @@ -347,7 +459,8 @@ export function closeTab( export function closeTabs( snapshot: TabsSnapshot, tabIds: string[], - focusTabId?: string | null, + focusTabId: string | null | undefined, + deps: { makeId: IdFactory; now: Clock }, ): TabsSnapshot { const ids = new Set(tabIds); if (ids.size === 0) return snapshot; @@ -361,7 +474,7 @@ export function closeTabs( let next = snapshot; for (const id of ids) { - next = closeTab(next, id).snapshot; + next = closeTab(next, id, deps).snapshot; } if (focusTabId) { @@ -373,6 +486,98 @@ export function closeTabs( return next; } +/** + * Remove a pane from its tab's layout (the hover close-X on a pane). The + * tab survives with its remaining panes; focus moves to the first remaining + * leaf when the closed pane was focused. No-op when the pane is the tab's + * only one (close the tab instead — the UI hides the X then). + */ +export function closePane( + snapshot: TabsSnapshot, + tabId: string, + paneId: string, +): TabsSnapshot { + const tab = snapshot.tabs.find((t) => t.id === tabId); + const pane = snapshot.panes.find((p) => p.id === paneId); + if (!tab || !pane || pane.tabId !== tabId) return snapshot; + const layout = removePaneFromLayout(tab.layout, paneId); + if (layout === null || layout === tab.layout) return snapshot; + const focusedPaneId = + tab.focusedPaneId === paneId + ? collectLeafPaneIds(layout)[0] + : tab.focusedPaneId; + return { + ...snapshot, + tabs: snapshot.tabs.map((t) => + t.id === tabId ? { ...t, layout, focusedPaneId } : t, + ), + panes: snapshot.panes.filter((p) => p.id !== paneId), + }; +} + +/** + * Merge one tab's panes into another as a split — the drop primitive for + * dragging a pill onto the content area. The source tab's whole layout + * subtree is spliced next to `targetPaneId` (or the layout root when null) + * on the `direction` side; the source tab disappears from the strip and the + * target tab gains its panes, with focus landing on the source's focused + * pane. No-op when source and target are the same tab, live in different + * windows, or either is missing. + */ +export function mergeTabIntoTab( + snapshot: TabsSnapshot, + input: { + windowId: string; + sourceTabId: string; + targetTabId: string; + targetPaneId: string | null; + direction: SplitDropDirection; + now: Clock; + }, +): TabsSnapshot { + const { windowId, sourceTabId, targetTabId, targetPaneId, direction } = input; + if (sourceTabId === targetTabId) return snapshot; + const source = snapshot.tabs.find((t) => t.id === sourceTabId); + const target = snapshot.tabs.find((t) => t.id === targetTabId); + if (!source || !target) return snapshot; + if (source.windowId !== windowId || target.windowId !== windowId) { + return snapshot; + } + if ( + targetPaneId !== null && + !collectLeafPaneIds(target.layout).includes(targetPaneId) + ) { + return snapshot; + } + const layout = insertNodeInLayout( + target.layout, + targetPaneId, + direction, + source.layout, + ); + if (layout === target.layout) return snapshot; + const ts = input.now(); + const merged: TabsSnapshot = { + ...snapshot, + tabs: snapshot.tabs + .filter((t) => t.id !== sourceTabId) + .map((t) => + t.id === targetTabId + ? { + ...t, + layout, + focusedPaneId: source.focusedPaneId, + lastActiveAt: ts, + } + : t, + ), + panes: snapshot.panes.map((p) => + p.tabId === sourceTabId ? { ...p, tabId: targetTabId } : p, + ), + }; + return setActiveTab(merged, windowId, targetTabId); +} + /** * Persist a window's full tab order — the drop primitive for drag-to-reorder. * The UI sends the final stored order (pin-agnostic; the pinned-first display @@ -406,191 +611,316 @@ export function setTabOrder( return changed ? { ...snapshot, tabs } : snapshot; } -// ----- Navigation intent (drives the renderer effect) ----- +/** + * Set the sizes of a split in a tab's layout, addressed by child-index path + * (the resize-gesture commit). Validation lives in `setSplitSizesAtPath`; an + * invalid path or sizes leaves the snapshot unchanged. + */ +export function setPaneSizes( + snapshot: TabsSnapshot, + tabId: string, + path: number[], + sizes: number[], +): TabsSnapshot { + const tab = snapshot.tabs.find((t) => t.id === tabId); + if (!tab) return snapshot; + const layout = setSplitSizesAtPath(tab.layout, path, sizes); + if (layout === tab.layout) return snapshot; + return { + ...snapshot, + tabs: snapshot.tabs.map((t) => (t.id === tabId ? { ...t, layout } : t)), + }; +} + +// ----- Snapshot healing ----- /** - * What a navigation means for the tab strip, given the router state. This is the - * decision the renderer makes on every location change; extracted as a pure - * function so the UX rules are testable without a router. + * Heal a snapshot into the invariants every transform assumes. Applied on + * load (and after any remote write) so one bad row can't wedge the strip: + * + * - a primary window exists; + * - every tab lives in an existing window (orphans move to the primary); + * - every pane belongs to an existing tab (orphans are dropped) and agrees + * with its tab's window; + * - every tab's layout is canonical, and its leaves biject with the tab's + * pane rows (unknown leaves are pruned, unlisted panes grafted in, a + * paneless tab gets a fresh blank pane); + * - every tab's `focusedPaneId` is one of its leaves; + * - every window holds >= 1 tab (the primary is backfilled with a blank tab; + * an empty secondary window is dropped) and its `activeTabId` is one of + * its tabs; + * - tab positions within a window are unique (stable reindex on collision). * - * - `activate`: the entry is tagged with a tab (a tab switch, or a back/forward - * replay landing on a tab) → focus that tab. - * - `replace`: an untagged navigation to a target (canvas or task) while a tab - * is active → swap the active tab's target in place (in-tab navigation), and - * stamp the entry. - * - `open`: an untagged navigation to a target with no active tab → open one. - * - `stamp`: an untagged navigation whose target already matches the active tab - * → nothing to change, just tag the entry so back/forward can replay it. - * - `noop`: nothing to do (already on the right tab, or a blank/landing route). + * Returns the same reference when nothing needed healing — callers use that + * to decide whether to re-persist. */ -export type TabNavDecision = - | { type: "activate"; tabId: string } - | { - type: "replace"; - tabId: string; - dashboardId: string | null; - taskId: string | null; - channelId: string | null; - channelSection: string | null; - appView: string | null; - stampTabId: string | null; +export function ensureSnapshotIntegrity( + snapshot: TabsSnapshot, + deps: { makeId: IdFactory; now: Clock }, +): TabsSnapshot { + const { makeId, now } = deps; + let changed = false; + + // Primary window exists. + let windows = snapshot.windows; + if (!windows.some((w) => w.isPrimary)) { + changed = true; + windows = + windows.length > 0 + ? windows.map((w, i) => (i === 0 ? { ...w, isPrimary: true } : w)) + : [ + { + id: makeId(), + isPrimary: true, + bounds: null, + activeTabId: null, + }, + ]; + } + const windowIds = new Set(windows.map((w) => w.id)); + const primaryId = windows.find((w) => w.isPrimary)?.id ?? windows[0].id; + + // Tabs live in existing windows. + let tabs = snapshot.tabs; + if (tabs.some((t) => !windowIds.has(t.windowId))) { + changed = true; + tabs = tabs.map((t) => + windowIds.has(t.windowId) ? t : { ...t, windowId: primaryId }, + ); + } + const tabById = new Map(tabs.map((t) => [t.id, t])); + + // Panes belong to existing tabs (orphans drop) and agree with the tab's + // window. + let panes = snapshot.panes; + { + let panesChanged = false; + const healed: BrowserPane[] = []; + for (const p of panes) { + const tab = tabById.get(p.tabId); + if (!tab) { + panesChanged = true; + continue; + } + if (tab.windowId !== p.windowId) { + panesChanged = true; + healed.push({ ...p, windowId: tab.windowId }); + } else { + healed.push(p); + } } - | { - type: "open"; - dashboardId: string | null; - taskId: string | null; - channelId: string | null; - channelSection: string | null; - appView: string | null; - stampTabId: string | null; + if (panesChanged) { + changed = true; + panes = healed; } - | { type: "stamp"; stampTabId: string } - | { type: "noop" }; - -export function decideTabNavigation(input: { - /** tabId carried in the current history entry, if any. */ - historyTabId: string | null; - /** - * Ids of the tabs that currently exist in this window. A history entry can - * be tagged with a tab that has since been closed (back/forward replays the - * entry); such a dead tag must NOT activate — it falls through and the route - * decides (in-tab replace / open / stamp), which also re-stamps the entry - * with a live tab. When omitted, tags are trusted (legacy behaviour). - */ - windowTabIds?: readonly string[]; - /** - * The window's tabs with their identities. When a navigation's route matches - * an existing tab that isn't the active one, we activate that tab instead of - * replacing the active tab's target (which would duplicate it) or opening a - * second copy. This also self-heals a rapid tab switch whose history stamp - * was lost: it arrives looking like an in-tab nav, but the route still - * identifies the intended tab, so we focus it rather than corrupt the active - * tab. When omitted, this dedup is skipped (legacy behaviour). - */ - windowTabs?: readonly (TabIdentity & { id: string })[]; - /** The window's active tab id from the server snapshot (lags history). */ - serverActiveTabId: string | null; - /** The active tab record, if one exists. */ - activeTab: { - id: string; - dashboardId: string | null; - taskId: string | null; - channelId?: string | null; - channelSection?: string | null; - appView?: string | null; - } | null; - /** Canvas in the current route, if any. */ - routeDashboardId: string | null; - /** Task in the current route, if any. */ - routeTaskId: string | null; - routeChannelId: string | null; - /** Channel sub-section in the current route, if any. */ - routeChannelSection?: string | null; - /** Top-level app page in the current route, if any. */ - routeAppView?: string | null; -}): TabNavDecision { - const { - historyTabId, - serverActiveTabId, - activeTab, - routeDashboardId, - routeTaskId, - routeChannelId, - } = input; - const routeChannelSection = input.routeChannelSection ?? null; - const routeAppView = input.routeAppView ?? null; - - // Tagged entry for a DIFFERENT tab → a tab switch or a back/forward replay. - // Focus it (this is how "back returns to the previous tab" resolves). Two - // guards: (1) the tagged tab must still exist — back/forward can replay an - // entry whose tab was closed, and activating a dead id persists a dangling - // activeTabId (every nav then opens a new tab); (2) when the tag equals the - // active tab we must NOT stop here: an in-tab nav can arrive tagged with the - // active tab — fall through and decide from the route. - const historyTabIsLive = - !!historyTabId && - (input.windowTabIds ? input.windowTabIds.includes(historyTabId) : true); - if (historyTabId && historyTabIsLive && historyTabId !== serverActiveTabId) { - return { type: "activate", tabId: historyTabId }; } - // Navigation within the active tab. A real target is a canvas, a task, or a - // channel (home or sub-section); the landing/blank route (no channel) is a - // noop. - const routeIdentity: TabIdentity = { - dashboardId: routeDashboardId, - taskId: routeTaskId, - channelId: routeChannelId, - channelSection: routeChannelSection, - appView: routeAppView, - }; - if (!routeDashboardId && !routeTaskId && !routeChannelId && !routeAppView) { - return { type: "noop" }; + // Per tab: canonical layout, leaf↔pane bijection, valid focused pane. + const panesByTab = new Map(); + for (const p of panes) { + const list = panesByTab.get(p.tabId); + if (list) list.push(p); + else panesByTab.set(p.tabId, [p]); + } + const extraPanes: BrowserPane[] = []; + tabs = tabs.map((tab) => { + const own = panesByTab.get(tab.id) ?? []; + const ownIds = new Set(own.map((p) => p.id)); + + // Prune leaves without a pane row, then canonicalise. + let layout: PaneLayoutNode | null = tab.layout; + for (const leafId of collectLeafPaneIds(tab.layout)) { + if (!ownIds.has(leafId) && layout !== null) { + layout = removePaneFromLayout(layout, leafId); + } + } + layout = layout === null ? null : normalizeLayout(layout); + + // Graft pane rows missing from the layout. + const leafIds = new Set(layout ? collectLeafPaneIds(layout) : []); + for (const p of own) { + if (leafIds.has(p.id)) continue; + layout = + layout === null + ? { type: "leaf", paneId: p.id } + : insertPaneInLayout(layout, null, "right", p.id); + leafIds.add(p.id); + } + + // A tab with no panes at all gets a fresh blank one. + if (layout === null) { + const ts = now(); + const pane: BrowserPane = { + id: makeId(), + tabId: tab.id, + windowId: tab.windowId, + ...BLANK_PANE_IDENTITY, + scrollState: null, + createdAt: ts, + lastActiveAt: ts, + }; + extraPanes.push(pane); + layout = { type: "leaf", paneId: pane.id }; + leafIds.add(pane.id); + } + + const focusedPaneId = leafIds.has(tab.focusedPaneId) + ? tab.focusedPaneId + : collectLeafPaneIds(layout)[0]; + + if (layout === tab.layout && focusedPaneId === tab.focusedPaneId) { + return tab; + } + changed = true; + return { ...tab, layout, focusedPaneId }; + }); + if (extraPanes.length > 0) { + changed = true; + panes = [...panes, ...extraPanes]; } - const activeMatchesRoute = - !!activeTab && - sameIdentity( + // Windows hold >= 1 tab; empty secondaries drop, the primary backfills. + const tabsOf = (windowId: string) => + tabs.filter((t) => t.windowId === windowId); + const emptySecondaries = windows.filter( + (w) => !w.isPrimary && tabsOf(w.id).length === 0, + ); + if (emptySecondaries.length > 0) { + changed = true; + const dead = new Set(emptySecondaries.map((w) => w.id)); + windows = windows.filter((w) => !dead.has(w.id)); + } + if (tabsOf(primaryId).length === 0) { + changed = true; + const ts = now(); + const paneId = makeId(); + const tabId = makeId(); + panes = [ + ...panes, + { + id: paneId, + tabId, + windowId: primaryId, + ...BLANK_PANE_IDENTITY, + scrollState: null, + createdAt: ts, + lastActiveAt: ts, + }, + ]; + tabs = [ + ...tabs, { - dashboardId: activeTab.dashboardId, - taskId: activeTab.taskId, - channelId: activeTab.channelId ?? null, - channelSection: activeTab.channelSection ?? null, - appView: activeTab.appView ?? null, + id: tabId, + windowId: primaryId, + layout: { type: "leaf", paneId }, + focusedPaneId: paneId, + position: POSITION_GAP, + createdAt: ts, + lastActiveAt: ts, }, - routeIdentity, + ]; + } + + // Valid activeTabId per window (fall back to the most recently active tab). + windows = windows.map((w) => { + const own = tabsOf(w.id); + if (w.activeTabId && own.some((t) => t.id === w.activeTabId)) return w; + const fallback = [...own].sort( + (a, b) => b.lastActiveAt - a.lastActiveAt, + )[0]; + const activeTabId = fallback ? fallback.id : null; + if (activeTabId === w.activeTabId) return w; + changed = true; + return { ...w, activeTabId }; + }); + + // Unique tab positions per window (stable reindex on collision). + for (const w of windows) { + const own = tabsOf(w.id); + const positions = new Set(own.map((t) => t.position)); + if (positions.size === own.length) continue; + changed = true; + const reindexed = new Map( + [...own] + .sort((a, b) => a.position - b.position || a.createdAt - b.createdAt) + .map((t, i) => [t.id, (i + 1) * POSITION_GAP]), ); + tabs = tabs.map((t) => { + const pos = reindexed.get(t.id); + return pos === undefined || pos === t.position + ? t + : { ...t, position: pos }; + }); + } + + return changed ? { windows, tabs, panes } : snapshot; +} - // A blank active tab is a fresh `+` tab waiting for its first target: the - // navigation is "fill me", never a switch — so the dedup below must not - // steal it (activating another tab would strand the blank forever). - const activeIsBlank = - !!activeTab && - activeTab.dashboardId == null && - activeTab.taskId == null && - (activeTab.channelId ?? null) == null && - (activeTab.appView ?? null) == null; - - // The route already lives in another tab → focus it instead of replacing the - // active tab's target (which would leave two tabs on the same identity) or - // opening a duplicate. Also recovers a rapid switch whose history tag was - // lost: the intended tab is still identified by the route. Only when the - // active tab does NOT already show the route — otherwise, if a duplicate tab - // already exists, we'd bounce between the two identical tabs forever. - if (!activeMatchesRoute && !activeIsBlank) { - const existingMatch = input.windowTabs?.find( - (t) => t.id !== activeTab?.id && sameIdentity(t, routeIdentity), +// ----- Navigation intent (drives the per-pane renderer effect) ----- + +/** History action reported by the pane router for the navigation. */ +export type PaneHistoryAction = + | "PUSH" + | "REPLACE" + | "BACK" + | "FORWARD" + | "GO" + | null; + +/** + * What a pane's location change means for the tab system. This is the decision + * a pane's reconcile effect makes on every location change; extracted as a + * pure function so the UX rules are testable without a router. + * + * - `noop`: nothing to do — the route is blank/landing, or the pane already + * shows it. + * - `activateTab`: the route's identity is already open in another pane of + * this window → focus that pane's tab instead of duplicating the page. + * Only on PUSH navigations (fresh user intent): back/forward replays a + * pane's own history and must never be hijacked to another tab. Never when + * this pane is blank — a blank pane's first navigation is "fill me". + * - `replacePane`: an ordinary in-pane navigation → point the pane at the + * route's identity (`setPaneTarget`). + */ +export type PaneNavDecision = + | { type: "noop" } + | { type: "activateTab"; tabId: string; paneId: string } + | { type: "replacePane" }; + +export function decidePaneNavigation(input: { + /** The pane's current identity (from the snapshot mirror). */ + paneIdentity: PaneIdentity; + /** Identity the pane's router location resolves to. */ + routeIdentity: PaneIdentity; + /** Every other pane in the window (any tab), with its owner tab. */ + otherOpenPanes: readonly { + tabId: string; + paneId: string; + identity: PaneIdentity; + }[]; + historyAction: PaneHistoryAction; +}): PaneNavDecision { + const { paneIdentity, routeIdentity, otherOpenPanes, historyAction } = input; + + // The landing/blank route points at nothing — leave the pane as it is. + if (paneIsBlank(routeIdentity)) return { type: "noop" }; + if (samePaneIdentity(paneIdentity, routeIdentity)) return { type: "noop" }; + + // Dedup: the page already lives in another pane → focus it there. PUSH + // only — back/forward replays this pane's own history and must not be + // hijacked — and never from a blank pane, whose first navigation fills it. + if (historyAction === "PUSH" && !paneIsBlank(paneIdentity)) { + const existing = otherOpenPanes.find((p) => + samePaneIdentity(p.identity, routeIdentity), ); - if (existingMatch) { - return { type: "activate", tabId: existingMatch.id }; + if (existing) { + return { + type: "activateTab", + tabId: existing.tabId, + paneId: existing.paneId, + }; } } - if (activeTab && !activeMatchesRoute) { - return { - type: "replace", - tabId: activeTab.id, - dashboardId: routeDashboardId, - taskId: routeTaskId, - channelId: routeChannelId, - channelSection: routeChannelSection, - appView: routeAppView, - stampTabId: serverActiveTabId, - }; - } - if (!activeTab) { - return { - type: "open", - dashboardId: routeDashboardId, - taskId: routeTaskId, - channelId: routeChannelId, - channelSection: routeChannelSection, - appView: routeAppView, - stampTabId: serverActiveTabId, - }; - } - // Active tab already shows this target — just tag the entry. - return serverActiveTabId - ? { type: "stamp", stampTabId: serverActiveTabId } - : { type: "noop" }; + return { type: "replacePane" }; } diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 87b891dfe1..cc257b05dd 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -16,29 +16,56 @@ export { FONT_EXTENSIONS, isBinaryFile, } from "./binary"; +export { + collectLeafPaneIds, + insertNodeInLayout, + insertPaneInLayout, + normalizeLayout, + pathToPane, + removePaneFromLayout, + setSplitSizesAtPath, +} from "./browser-pane-layout"; export { activeTabIsBlank, + BLANK_PANE_IDENTITY, type CloseTabResult, + closePane, closeTab, closeTabs, - decideTabNavigation, + decidePaneNavigation, + ensureSnapshotIntegrity, + focusedPaneOfTab, + mergeTabIntoTab, newBlankTab, type OpenTabResult, openOrFocusTab, + type PaneHistoryAction, + type PaneIdentity, + type PaneNavDecision, POSITION_GAP, + paneIdentityOf, + paneIsBlank, primaryWindow, primaryWindowHasNoTabs, + samePaneIdentity, + setFocusedPane, + setPaneSizes, + setPaneTarget, setTabOrder, - setTabTarget, setWindowActiveTab, - type TabNavDecision, - type TabTarget, + tabPanes, } from "./browser-tabs"; export { + type BrowserPane, type BrowserTab, type BrowserWindow, + browserPaneSchema, browserTabSchema, browserWindowSchema, + type PaneLayoutNode, + paneLayoutNodeSchema, + type SplitDropDirection, + splitDropDirectionSchema, type TabsSnapshot, tabsSnapshotSchema, type WindowBounds, diff --git a/packages/ui/src/features/browser-tabs/AGENTS.md b/packages/ui/src/features/browser-tabs/AGENTS.md index 46e3681401..cf598fbe11 100644 --- a/packages/ui/src/features/browser-tabs/AGENTS.md +++ b/packages/ui/src/features/browser-tabs/AGENTS.md @@ -1,284 +1,185 @@ # Browser tabs (Channels canvas surface) -A browser-style tab strip in the Channels title bar (`/website/*`), each tab -fronting an open **canvas, task, or channel sub-section** (a `TabIdentity`: -`dashboardId | taskId | channel(+section) | blank`). -This file documents the UX and the model; edit it when the behaviour changes. - -Canvases and tasks are equal citizens: navigating to either -(`/website/$channelId/dashboards/$dashboardId` or -`/website/$channelId/tasks/$taskId`) replaces the active tab's target in place, -the label resolves from the canvas name or the task title, and switching back -returns to whichever the tab points at. `setTabTarget` is the in-tab-nav -primitive for both. - -Channel sub-sections are tabs too: the header nav (`Inbox`, `Artifacts`, -`Recents`, `CONTEXT.md` — see `canvas/channelSections.ts`) routes to -`/website/$channelId/
`, which is identified by `channelId` + -`channelSection`. The tab labels by the section (`Inbox`) with a `#` icon; the -channel home (`/website/$channelId`, no section) labels by the channel name. -Every channel tab's hover leads with `#` then the page name (the home -tab, whose label already is the channel, shows just the one line). Switching -sections is an in-tab replace — one channel tab, the section is sub-navigation -within it — because the identity differs only by `channelSection`. -Dedup/identity keys on all four fields, so two channels' inboxes (or one -channel's inbox vs artifacts) are distinct tabs. +A browser-style tab strip in the title bar. **Each tab OWNS a split-pane +layout** (Chrome split-view model): the strip pill is the whole tab; the tab's +`layout` is a `PaneLayoutNode` tree whose leaves are **panes**, and panes carry +the identity — an open **canvas, task, channel sub-section, app page, or +blank** (`PaneIdentity`: `dashboardId | taskId | channel(+section) | appView | +blank`). The common single-pane tab is a bare-leaf layout. A multi-pane tab's +pill swaps its content icon for a **layout glyph** (`PaneLayoutGlyph`, a mini +SVG of the actual tree). This file documents the UX and the model; edit it when +the behaviour changes. + +Canvases and tasks are equal citizens: navigating to either inside a pane +replaces that pane's identity in place (`setPaneTarget`), the label resolves +from the canvas name or the task title. Channel sub-sections work the same +(identity differs by `channelSection`); the tab labels by its FOCUSED pane's +identity. + +## Model + +``` +window { activeTabId } — which tab the strip shows +tab { layout, focusedPaneId } — strip unit; owns a pane tree +pane { tabId, identity… } — content unit; one router each +``` + +- **One router per pane** (`createAppRouter`): memory history over the shared + route tree, cached in `paneRouterRegistry` keyed by paneId. Inactive tabs' + panes unmount but their routers stay cached, so locations survive tab + switches. Every navigation writes the pane's href to sessionStorage + (`paneLocationPersistence`) for Cmd+R/HMR restore; across relaunches the + identity on the pane row is the source of truth (`hrefForIdentity`). +- **A pane's location IS its content pointer.** `PaneChrome` (the root route, + mounted once per pane) runs `decidePaneNavigation` on every location change: + `replacePane` → `setPaneTarget` (the ordinary case); `activateTab` → a PUSH + navigation to a page already open in another pane focuses that pane's tab + instead of duplicating (never on back/forward, never from a blank pane); + `noop` for blank/landing routes. There is **no history tabId-stamping** — + that whole v1 mechanism is gone. +- **Tab switches are pure store mutations** (`setWindowActiveTab`), NOT + navigations — browser-like: back/forward move within a pane's own history + and never replay tab switches. The title-bar `< >` buttons follow the + FOCUSED pane (active tab's `focusedPaneId`) via `usePaneHistoryControls` + + the per-router forward tracker. +- **Chrome lives outside the route tree** (`shell/AppShell.tsx`): title bar + (strip, back/forward, usage, PostHog Web), sidebar, global modals, deep + links. It wraps everything in a `RouterContextProvider` bound to the focused + pane's router (`useFocusedPaneRouter`), so chrome hooks (useAppView, Link, + navigate) target the focused pane automatically. `App.tsx` boots the active + tab's focused pane's router, then renders `AppShell → PaneTreeRenderer`. ## Where the logic lives -The feature is deliberately split so the rules are portable and testable: - -- **`@posthog/shared` (`browser-tabs.ts`, `browser-tabs-schemas.ts`)** — pure, - host-neutral logic: the domain shapes (`BrowserTab` / `BrowserWindow` / - `TabsSnapshot` / `TabTarget`), the transforms (`openOrFocusTab`, `newBlankTab`, - `setTabTarget`, `closeTab`, `closeTabs`, `setTabOrder`), `decideTabNavigation` (what a - location change means for the strip), and the snapshot predicates - (`primaryWindow`, `activeTabIsBlank`, `primaryWindowHasNoTabs`) the `/website` - index uses to choose the new-tab screen over a first-channel redirect. No - React, no I/O. This is where behaviour is unit-tested. Back/forward is driven by - router history + `decideTabNavigation`, not a separate action stack. -- **`@posthog/workspace-server` (`services/browser-tabs/`, `db/`)** — the +- **`@posthog/shared`** (`browser-tabs.ts`, `browser-pane-layout.ts`, + `browser-tabs-schemas.ts`) — pure, host-neutral logic: the domain shapes, + the layout tree math (`insertNodeInLayout`/`insertPaneInLayout`, + `removePaneFromLayout`, `setSplitSizesAtPath`, `normalizeLayout` — canonical + form: n-ary splits, sizes sum to 1, no same-direction nesting), the + transforms (`openOrFocusTab` window-wide pane dedup, `newBlankTab`, + `setPaneTarget`, `setWindowActiveTab`, `setFocusedPane`, `closeTab`, + `closeTabs`, `closePane`, `mergeTabIntoTab`, `setTabOrder`, `setPaneSizes`), + `ensureSnapshotIntegrity` (boot healing: primary window, ≥1 tab, layout↔pane + bijection, valid focus/active ids, position de-collision), and + `decidePaneNavigation`. No React, no I/O. Behaviour is unit-tested here. +- **`@posthog/workspace-server`** (`services/browser-tabs/`, `db/`) — the authoritative single-instance `BrowserTabsService` in the main process. Owns - the durable snapshot in sqlite (`browser_tabs` / `browser_windows`), applies - the shared transforms, and emits `snapshotChange` for cross-window fan-out. - The repo persists the whole snapshot as a transactional full replace. -- **host-router (`routers/browser-tabs.router.ts`)** — one-line forwards over the - service + the snapshot subscription. Renderer calls it via `useHostTRPC`; - resolved from the main container (bound in `apps/code` main `di`). -- **`@posthog/core` (`browser-tabs/browserTabsStore.ts`)** — renderer mirror of - the snapshot, seeded once and kept live by the subscription. -- **this folder (`@posthog/ui`)** — `BrowserTabStrip` (container; mounted in the - Channels title bar in `router/routes/__root.tsx`), `TabStrip` (presentational), - `BlankTabView` (the new-tab placeholder), `TaskTabIcon` (sidebar-parity status - icon for task tabs), the client facade, the boot contribution that seeds + - subscribes the store, and **`tabsSync.ts` — the local-first sync policy**: - every operation applies its shared pure transform to the renderer mirror - synchronously (interactions are instant; new tabs mint their id client-side - so no navigation ever waits on IPC), server writes are background persistence, - and while any write is in flight remote snapshot pushes are dropped because - they may predate newer local state. If a push was dropped, the renderer - re-fetches the authoritative snapshot after the write batch settles so a real - mutation from another window is retained; otherwise the last settling write - applies its returned snapshot. This makes rapid tab switching race-free - without losing cross-window updates. - -One source of truth: any window mutates → service writes sqlite + emits → every -window's store updates. No window talks to another directly. The same shape ports -to web: a remote workspace-server + the subscription over WS, only the adapters -differ. Desktop ships first. + the durable snapshot in sqlite (`browser_windows` / `browser_tabs` / + `browser_panes`), applies the shared transforms, heals with + `ensureSnapshotIntegrity` at boot, and emits `snapshotChange` for + cross-window fan-out. The repo persists the whole snapshot as a + transactional full replace; a corrupt tab `layout` degrades to a leaf and + heals. +- **host-router** (`routers/browser-tabs.router.ts`) — one-line forwards. +- **`@posthog/core`** (`browser-tabs/browserTabsStore.ts`) — renderer mirror. +- **this folder (`@posthog/ui`)** — `BrowserTabStrip` (title-bar container), + `TabStrip` (presentational), `PaneLayoutGlyph`, `panes/` (`PaneTreeRenderer` + renders the ACTIVE tab's layout with `react-resizable-panels`; + `BrowserPane` = router + focus ring + pointerdown focus; `PaneDropZones` / + `RootDropZones` merge drops; `paneDragStore` transient drag state), + `BlankTabView`, `TaskTabIcon`, the client facade, the boot contribution, and + **`tabsSync.ts` — the local-first sync policy**: every operation applies its + shared pure transform to the renderer mirror synchronously (interactions are + instant; tabs/panes mint their ids client-side so nothing waits on IPC), + server writes are background persistence, and while any write is in flight + remote snapshot pushes are dropped; a dropped push triggers an authoritative + re-fetch after the write batch settles, else the last settling write + reconciles. ## UX ### The strip -- Lives in the Channels title bar, after a `#title-bar-left` section sized to the - Channels sidebar width so the strip starts flush with the content pane. -- Each tab is a quill `Button` (variant `default`). The active tab is elevated; - inactive tabs are muted. Tabs **shrink to fit** — the strip never scrolls - (`overflow-hidden`, pills `flex-1 basis-[200px]` capped at `max-w-[200px]`). -- Labels **fade** at the right edge (a CSS mask, not an ellipsis). The close - affordance reveals on hover; on hover the button gains right padding so the - label shrinks and its fade follows, clearing room for the close button. -- Icon: a canvas tab uses the template icon (`iconForTemplate`); a task tab uses - `TaskTabIcon` — the **same status icon as the sidebar** (cloud run status, PR - state, generating / unread / pinned / needs-permission), so a tab and its - sidebar row never drift. -- Hover shows a tooltip with the name and (if any) the channel. All tab tooltips - share one `TooltipProvider` so moving across tabs shows each instantly. -- The **active tab's name + highlight follow the current route / history state** - — they update the instant you navigate, not after the server snapshot - round-trips (see Gotchas). - -### Opening, replacing, the new-tab page -- **Navigating while a tab is active replaces that tab's target in place** - (in-tab navigation) — it does *not* open or dedup-focus another tab. -- **New tabs come only from `+`.** `+` opens a **blank tab** (no target); the - content pane renders a quill `` "new tab page" (`BlankTabView`). - Navigating to a canvas/task while the blank tab is active fills it in. -- `openOrFocusTab` **dedups per window** on the full target (canvas or task); - the same target may be open in different windows. - -### Closing -- Closing the active tab focuses its neighbour. -- Closing the last tab of a **secondary** window closes the window; closing the - last tab of the **primary** window empties the strip and lands on the - **new-tab screen** at `/website` — it does *not* jump to the first channel - (see Gotchas). - -### Context menu & pinning -- Right-click on a pill opens a quill `ContextMenu`: **Pin/Unpin tab**, then - **Close tab / Close other tabs / Close tabs to the right / to the left**. - Bulk items disable when they would close nothing. -- Bulk closes go through one `closeMany` procedure backed by the `closeTabs` - transform, which **composes `closeTab`** so the per-window succession rules - live in one place. The UI computes the id list from the strip's **displayed** - (pinned-first) order and passes the right-clicked tab as the `focusTabId` - anchor; when the active tab is among those closed, focus follows the anchor - rather than `closeTab`'s stored-order neighbour (which could be a pinned tab - at the far end of the strip). -- **Pinned tabs are view state, not domain state**: ids live in - `pinnedTabsStore` (zustand `persist` → localStorage). Pinned tabs collapse to - an **icon-only** pill (label moves to the tooltip; the `#channel / home` - hover still applies), sort to the front of the strip, hide the hover close, - and are skipped by every bulk close. Stale pins are pruned against the live - snapshot; unpinning re-homes the tab to the front of the unpinned block - (`frontOfUnpinnedOrder`), applied optimistically so it doesn't double-jump. -- **Single-renderer assumption.** Pins are per-origin: the desktop app is - single-window, so there is no live cross-window sync of pins. For the web - host (multiple browser tabs share the origin) a `storage`-event listener - keeps renderers roughly in step, but the pin-protection on bulk close is a - renderer-side filter — the `closeMany`/`closeTabs` service layer is - pin-agnostic (pins never leave the renderer). The canonical tab **order** - stays pin-agnostic in SQLite; only rendering applies the pinned-first - partition. - -### Drag to reorder -- Pills are `@dnd-kit/react` sortables (x-axis–locked, full-opacity preview), - split into two sortable groups so a drag can't cross the pinned boundary. -- The in-flight preview lives in a **transient view store** (`tabReorderStore`), - never in the domain snapshot mirror: `dragover` reorders the previewed - *stored* order **within the dragged tab's pin group only** (`reorderWithinGroup` - — the other group's stored slots are untouched, so the pinned-first partition - is never baked into stored positions), and the strip renders it. `dragend` - persists the final stored order via `setOrder`/`setTabOrder` (identity- - preserving) after optimistically applying it; a cancel just drops the - preview. Keeping the preview out of the mirror means a concurrent server - snapshot push mid-drag can't clobber it and the app shell doesn't re-render - per `dragover`. - -### Back / forward (the action timeline) -- Every router history entry is **tagged with the tab it belongs to** (`tabId` in - `HistoryState`, via module augmentation). -- **Switching tabs adds history.** Going from tab A to tab B and pressing - **back** returns to A; pressing **forward** returns to B. -- **Back walks one shared, tab-tagged timeline.** Navigations made *within* a - tab are tagged with that tab, so back first steps through the current tab's - own history; **once the current tab has no more history, back continues into - the previous tab** (and forward replays the other way). -- `< >` only move the focus pointer — they never open or close tabs. The active - tab is derived from the current history entry; entries for tabs you've since - closed are skipped. - -### Cross-window & persistence -- Tabs, order, and windows persist to sqlite; the full session (all windows + - their tabs + active tab) is restored on launch. -- Per-tab `scrollState` is reserved but **unwired** — scroll restoration is a - later follow-up (it needs a sandbox postMessage contract; the canvas iframe is - null-origin so the host can't read scroll). +- One strip, in the title bar (mounted by `AppShell`). Pills shrink to fit, + labels fade at the right edge, close reveals on hover — unchanged. +- The pill renders the **focused pane's** identity (icon + label). A + multi-pane tab replaces the icon with the layout glyph. +- Right-click context menu: Pin/Unpin, Close / Close others / to the right / + to the left. Pins are view state (`pinnedTabsStore`), pinned-first display + partition over pin-agnostic stored order — unchanged from v0. +- Cmd+T new tab, Cmd+W close active tab (inner-first vs task editor tabs), + Cmd+1-9 switch (channels on). All are store mutations — no navigation. + +### Splitting (merge a tab into another) +- **Drag tab B's pill onto the content area** while tab A is active: 5 zones + per pane (4 edges = split that side, center = merge to the right of that + pane) + 4 thin root-edge zones (split at the layout root). Drop → B's whole + pane subtree splices into A's layout (`mergeTabIntoTab` + + `insertNodeInLayout`; same-direction nesting flattens), B's pill disappears, + focus lands on B's focused pane. Panes keep their ids, so their routers (and + history) ride along — no router fix-ups. +- Dragging the ACTIVE tab's own pill arms no zones (a tab can't merge into + itself); pill-over-pill stays a reorder. +- **Close a pane** with the hover X (top-right of each pane; multi-pane tabs + only). The layout collapses (`closePane`); the last remaining pane makes the + tab single-pane again (glyph drops). Closing the TAB closes all its panes. +- Panes resize via Chrome-style gutters (w-2 with a grab pill; commit on + drag-end only, `setPaneSizes` tab-scoped). Focus ring: a pointer-transparent + overlay driven by domain focus (`data-focused` ← `focusedPaneId`), shown + only on multi-pane tabs. Clicking anywhere in a pane focuses it + (capture-phase pointerdown). + +### Opening, blanks, closing +- `openOrFocusTab` dedups across **every pane in the window**: if any pane + already shows the identity, its tab activates and the pane focuses. +- `+` appends a blank single-pane tab; the strip handler pre-seeds its router + at the default landing (`/website` new-tab page with channels on, `/code` + otherwise). Navigating fills the pane in place. +- Closing the active tab focuses its neighbour (the pane tree just re-renders; + no navigation). Closing the **primary** window's last tab backfills a fresh + blank tab in the same transform (never-empty strip; ids minted renderer-side + for optimistic agreement). A **secondary** window closes with its last tab. ## Gotchas / implementation notes -- **History state inherits across plain navigations.** A plain `navigate` (e.g. - the sidebar) carries the current entry's `tabId` forward, so an in-tab nav - arrives *tagged with the active tab*. `decideTabNavigation` therefore treats a - tag as a "switch" **only when it differs** from the active tab; an equal tag - falls through to a route-based replace. Getting this wrong makes in-tab - navigation silently noop (the tab reverts on switch-away). -- **Stamp with `loc.href`.** When stamping a history entry, use the full - `router.history.location.href` (a string). Reconstructing `pathname + search` - crashes — `search` is parsed to an object at runtime ("Cannot convert object - to primitive value"), which trips the error boundary and breaks persistence. -- **Active tab is derived from history state, not the server snapshot.** The - history `tabId` flips instantly on navigate; the server `activeTabId` - round-trips. The strip prefers history for "which tab is active" and resolves - the active tab's label from the *route* target so the name/highlight don't lag - a navigation behind. -- **Label resolution is reactive + cached.** Names come from the active - record's warm fetch, then the channel list / all-tasks list, then a - module-level cache — and the `tabs` memo references those sources directly (so - biome's exhaustive-deps doesn't strip them and labels stay reactive). -- **Tab rendering is a wrapper `div` + Button + sibling close button.** The close - cannot nest inside the Button (button-in-button is invalid + fails a11y lint); - it's an absolutely-positioned sibling. The wrapper is `flex` so it hugs the - button height (a block wrapper adds an inline line-box ~2px taller). -- **The `/website` index must not redirect to `channels[0]` while a blank tab is - active or the strip is empty.** The blank `+` tab and the closed-all-tabs state - both park at `/website`, whose `WebsiteChannelsIndex` otherwise ``s to - the first channel. That puts a channel in the route, so `decideTabNavigation` - opens a tab for it — hijacking the blank tab to `channels[0]`, or silently - re-filling a strip the user just emptied. It's guarded with `activeTabIsBlank` - (blank `+` tab) and `primaryWindowHasNoTabs` (closed-all → render - `BlankTabView`), plus an `onIndexPath` check: TanStack renders this *stale* - index for a couple of frames **after** the URL has already left `/website` - (the `__root` Outlet un-suppresses on the way to `/website/$channelId` before - the matched leaf settles), and that stale render must not redirect. -- **All writes are local-first (`tabsSync.ts`).** Close/open/new/reorder apply - their shared transform to the mirror and navigate in the same tick; the - `/website` index therefore always renders against post-mutation state and - can't redirect (re-opening a tab) mid-flight. Mutation results and - subscription pushes are never applied while writes are in flight — only the - last settle reconciles. Don't add a mutation `onSuccess` that calls - `setSnapshot`; route new writes through `applyLocalTransform` + - `persistWrite`. +- **PaneChrome's reconcile effect is keyed on the LOCATION only**; the mirror + is read fresh (`readMirror()`) — subscribing it to the mirror re-runs it in + local-first gaps and mis-fires (same rule as the old strip effect). +- **Dedup is PUSH-only and never from a blank pane** — back/forward replays a + pane's own history and must not be hijacked; a blank pane's first navigation + is "fill me". The history action comes from the per-router tracker + (`getPaneHistoryTracker(router).lastAction()`). +- **Don't clear `paneDragStore`/`tabReorderStore` synchronously in dragend** — + rAF-defer so @dnd-kit finishes DOM cleanup (unmounting zones it still + references throws). +- **BrowserPane creates routers at render time** (RouterProvider needs the + instance immediately); the registry is the cache. Cleanup only when the pane + is truly gone from the snapshot — NOT on transient unmounts (tab switches). +- **globals.css pins `[data-panel-resize-handle-enabled]` to 1px** for the + task-detail panels — the pane gutters override with Tailwind important + (`w-2!`/`h-2!`). +- **The `/website` index must not redirect to `channels[0]` while a blank pane + is active or the strip is empty** — guarded via `activeTabIsBlank` (focused + pane blank) and `primaryWindowHasNoTabs`, plus the stale-index-render check + in `WebsiteChannelsIndex`. +- **All writes are local-first (`tabsSync.ts`).** Don't add a mutation + `onSuccess` that calls `setSnapshot`; route new writes through + `applyLocalTransform` + `persistWrite`. +- **Imperative navigation targets the focused pane.** `routerRef.getRouterOrNull` + and `navigationBridge` resolve through `paneRouterRegistry` — "the" router is + the active tab's focused pane's. +- **Settings renders through a portal** (PaneChrome) covering the window while + staying a route in the pane's history, so `history.back()` still exits it. ## Testing -- **Pure behaviour** is tested in `@posthog/shared` (`browser-tabs.test.ts`): - open/dedup, close (neighbour / secondary-window / primary-landing), - `closeTabs` (bulk close + anchor focus), `setTabOrder`, `newBlankTab`, - `setTabTarget` (canvas + task), and - **`decideTabNavigation`** — which encodes the activate / replace / open / - stamp / noop decision the strip makes on every navigation (including "back - returns to the previous tab" and the inherited-tag in-tab case) — plus the - snapshot predicates (`activeTabIsBlank`, `primaryWindowHasNoTabs`, - `primaryWindow`) that gate the index's new-tab-screen-vs-redirect choice. - `BrowserTabStrip`'s effect dispatches that decision, so the tested function is - the one that runs. -- **Presentational** rendering is tested in `TabStrip.test.tsx` (active styling, - select, close-without-select, new-tab). -- Full back/forward integration across the real router belongs in an E2E - (Playwright) spec, not a unit test. - -## Split view (parked — how to approach it) - -A working prototype (July 2026, since removed — recoverable from git history) -let a pill be dragged off the strip onto right/bottom drop zones over the -content area, splitting the scene into a resizable two-pane -`react-resizable-panels` group. What we learned, for whoever picks it up: - -- **The constraint:** one TanStack Router = one location = one ``. - Two panes can't both be routes. Three ways out, in order of preference: - 1. **Router-less target pane** (what the prototype did): the secondary pane - renders the tab's target directly by id. `WebsiteDashboard` already takes - `dashboardId` as a prop and `TaskDetail` takes a `task` (replicate the - cache-first fetch from `routes/website/$channelId/tasks/$taskId.tsx`) — - both mount standalone today. **Channel views (inbox/artifacts/…) are the - blocker**: they read route params/loaders throughout, so they need a - props-parameterization pass before they can render in a pane. That - refactor is most of the remaining work. - 2. **Second router over memory history** — renders any route, but needs a - chrome-less root and confuses the tab-strip navigation effect - (`decideTabNavigation` assumes one router). - 3. **Tear-off to a second OS window** — the tabs data model already supports - it (`browser_windows`, secondary-window close semantics in - `closeTab`/`closeTabs`); Electron-only. -- **Wiring that already exists and stays:** `BrowserTabsDndProvider` wraps the - channels chrome, so drop zones over the content area just register - `useDroppable` targets in the same scope; pill drag data is - `{ type: "browser-tab", tabId }`. The prototype's pieces were a persisted - `splitViewStore` (identity + direction + transient `isDraggingTab`), a - `TabSplitLayout` wrapper around the outlet box in `__root.tsx`, and a - split-zone branch in the provider's `dragend`. -- **UX decisions already settled:** zones are right 35% / bottom 35% - (non-overlapping), a second drop replaces the split, a blank tab is - rejected, the split persists across relaunch, and a header X closes it. -- **Open questions for the real version:** should the split pane get its own - tab strip (it probably wants the panels feature's tree model instead of a - single-pane store); how does the active-tab highlight relate to the - secondary pane; and whether in-pane navigation should be possible at all - without a router. +- **Pure behaviour** in `@posthog/shared`: `browser-tabs.test.ts` (transforms, + integrity healing, `decidePaneNavigation`) and + `browser-pane-layout.test.ts` (tree math incl. `insertNodeInLayout` + subtree splices and normalize identity-preservation). +- **Presentational** rendering in `TabStrip.test.tsx`; sync policy in + `tabsSync.test.ts`. +- Live verification: drive the real app over CDP (`test-electron-app` skill) — + merge drops work with synthetic mouse events; resizable-panel gutter drags + do not (buttons=1 isn't held), use a real mouse for those. ## Known rough edges / follow-ups -- Content is rendered by the route `` while the strip's active tab is - store state. An in-tab content replace followed by `back` can briefly show a - route/tab mismatch. Tightening this means rendering the target by the active - tab's id rather than the route. -- Drag-to-reorder is wired (see **Drag to reorder** above). Tear-off to a new - OS window is still unwired. -- Many pinned tabs overflow the strip: pinned pills are incompressible and the - tablist only `overflow-hidden`s (so they clip within the strip rather than - overlap the title bar). A scrollable / overflow-menu strip is a follow-up. -- Scroll restoration (the reserved `scrollState`) is unwired. +- Inactive tabs unmount their panes (terminals detach on tab switch) — same + as pre-split behaviour, not a regression. +- Tear-off to a second OS window is modelled (`browser_windows`, secondary + close semantics) but unwired. +- Many pinned tabs overflow the strip (clip, no scroll) — follow-up. +- Scroll restoration (`scrollState`, now on panes) is reserved/unwired. ## Dev note diff --git a/packages/ui/src/features/browser-tabs/BrowserTabStrip.tsx b/packages/ui/src/features/browser-tabs/BrowserTabStrip.tsx index 8b42382a4c..c491356ac6 100644 --- a/packages/ui/src/features/browser-tabs/BrowserTabStrip.tsx +++ b/packages/ui/src/features/browser-tabs/BrowserTabStrip.tsx @@ -1,40 +1,23 @@ -import { - BrainIcon, - HashIcon, - HouseIcon, - PlugsConnectedIcon, - RobotIcon, - SquaresFourIcon, - TrayIcon, -} from "@phosphor-icons/react"; -import { ROOT_LOGGER, type RootLogger } from "@posthog/di/logger"; -import { useService } from "@posthog/di/react"; +import { HashIcon } from "@phosphor-icons/react"; import { useHostTRPC } from "@posthog/host-router/react"; import { closeTab as closeTabLocal, closeTabs as closeTabsLocal, - decideTabNavigation, + focusedPaneOfTab, newBlankTab as newBlankTabLocal, - openOrFocusTab as openOrFocusLocal, PROJECT_BLUEBIRD_FLAG, + paneIdentityOf, primaryWindow, setTabOrder, - setTabTarget as setTabTargetLocal, setWindowActiveTab, - type TabsSnapshot, } from "@posthog/shared"; import { channelSectionFor } from "@posthog/ui/features/canvas/channelSections"; import { iconForTemplate } from "@posthog/ui/features/canvas/components/canvasTemplateIcon"; -import { - type Channel, - useChannelMutations, - useChannels, -} from "@posthog/ui/features/canvas/hooks/useChannels"; +import { useChannels } from "@posthog/ui/features/canvas/hooks/useChannels"; import { useDashboard, useDashboards, } from "@posthog/ui/features/canvas/hooks/useDashboards"; -import { PERSONAL_CHANNEL_NAME } from "@posthog/ui/features/canvas/hooks/useTaskChannels"; import { SHORTCUTS } from "@posthog/ui/features/command/keyboard-shortcuts"; import { useFeatureFlag } from "@posthog/ui/features/feature-flags/useFeatureFlag"; import { usePanelLayoutStore } from "@posthog/ui/features/panels/panelLayoutStore"; @@ -42,57 +25,35 @@ import { getLeafPanel } from "@posthog/ui/features/panels/panelStoreHelpers"; import { useSidebarStore } from "@posthog/ui/features/sidebar/sidebarStore"; import { taskDetailQuery } from "@posthog/ui/features/tasks/queries"; import { useTasks } from "@posthog/ui/features/tasks/useTasks"; -import { useAppView } from "@posthog/ui/router/useAppView"; +import { createAppRouter } from "@posthog/ui/router/createAppRouter"; +import { setPaneRouter } from "@posthog/ui/router/paneRouterRegistry"; import { useMutation, useQuery } from "@tanstack/react-query"; -import { - useNavigate, - useParams, - useRouter, - useRouterState, -} from "@tanstack/react-router"; -import { type ReactNode, useEffect, useMemo } from "react"; +import { useEffect, useMemo } from "react"; import { useHotkeys } from "react-hotkeys-hook"; +import { APP_VIEW_META, isAppView } from "./appViews"; import { frontOfUnpinnedOrder, partitionPinnedFirst, storedOrderIds, } from "./displayOrder"; +import { PaneLayoutGlyph } from "./PaneLayoutGlyph"; import { usePinnedTabsStore } from "./pinnedTabsStore"; import { TabStrip, type TabView } from "./TabStrip"; import { TaskTabIcon } from "./TaskTabIcon"; +import { defaultBlankPaneHref } from "./tabHref"; import { useTabReorderStore } from "./tabReorderStore"; -import { - applyLocalTransform, - persistWrite, - readMirror, - reseedMirror, -} from "./tabsSync"; +import { applyLocalTransform, persistWrite } from "./tabsSync"; import { useTabsSnapshot } from "./useBrowserTabs"; -/** The active tab id is carried in router history state so back/forward replay - * tab switches. */ -declare module "@tanstack/history" { - interface HistoryState { - tabId?: string; - } -} - /** - * Module-level caches of display info, keyed by id. Tabs store only references; - * names are resolved here as the user navigates (which loads each channel's - * canvases/tasks), so cross-channel tabs still render a real label without - * loading every channel up front. + * Module-level caches of display info, keyed by id. Panes store only + * references; names are resolved here as the user navigates (which loads each + * channel's canvases/tasks), so cross-channel tabs still render a real label + * without loading every channel up front. */ const canvasInfo = new Map(); const taskInfo = new Map(); -// Dedupe concurrent #me provisioning. The folder-creation endpoint isn't -// server-side idempotent, and the new-tab path is on Cmd+T (trivially -// double-fired/held) — so two landings racing before the first create's cache -// update lands could each create a "me" folder. One in-flight create is shared -// across callers until it settles. -let personalChannelInFlight: Promise | null = null; - /** Bounded insert (most-recent kept) so the caches don't grow unbounded over a * long session. */ const MAX_CACHE_ENTRIES = 200; @@ -108,7 +69,7 @@ function remember(map: Map, key: string, value: V): void { // True when the open task's focused editor panel has a closeable active tab. // Cmd+W is inner-first: it closes that editor tab (handled by // usePanelKeyboardShortcuts) before it closes the browser tab. -function taskHasCloseableEditorTab(taskId: string | undefined): boolean { +function taskHasCloseableEditorTab(taskId: string | null): boolean { if (!taskId) return false; const layout = usePanelLayoutStore.getState().getLayout(taskId); const panelId = layout?.focusedPanelId; @@ -120,91 +81,18 @@ function taskHasCloseableEditorTab(taskId: string | undefined): boolean { return !!activeTab && activeTab.closeable !== false; } -type TabRef = { - id: string; - dashboardId: string | null; - taskId: string | null; - channelId: string | null; - channelSection: string | null; - appView: string | null; -}; - -// The top-level app pages that can be a tab. Keyed by useAppView's view.type; -// each maps to its canonical route (a task/canvas/channel tab has its own -// route, these don't) plus the strip's label + icon. -type AppView = - | "home" - | "inbox" - | "agents" - | "skills" - | "mcp-servers" - | "command-center"; - -const APP_VIEW_META: Record = { - home: { label: "Home", icon: }, - inbox: { label: "Inbox", icon: }, - agents: { label: "Agents", icon: }, - skills: { label: "Skills", icon: }, - "mcp-servers": { - label: "MCP servers", - icon: , - }, - "command-center": { - label: "Command center", - icon: , - }, -}; - -function isAppView(value: string): value is AppView { - return value in APP_VIEW_META; -} - +/** + * The window's single tab strip (title bar). Each pill is a whole TAB — which + * owns a pane layout; a multi-pane tab's pill carries a mini glyph of its + * actual configuration. Selecting, closing, and creating tabs are pure store + * mutations (local-first transforms + background persist): no router + * navigation is involved, because pane routers keep their locations and the + * pane tree simply renders the newly active tab. Labels and icons derive from + * each tab's FOCUSED pane's identity. + */ export function BrowserTabStrip() { - const logger = useService(ROOT_LOGGER); const snapshot = useTabsSnapshot(); - const navigate = useNavigate(); - const router = useRouter(); const trpc = useHostTRPC(); - const params = useParams({ strict: false }) as { - channelId?: string; - dashboardId?: string; - taskId?: string; - }; - const historyTabId = useRouterState({ - select: (s) => s.location.state.tabId, - }); - const pathname = useRouterState({ select: (s) => s.location.pathname }); - // Tabs work in both spaces: channel-scoped tabs live under /website, while a - // plain task tab (no channel) belongs to the Code experience. The space - // decides where a task/blank tab navigates. - const inChannels = pathname.startsWith("/website"); - // Top-level app pages (Inbox, Agents, Skills, MCP servers, Command Center, - // Home) are tab targets too. useAppView normalizes both the /code routes and - // their /website mirrors to the same view.type, so a tab survives either space. - const view = useAppView(); - const routeAppView: AppView | null = isAppView(view.type) ? view.type : null; - - const { channels } = useChannels(); - const { createChannel } = useChannelMutations(); - // Whether the channels surface is live — the same gate the sidebar uses. This - // (not the current route) decides a new tab's default: with channels on a - // fresh tab opens #me, otherwise the Code new-task screen. Keying off the - // route would leave the behaviour stale right after the toggle flips. - const bluebirdEnabled = useFeatureFlag( - PROJECT_BLUEBIRD_FLAG, - import.meta.env.DEV, - ); - const channelsEnabled = - useSidebarStore((s) => s.channelsEnabled) && bluebirdEnabled; - - // The active channel sub-section (artifacts/history/context) is the - // route segment after the channelId. Null when on the channel home or a - // non-section route (canvas/task), so a channel-home tab labels by name. - const routeChannelSection = useMemo(() => { - if (!params.channelId) return null; - const seg = pathname.split("/")[3] ?? null; - return channelSectionFor(seg)?.key ?? null; - }, [pathname, params.channelId]); // Local-first sync (see tabsSync.ts): every operation applies its shared // pure transform to the mirror synchronously via applyLocalTransform, then @@ -212,15 +100,9 @@ export function BrowserTabStrip() { // transport — their returned snapshots are handled by persistWrite's // last-settle reconcile, never applied directly, so a stale echo can't // rewind the mirror mid-interaction. - const openOrFocus = useMutation( - trpc.browserTabs.openOrFocus.mutationOptions(), - ); const newBlankTab = useMutation( trpc.browserTabs.newBlankTab.mutationOptions(), ); - const setTabTarget = useMutation( - trpc.browserTabs.setTabTarget.mutationOptions(), - ); const close = useMutation(trpc.browserTabs.close.mutationOptions()); const closeMany = useMutation(trpc.browserTabs.closeMany.mutationOptions()); const setOrder = useMutation(trpc.browserTabs.setOrder.mutationOptions()); @@ -241,31 +123,43 @@ export function BrowserTabStrip() { prunePinned(snapshot.tabs.map((t) => t.id)); }, [snapshot, prunePinned]); + // Whether the channels surface is live — the same gate the sidebar uses. + // This (not the current route) decides a new tab's default landing and + // whether Cmd+1-9 switches browser tabs rather than sidebar tasks. + const bluebirdEnabled = useFeatureFlag( + PROJECT_BLUEBIRD_FLAG, + import.meta.env.DEV, + ); + const channelsEnabled = + useSidebarStore((s) => s.channelsEnabled) && bluebirdEnabled; const win = primaryWindow(snapshot); const windowId = win?.id; - // The history state flips the instant you navigate, while the server snapshot - // round-trips — so prefer it for "which tab is active" to avoid a one-step lag - // in the highlight and the name. Validate it against the live tab list first: - // back/forward can replay an entry tagged with a since-closed tab, and a dead - // id here would blank the strip highlight and point Cmd+W at a tab that no - // longer exists (the navigation effect heals the tag, but asynchronously). - const historyTabIsLive = - !!historyTabId && snapshot.tabs.some((t) => t.id === historyTabId); - const activeTabId = - (historyTabIsLive ? historyTabId : null) ?? win?.activeTabId ?? null; + const activeTabId = win?.activeTabId ?? null; + const activeTab = activeTabId + ? snapshot.tabs.find((t) => t.id === activeTabId) + : undefined; + const activeIdentity = activeTab + ? (() => { + const pane = focusedPaneOfTab(snapshot, activeTab); + return pane ? paneIdentityOf(pane) : null; + })() + : null; // Names feed the tab labels. The channel canvas list + all-tasks list cover - // most tabs; a direct fetch of the *current route's* canvas/task (warm cache - // from the detail page) makes the focused tab's name update the instant you - // navigate — keyed off the route, not the tab's stored (lagging) target. - // Only poll the all-tasks list when a task tab actually needs a title. - const hasTaskTab = snapshot.tabs.some((t) => t.taskId != null); - const { dashboards } = useDashboards(params.channelId); - const { dashboard: activeRecord } = useDashboard(params.dashboardId); - const { data: allTasks } = useTasks(undefined, { enabled: hasTaskTab }); + // most tabs; a direct fetch of the *active pane's* canvas/task (warm cache + // from the detail page) makes the focused tab's name update the instant its + // pane navigates. Only poll the all-tasks list when a task pane actually + // needs a title. + const hasTaskPane = snapshot.panes.some((p) => p.taskId != null); + const { channels } = useChannels(); + const { dashboards } = useDashboards(activeIdentity?.channelId ?? undefined); + const { dashboard: activeRecord } = useDashboard( + activeIdentity?.dashboardId ?? undefined, + ); + const { data: allTasks } = useTasks(undefined, { enabled: hasTaskPane }); const { data: activeTaskRecord } = useQuery({ - ...taskDetailQuery(params.taskId ?? ""), - enabled: !!params.taskId, + ...taskDetailQuery(activeIdentity?.taskId ?? ""), + enabled: !!activeIdentity?.taskId, }); // Remember names so a background tab from another channel keeps its label // after its channel's list unloads. Written in an effect (not during render) @@ -286,157 +180,6 @@ export function BrowserTabStrip() { } }, [dashboards, activeRecord, allTasks, activeTaskRecord]); - // Resolve what the current location means for the strip (see - // decideTabNavigation) and apply it: focus a tab, replace the active tab's - // target in place, open a tab, and/or stamp the history entry with the tab it - // belongs to so back/forward can replay it. - // - // Keyed on the LOCATION only — the route is the command stream; the mirror is - // state this effect reconciles against, read fresh via readMirror() rather - // than subscribed to. Running on mirror changes is actively wrong under - // local-first sync: a handler moves the mirror BEFORE it navigates (e.g. the - // + tab appends and focuses a blank tab), and an effect run in that gap sees - // the OLD location's tag disagree with the new mirror focus and "activates" - // the stale tab — yanking focus back and mis-targeting the follow-up - // navigation as an in-tab replace of the wrong tab. - useEffect(() => { - if (!windowId) return; - const stamp = (tabId: string) => { - const loc = router.history.location; - // Already tagged — skip the replace so history entries and router - // subscribers don't churn. - if ((loc.state as { tabId?: string }).tabId === tabId) return; - // Use the full href (always a string); reconstructing from pathname + - // search crashes because search is parsed to an object at runtime. - router.history.replace(loc.href, { ...(loc.state as object), tabId }); - }; - const mirror = readMirror(); - const mirrorWin = primaryWindow(mirror); - const mirrorTabs = mirror.tabs.filter((t) => t.windowId === windowId); - const mirrorActive = mirrorWin?.activeTabId - ? mirrorTabs.find((t) => t.id === mirrorWin.activeTabId) - : undefined; - const decision = decideTabNavigation({ - historyTabId: historyTabId ?? null, - // Validates history tags: back/forward can replay an entry tagged with a - // closed tab; activating that dead id would persist a dangling - // activeTabId, after which every nav "opens" (no active tab found). - windowTabIds: mirrorTabs.map((t) => t.id), - // Identities of this window's tabs, so a navigation to a target already - // open in another tab focuses it instead of duplicating it (and a rapid - // switch whose history stamp was lost self-heals to the right tab). - windowTabs: mirrorTabs.map((t) => ({ - id: t.id, - dashboardId: t.dashboardId, - taskId: t.taskId, - channelId: t.channelId, - channelSection: t.channelSection, - appView: t.appView, - })), - serverActiveTabId: mirrorWin?.activeTabId ?? null, - activeTab: mirrorActive - ? { - id: mirrorActive.id, - dashboardId: mirrorActive.dashboardId, - taskId: mirrorActive.taskId, - channelId: mirrorActive.channelId, - channelSection: mirrorActive.channelSection, - appView: mirrorActive.appView, - } - : null, - routeDashboardId: params.dashboardId ?? null, - routeTaskId: params.taskId ?? null, - routeChannelId: params.channelId ?? null, - routeChannelSection, - routeAppView, - }); - switch (decision.type) { - case "activate": { - // Focus in the mirror synchronously; persist in the background. - applyLocalTransform((s) => - setWindowActiveTab(s, windowId, decision.tabId), - ); - void persistWrite(() => - setActiveTab.mutateAsync({ windowId, tabId: decision.tabId }), - ); - // Heal the history tag to the tab we're activating. Normally it already - // matches (a tagged switch), so `stamp` no-ops. When the dedup path - // activated an existing tab the route pointed at (a switch whose stamp - // was lost), the entry still carries the STALE tab — left unhealed, the - // first branch above would re-activate it next render and ping-pong with - // the dedup (a "Maximum update depth exceeded" loop). Stamping breaks it. - stamp(decision.tabId); - break; - } - case "replace": { - const target = { - tabId: decision.tabId, - dashboardId: decision.dashboardId, - taskId: decision.taskId, - channelId: decision.channelId, - channelSection: decision.channelSection, - appView: decision.appView, - }; - // Synchronous local apply keeps re-entrant runs (and the /website index - // redirect guard) from ever seeing the pre-navigation target. - applyLocalTransform((s) => - setTabTargetLocal(s, { ...target, now: Date.now }), - ); - void persistWrite(() => setTabTarget.mutateAsync(target)); - if (decision.stampTabId) stamp(decision.stampTabId); - break; - } - case "open": { - const input = { - windowId, - dashboardId: decision.dashboardId, - taskId: decision.taskId, - channelId: decision.channelId, - channelSection: decision.channelSection, - appView: decision.appView, - }; - // Mint the id here so the local apply and the persisted state agree on - // it; openOrFocusLocal may instead dedup-focus an existing tab, in - // which case the minted id goes unused (identically on the server). - const mintedId = crypto.randomUUID(); - let openedTabId: string = mintedId; - applyLocalTransform((s) => { - const result = openOrFocusLocal(s, { - ...input, - makeId: () => mintedId, - now: Date.now, - }); - openedTabId = result.tabId; - return result.snapshot; - }); - void persistWrite(() => - openOrFocus.mutateAsync({ ...input, tabId: mintedId }), - ); - // Stamp the entry with the tab that now owns this route. - stamp(openedTabId); - break; - } - case "stamp": - stamp(decision.stampTabId); - break; - } - }, [ - // windowId flips once when the boot seed lands — that run adopts the - // initial route. Everything else here is location; mirror state is read - // fresh inside, deliberately NOT a dependency (see the comment above). - windowId, - historyTabId, - params.channelId, - params.dashboardId, - params.taskId, - routeChannelSection, - routeAppView, - openOrFocus.mutateAsync, - setTabTarget.mutateAsync, - setActiveTab.mutateAsync, - router, - ]); - const channelName = useMemo(() => { const map = new Map(channels.map((c) => [c.id, c.name])); return (id: string | null) => (id ? (map.get(id) ?? null) : null); @@ -481,33 +224,33 @@ export function BrowserTabStrip() { .filter((t) => t !== undefined) .map((t): TabView => { const pinned = pinnedSet.has(t.id); - // The active tab shows the current route's target, so resolve from the - // route (instant) rather than its stored ids (which lag a navigation). - const isActive = t.id === activeTabId; - const taskId = isActive ? (params.taskId ?? null) : t.taskId; - const dashId = isActive ? (params.dashboardId ?? null) : t.dashboardId; - const channelId = isActive ? (params.channelId ?? null) : t.channelId; - const section = isActive ? routeChannelSection : t.channelSection; - const appView = isActive ? routeAppView : t.appView; - const channel = channelName(channelId); - if (taskId) { - const task = findTask(taskId); + // The pill shows the FOCUSED pane's identity; a multi-pane tab swaps + // the content icon for a glyph of its actual layout. + const pane = focusedPaneOfTab(snapshot, t); + const identity = pane ? paneIdentityOf(pane) : null; + const glyph = + t.layout.type === "split" ? ( + + ) : null; + const channel = channelName(identity?.channelId ?? null); + if (identity?.taskId) { + const task = findTask(identity.taskId); return { id: t.id, - label: task?.title ?? taskInfo.get(taskId) ?? "Task", - icon: , + label: task?.title ?? taskInfo.get(identity.taskId) ?? "Task", + icon: glyph ?? , channelName: channel, pinned, }; } - if (dashId) { - const info = resolveCanvas(dashId); + if (identity?.dashboardId) { + const info = resolveCanvas(identity.dashboardId); return { id: t.id, label: info?.name ?? "Canvas", - icon: iconForTemplate(info?.templateId ?? "freeform", { - size: 14, - }), + icon: + glyph ?? + iconForTemplate(info?.templateId ?? "freeform", { size: 14 }), channelName: channel, pinned, }; @@ -515,12 +258,12 @@ export function BrowserTabStrip() { // A channel tab: a sub-section (Artifacts/Recents/…) or the channel home. // The section drives the label; the channel name carries the `#` hover // context. Home has no section, so it labels by the channel name. - if (channelId) { - const meta = channelSectionFor(section); + if (identity?.channelId) { + const meta = channelSectionFor(identity.channelSection); return { id: t.id, label: meta?.label ?? channel ?? "Channel", - icon: , + icon: glyph ?? , channelName: channel, // No section meta → the channel's index page. isChannelHome: !meta, @@ -528,16 +271,22 @@ export function BrowserTabStrip() { }; } // A top-level app page (Inbox, Agents, Skills, …). - if (appView && isAppView(appView)) { + if (identity?.appView && isAppView(identity.appView)) { return { id: t.id, - label: APP_VIEW_META[appView].label, - icon: APP_VIEW_META[appView].icon, + label: APP_VIEW_META[identity.appView].label, + icon: glyph ?? APP_VIEW_META[identity.appView].icon, channelName: null, pinned, }; } - return { id: t.id, label: "New tab", channelName: null, pinned }; + return { + id: t.id, + label: "New tab", + icon: glyph ?? undefined, + channelName: null, + pinned, + }; }); }, [ snapshot, @@ -549,116 +298,36 @@ export function BrowserTabStrip() { activeRecord, allTasks, activeTaskRecord, - activeTabId, - params.channelId, - params.dashboardId, - params.taskId, - routeChannelSection, - routeAppView, ]); - // Navigate to a tab, tagging the history entry with its id so the switch is - // replayable by back/forward. A canvas/task tab goes to its route; a blank tab - // pushes a plain entry (the empty placeholder renders from the active tab). - const goToTab = (tab: TabRef) => { - const state = (prev: object) => ({ ...prev, tabId: tab.id }); - if (tab.taskId && tab.channelId) { - navigate({ - to: "/website/$channelId/tasks/$taskId", - params: { channelId: tab.channelId, taskId: tab.taskId }, - state, - }); - } else if (tab.taskId) { - // A channel-less task tab — the Code task detail route. - navigate({ - to: "/code/tasks/$taskId", - params: { taskId: tab.taskId }, - state, - }); - } else if (tab.dashboardId && tab.channelId) { - navigate({ - to: "/website/$channelId/dashboards/$dashboardId", - params: { channelId: tab.channelId, dashboardId: tab.dashboardId }, - state, - }); - } else if (tab.channelId) { - const params = { channelId: tab.channelId }; - // Section keys are the route segments; unknown/stale sections (e.g. from - // a since-removed tab type) fall back to the channel home. - const section = channelSectionFor(tab.channelSection); - if (section) { - navigate({ - to: `/website/$channelId/${section.key}` as const, - params, - state, - }); - } else { - navigate({ to: "/website/$channelId", params, state }); - } - } else if (tab.appView && isAppView(tab.appView)) { - // A top-level app page — back to its canonical route (literal `to` per - // case so the router types stay checked). - switch (tab.appView) { - case "home": - navigate({ to: "/code/home", state }); - break; - case "inbox": - navigate({ to: "/code/inbox", state }); - break; - case "agents": - navigate({ to: "/code/agents", state }); - break; - case "skills": - navigate({ to: "/skills", state }); - break; - case "mcp-servers": - navigate({ to: "/mcp-servers", state }); - break; - case "command-center": - navigate({ to: "/command-center", state }); - break; - default: { - // Exhaustiveness guard: a new AppView value fails to compile here - // until its canonical route is wired above — so the tab-target set - // (union + APP_VIEW_META) and this navigation can't drift apart. - const _exhaustive: never = tab.appView; - return _exhaustive; - } - } - } else { - // Blank / landing tab: park on the space's home — the channels index, or - // the Code new-task screen. - navigate({ to: inChannels ? "/website" : "/code", state }); - } - }; - + // A tab switch is a pure store mutation: the pane tree renders the newly + // active tab, whose pane routers kept their locations. No history entry is + // written (browser-like — back/forward stay within a pane). const handleSelect = (tabId: string) => { - const tab = snapshot.tabs.find((t) => t.id === tabId); - if (!tab || !windowId) return; - // goToTab stamps historyTabId; the navigation effect picks it up and issues - // setActiveTab via the "activate" path — no need to also fire it here. - goToTab(tab); + if (!windowId || tabId === activeTabId) return; + applyLocalTransform((s) => setWindowActiveTab(s, windowId, tabId)); + void persistWrite(() => setActiveTab.mutateAsync({ windowId, tabId })); }; - // Navigate to the close's survivor, or — when the last tab was closed — to the - // flag's default landing (#me / new-task), never the /website index (which - // would redirect to channels[0], re-opening a random channel tab). - const applyCloseResult = (next: TabsSnapshot) => { - const w = primaryWindow(next); - const active = w?.activeTabId - ? next.tabs.find((t) => t.id === w.activeTabId) - : null; - if (active) goToTab(active); - else landOnDefault(); - }; - - // Close applies locally and navigates to the survivor in the same tick — the - // /website index therefore always renders against the post-close snapshot - // and can't redirect (re-opening a tab) mid-flight. + // Closing needs no navigation either: succession (or the blank backfill on + // the last tab) is decided inside the transform, and the pane tree follows + // the snapshot. The blank-backfill ids are minted here so the local apply + // and the persisted state agree. const handleClose = (tabId: string) => { - const next = applyLocalTransform((s) => closeTabLocal(s, tabId).snapshot); - applyCloseResult(next); - void persistWrite(() => close.mutateAsync({ tabId })); + const blankTabId = crypto.randomUUID(); + const blankPaneId = crypto.randomUUID(); + applyLocalTransform( + (s) => + closeTabLocal(s, tabId, { + makeId: () => crypto.randomUUID(), + now: Date.now, + blankTabId, + blankPaneId, + }).snapshot, + ); + void persistWrite(() => + close.mutateAsync({ tabId, blankTabId, blankPaneId }), + ); }; // Unpinning re-homes the tab at the front of the unpinned block. Apply the @@ -678,10 +347,12 @@ export function BrowserTabStrip() { // always survives) takes focus if the active tab was among those closed. const handleCloseMany = (tabIds: string[], anchorTabId: string) => { if (tabIds.length === 0) return; - const next = applyLocalTransform((s) => - closeTabsLocal(s, tabIds, anchorTabId), + applyLocalTransform((s) => + closeTabsLocal(s, tabIds, anchorTabId, { + makeId: () => crypto.randomUUID(), + now: Date.now, + }), ); - applyCloseResult(next); void persistWrite(() => closeMany.mutateAsync({ tabIds, focusTabId: anchorTabId }), ); @@ -718,92 +389,36 @@ export function BrowserTabStrip() { ); }; - // The default landing, keyed off the channels toggle (not the current route, - // which lags a toggle flip): #me when channels are on, the Code new-task - // screen otherwise. Deliberately never routes through the /website index, - // which would redirect to channels[0]. `tabId` (a fresh blank tab) fills that - // tab in place; without one (last tab closed) the navigation opens a new tab. - const landOnDefault = (tabId?: string) => { - const state = tabId ? (prev: object) => ({ ...prev, tabId }) : undefined; - if (!channelsEnabled) { - navigate({ to: "/code", state }); - return; - } - // #me is provisioned lazily the first time (same bridge the sidebar's #me - // row uses); fall back to the new-task screen if it can't be created. - void (async () => { - try { - const existing = channels.find((c) => c.name === PERSONAL_CHANNEL_NAME); - if (!existing && !personalChannelInFlight) { - personalChannelInFlight = createChannel( - PERSONAL_CHANNEL_NAME, - ).finally(() => { - personalChannelInFlight = null; - }); - } - const folder = existing ?? (await personalChannelInFlight); - if (!folder) return; - navigate({ - to: "/website/$channelId", - params: { channelId: folder.id }, - state, - }); - } catch { - navigate({ to: "/code", state }); - } - })(); - }; - - // New tab is fully local: mint the id here, append the blank tab to the - // mirror and navigate in the same tick (no IPC wait), then persist with the - // same id so the durable state matches. The service is idempotent on the - // minted id, so a replay can't append a duplicate. - const createBlankTab = (targetWindowId: string) => { + // New tab is fully local: mint the ids here, append the blank tab to the + // mirror, pre-seed its pane's router at the default landing (the /website + // new-tab page with channels on, the Code new-task screen otherwise), then + // persist with the same ids. The service is idempotent on the minted tab + // id, so a replay can't append a duplicate. + const handleNewTab = () => { + if (!windowId) return; const tabId = crypto.randomUUID(); + const paneId = crypto.randomUUID(); applyLocalTransform( (s) => newBlankTabLocal(s, { - windowId: targetWindowId, - makeId: () => tabId, + windowId, + tabId, + paneId, + makeId: () => crypto.randomUUID(), now: Date.now, }).snapshot, ); - landOnDefault(tabId); + const router = createAppRouter({ + paneId, + initialHref: defaultBlankPaneHref(channelsEnabled), + }); + setPaneRouter(paneId, router); + void router.load().catch(() => undefined); void persistWrite(() => - newBlankTab.mutateAsync({ windowId: targetWindowId, tabId }), + newBlankTab.mutateAsync({ windowId, tabId, paneId }), ); }; - const handleNewTab = () => { - if (windowId) { - createBlankTab(windowId); - return; - } - // No window means the mirror never seeded (the boot fetch raced or - // failed) — the click must not die. Re-pull the authoritative snapshot - // (the server always has a primary window) and append into it. Resolve - // the window from the FETCHED snapshot, not the mirror: reseedMirror - // skips the store apply when a local write or newer remote push raced - // the fetch, and the mirror could still be windowless then. - void reseedMirror() - .then((server) => { - const win = server - ? primaryWindow(server) - : primaryWindow(readMirror()); - if (win) { - createBlankTab(win.id); - return; - } - // Should be unreachable (the server always mints a primary window), - // but a silent skip here reproduces the dead-"+" this path exists to - // fix — make it loud instead. - logger.error("browser-tabs: new-tab found no window after reseed"); - }) - .catch((error) => { - logger.error("browser-tabs: new-tab reseed failed", { error }); - }); - }; - // Cmd/Ctrl+T opens a new browser tab. Bound here (not globally) so it only // fires where the strip is mounted; the new-task shortcut owns Cmd/Ctrl+N. useHotkeys( @@ -822,7 +437,7 @@ export function BrowserTabStrip() { SHORTCUTS.CLOSE_TAB, (e) => { e.preventDefault(); - if (taskHasCloseableEditorTab(params.taskId)) return; + if (taskHasCloseableEditorTab(activeIdentity?.taskId ?? null)) return; if (activeTabId) handleClose(activeTabId); }, { enableOnFormTags: true, enableOnContentEditable: true }, diff --git a/packages/ui/src/features/browser-tabs/BrowserTabsDnd.tsx b/packages/ui/src/features/browser-tabs/BrowserTabsDnd.tsx index 810523c7c6..438c893ba6 100644 --- a/packages/ui/src/features/browser-tabs/BrowserTabsDnd.tsx +++ b/packages/ui/src/features/browser-tabs/BrowserTabsDnd.tsx @@ -1,10 +1,16 @@ import { type DragDropEvents, DragDropProvider } from "@dnd-kit/react"; import { browserTabsStore } from "@posthog/core/browser-tabs/browserTabsStore"; import { useHostTRPC } from "@posthog/host-router/react"; -import { primaryWindow, setTabOrder } from "@posthog/shared"; +import { + mergeTabIntoTab, + primaryWindow, + type SplitDropDirection, + setTabOrder, +} from "@posthog/shared"; import { useMutation } from "@tanstack/react-query"; import { type ReactNode, useRef } from "react"; import { reorderWithinGroup, storedOrderIds } from "./displayOrder"; +import { usePaneDragStore } from "./panes/paneDragStore"; import { usePinnedTabsStore } from "./pinnedTabsStore"; import { useTabReorderStore } from "./tabReorderStore"; import { applyLocalTransform, persistWrite } from "./tabsSync"; @@ -14,33 +20,47 @@ function sameOrder(a: string[], b: string[]): boolean { } /** - * DnD scope for browser-tab strip drags, mounted around the channels chrome. + * DnD scope for browser-tab pill drags, mounted around the whole shell (it + * must span the title-bar strip and the active tab's pane/root drop zones). * Handlers ignore any drag that isn't a browser tab, so task-detail's nested - * panel DnD provider keeps working untouched inside the outlet. + * panel DnD provider keeps working untouched inside pane content. * - * The drag preview lives in a transient view store (tabReorderStore), never in - * the domain snapshot mirror: dragover reorders the previewed *stored* order - * within the dragged tab's pin group, the strip renders it (pills shift aside), - * and only dragend persists — optimistically applying the final order to the - * mirror, then `setOrder` to the host. Keeping the preview out of the mirror - * means a concurrent server snapshot push mid-drag can't clobber it, the - * navigation effect and app shell don't churn per dragover, and a cancel simply - * drops the preview. + * Two drop families: + * - pill over pill → live reorder preview via the transient tabReorderStore + * (never the domain mirror — a server push mid-drag can't clobber it and a + * cancel just drops the preview), persisted on dragend; + * - pill onto a pane's merge zone / a content-area root edge → the dragged + * tab merges INTO the active tab as a split (its pill disappears; the + * active tab gains its panes). Center zone = merge to the right of that + * pane. + * + * Every structural drop is one applyLocalTransform + one persistWrite (the + * tabsSync local-first policy). No router work is needed: panes keep their + * ids across a merge, so their routers (and locations) ride along. */ export function BrowserTabsDndProvider({ children }: { children: ReactNode }) { const trpc = useHostTRPC(); const setOrder = useMutation(trpc.browserTabs.setOrder.mutationOptions()); + const mergeMutation = useMutation( + trpc.browserTabs.mergeTabIntoTab.mutationOptions(), + ); /** Stored order captured at dragstart — used to skip a no-op persist. */ const initialOrder = useRef(null); const onDragStart: DragDropEvents["dragstart"] = (event) => { - if (event.operation.source?.data?.type !== "browser-tab") return; + const src = event.operation.source?.data; + if (src?.type !== "browser-tab" || !src.tabId) return; const snapshot = browserTabsStore.getState().snapshot; const win = primaryWindow(snapshot); if (!win) return; const order = storedOrderIds(snapshot, win.id); initialOrder.current = order; useTabReorderStore.getState().setPreviewOrder(order); + // Arm the merge drop zones — but never for the ACTIVE tab's own pill + // (a tab can't merge into itself; PaneChrome double-checks per pane). + if (src.tabId !== win.activeTabId) { + usePaneDragStore.getState().setDrag({ tabId: src.tabId }); + } }; const onDragOver: DragDropEvents["dragover"] = (event) => { @@ -69,27 +89,59 @@ export function BrowserTabsDndProvider({ children }: { children: ReactNode }) { const onDragEnd: DragDropEvents["dragend"] = (event) => { const src = event.operation.source?.data; + const tgt = event.operation.target?.data; const order = useTabReorderStore.getState().previewOrder; const initial = initialOrder.current; initialOrder.current = null; - // Defer clearing the preview + persisting a frame so @dnd-kit finishes its - // DOM cleanup first (same gotcha as the panels feature). + // Defer clearing the transient stores + mutating a frame so @dnd-kit + // finishes its DOM cleanup first (clearing paneDragStore synchronously + // unmounts drop zones dnd-kit still references — same gotcha as the + // panels feature). requestAnimationFrame(() => { useTabReorderStore.getState().setPreviewOrder(null); - if ( - event.canceled || - src?.type !== "browser-tab" || - !order || - (initial && sameOrder(order, initial)) - ) { - return; - } + usePaneDragStore.getState().setDrag(null); + if (event.canceled || src?.type !== "browser-tab" || !src.tabId) return; + const tabId: string = src.tabId; const snapshot = browserTabsStore.getState().snapshot; const win = primaryWindow(snapshot); if (!win) return; - // Apply locally so the strip doesn't flit back to the mirror's pre-drop - // order for a frame; persist through the tabsSync gate so the echo can't - // rewind a newer write. + + // Merge: a pane zone of the active tab, or a content-area root edge. + // Center = "merge here", implemented as adjacent-right of that pane. + const merge = + tgt?.type === "browser-pane-zone" + ? { + targetPaneId: tgt.paneId as string, + direction: (tgt.zone === "center" + ? "right" + : tgt.zone) as SplitDropDirection, + } + : tgt?.type === "browser-root-zone" + ? { + targetPaneId: null, + direction: tgt.zone as SplitDropDirection, + } + : null; + if (merge && win.activeTabId && win.activeTabId !== tabId) { + const targetTabId = win.activeTabId; + const input = { + windowId: win.id, + sourceTabId: tabId, + targetTabId, + targetPaneId: merge.targetPaneId, + direction: merge.direction, + }; + const before = snapshot; + const after = applyLocalTransform((s) => + mergeTabIntoTab(s, { ...input, now: Date.now }), + ); + if (after === before) return; + void persistWrite(() => mergeMutation.mutateAsync(input)); + return; + } + + // Same-strip reorder (pill drop over the strip). + if (!order || (initial && sameOrder(order, initial))) return; applyLocalTransform((s) => setTabOrder(s, win.id, order)); void persistWrite(() => setOrder.mutateAsync({ windowId: win.id, tabIds: order }), diff --git a/packages/ui/src/features/browser-tabs/PaneLayoutGlyph.tsx b/packages/ui/src/features/browser-tabs/PaneLayoutGlyph.tsx new file mode 100644 index 0000000000..31859c911b --- /dev/null +++ b/packages/ui/src/features/browser-tabs/PaneLayoutGlyph.tsx @@ -0,0 +1,66 @@ +import type { PaneLayoutNode } from "@posthog/shared"; + +const SIZE = 14; +const GAP = 1.5; + +type GlyphRect = { x: number; y: number; w: number; h: number }; + +/** Flatten the layout tree into proportional rects within a SIZE×SIZE box. */ +function collectRects( + node: PaneLayoutNode, + x: number, + y: number, + w: number, + h: number, + out: GlyphRect[], +): void { + if (node.type === "leaf") { + out.push({ x, y, w, h }); + return; + } + const n = node.children.length; + const total = (node.direction === "row" ? w : h) - GAP * (n - 1); + const sum = node.sizes.reduce((a, b) => a + b, 0) || 1; + let offset = node.direction === "row" ? x : y; + node.children.forEach((child, i) => { + const share = total * ((node.sizes[i] ?? 1 / n) / sum); + if (node.direction === "row") { + collectRects(child, offset, y, share, h, out); + } else { + collectRects(child, x, offset, w, share, out); + } + offset += share + GAP; + }); +} + +/** + * Mini-diagram of a tab's ACTUAL pane configuration, drawn on the pill of a + * multi-pane tab (visual language borrowed from the command-center layout + * picker's `LayoutIcon`, but rendering the real tree — proportions included — + * rather than preset grids). + */ +export function PaneLayoutGlyph({ layout }: { layout: PaneLayoutNode }) { + const rects: GlyphRect[] = []; + collectRects(layout, 0, 0, SIZE, SIZE, rects); + return ( + + ); +} diff --git a/packages/ui/src/features/browser-tabs/appViews.tsx b/packages/ui/src/features/browser-tabs/appViews.tsx new file mode 100644 index 0000000000..5291895e39 --- /dev/null +++ b/packages/ui/src/features/browser-tabs/appViews.tsx @@ -0,0 +1,45 @@ +import { + BrainIcon, + HouseIcon, + PlugsConnectedIcon, + RobotIcon, + SquaresFourIcon, + TrayIcon, +} from "@phosphor-icons/react"; +import type { ReactNode } from "react"; + +/** + * The top-level app pages that can be a pane target. Keyed by useAppView's + * view.type; each maps to the strip's label + icon (the canonical route lives + * in tabHref.ts). These pages have no channel, task, or dashboard id, so this + * is what lets them be a real pane target (label + restore-on-refocus). + */ +export type AppView = + | "home" + | "inbox" + | "agents" + | "skills" + | "mcp-servers" + | "command-center"; + +export const APP_VIEW_META: Record< + AppView, + { label: string; icon: ReactNode } +> = { + home: { label: "Home", icon: }, + inbox: { label: "Inbox", icon: }, + agents: { label: "Agents", icon: }, + skills: { label: "Skills", icon: }, + "mcp-servers": { + label: "MCP servers", + icon: , + }, + "command-center": { + label: "Command center", + icon: , + }, +}; + +export function isAppView(value: string): value is AppView { + return value in APP_VIEW_META; +} diff --git a/packages/ui/src/features/browser-tabs/browserTabsClient.ts b/packages/ui/src/features/browser-tabs/browserTabsClient.ts index eaf4df383d..e18803f993 100644 --- a/packages/ui/src/features/browser-tabs/browserTabsClient.ts +++ b/packages/ui/src/features/browser-tabs/browserTabsClient.ts @@ -21,23 +21,29 @@ export interface BrowserTabsClient { channelId: string | null; channelSection?: string | null; appView?: string | null; - /** Renderer-minted id for a tab this call may create (local-first sync). */ + /** Renderer-minted ids this call may create with (local-first sync). */ tabId?: string; + paneId?: string; }): Promise; newBlankTab(input: { windowId: string; - /** Renderer-minted id (see openOrFocus.tabId). */ + /** Renderer-minted ids (see openOrFocus). */ tabId?: string; + paneId?: string; }): Promise; - setTabTarget(input: { - tabId: string; + setPaneTarget(input: { + paneId: string; dashboardId: string | null; taskId: string | null; channelId: string | null; channelSection?: string | null; appView?: string | null; }): Promise; - close(tabId: string): Promise; + close(input: { + tabId: string; + blankTabId?: string; + blankPaneId?: string; + }): Promise; setActiveTab(input: { windowId: string; tabId: string | null; diff --git a/packages/ui/src/features/browser-tabs/displayOrder.test.ts b/packages/ui/src/features/browser-tabs/displayOrder.test.ts index 5ac2c00249..35b3a7e8fd 100644 --- a/packages/ui/src/features/browser-tabs/displayOrder.test.ts +++ b/packages/ui/src/features/browser-tabs/displayOrder.test.ts @@ -12,13 +12,9 @@ function tab(id: string, position: number): BrowserTab { return { id, windowId: "w1", - dashboardId: null, - taskId: null, - channelId: null, - channelSection: null, - appView: null, + layout: { type: "leaf", paneId: `${id}-pane` }, + focusedPaneId: `${id}-pane`, position, - scrollState: null, createdAt: 0, lastActiveAt: 0, }; @@ -29,6 +25,7 @@ function snap(ids: string[]): TabsSnapshot { return { windows: [{ id: "w1", isPrimary: true, bounds: null, activeTabId: null }], tabs: ids.map((id, i) => tab(id, (i + 1) * 1000)), + panes: [], }; } diff --git a/packages/ui/src/features/browser-tabs/panes/BrowserPane.tsx b/packages/ui/src/features/browser-tabs/panes/BrowserPane.tsx new file mode 100644 index 0000000000..727ce73002 --- /dev/null +++ b/packages/ui/src/features/browser-tabs/panes/BrowserPane.tsx @@ -0,0 +1,107 @@ +import { useHostTRPC } from "@posthog/host-router/react"; +import { paneIdentityOf, setFocusedPane } from "@posthog/shared"; +import { hrefForIdentity } from "@posthog/ui/features/browser-tabs/tabHref"; +import { + createAppRouter, + persistedPaneHref, +} from "@posthog/ui/router/createAppRouter"; +import { removePaneLocation } from "@posthog/ui/router/paneLocationPersistence"; +import { + getPaneRouter, + removePaneRouter, + setPaneRouter, +} from "@posthog/ui/router/paneRouterRegistry"; +import { useMutation } from "@tanstack/react-query"; +import { RouterProvider } from "@tanstack/react-router"; +import { useEffect } from "react"; +import { applyLocalTransform, persistWrite, readMirror } from "../tabsSync"; + +/** + * One leaf pane of the active tab: focus boundary and its own router (looked + * up or lazily created in the registry — panes gained by a merge get a router + * on first mount, seeded from the pane's identity href). PaneChrome (the + * root route inside the router) reconciles this pane's location back into the + * snapshot via setPaneTarget. + */ +export function BrowserPane({ + paneId, + tabId, + showFocusRing, + isFocused, +}: { + paneId: string; + tabId: string; + showFocusRing: boolean; + isFocused: boolean; +}) { + const trpc = useHostTRPC(); + const setFocusedPaneMutation = useMutation( + trpc.browserTabs.setFocusedPane.mutationOptions(), + ); + + // Look up or create the pane's router. Creation is render-time on purpose: + // RouterProvider needs the instance immediately, and the registry acts as + // the cache so re-renders (tab switches, sibling mounts) reuse one instance + // — which is also what keeps an inactive tab's pane locations alive. + let router = getPaneRouter(paneId); + if (!router) { + const pane = readMirror().panes.find((p) => p.id === paneId); + const initialHref = + persistedPaneHref(paneId) ?? + (pane ? hrefForIdentity(paneIdentityOf(pane)) : "/code"); + router = createAppRouter({ paneId, initialHref }); + setPaneRouter(paneId, router); + void router.load().catch(() => undefined); + } + + // Drop the router (and its persisted location) once the pane is truly gone + // from the snapshot — NOT on transient unmounts (tab switches unmount every + // pane of the outgoing tab; their routers must survive). + useEffect(() => { + return () => { + const stillExists = readMirror().panes.some((p) => p.id === paneId); + if (!stillExists) { + removePaneRouter(paneId); + removePaneLocation(paneId); + } + }; + }, [paneId]); + + // Clicking anywhere in the pane focuses it within its tab. Capture-phase + // pointerdown so focus lands before any content interaction and survives + // stopPropagation inside the content; skipped when already focused so + // ordinary clicks in the focused pane don't spam writes. + const handlePointerDownCapture = () => { + const mirror = readMirror(); + const tab = mirror.tabs.find((t) => t.id === tabId); + if (!tab || tab.focusedPaneId === paneId) return; + applyLocalTransform((s) => setFocusedPane(s, tabId, paneId)); + void persistWrite(() => + setFocusedPaneMutation.mutateAsync({ tabId, paneId }), + ); + }; + + return ( +
+ {/* Merge drop zones mount INSIDE the router (PaneChrome), over the + content slot only. */} + + {/* Focus ring as a pointer-transparent OVERLAY above the content: an + inset ring on the pane element itself paints under its children, so + anything flush with the edge (scrolling content, section headers) + covers it. Driven by DOMAIN focus (data-focused ← focusedPaneId), + not :focus-within — clicks on non-focusable content move no DOM + focus, and in-pane navigation must keep its pane highlighted; every + path already maintains focusedPaneId (pane pointerdown capture, open + dedup, in-pane navigation). Below the drop zones (z-100). */} + {showFocusRing ? ( +
+ ) : null} +
+ ); +} diff --git a/packages/ui/src/features/browser-tabs/panes/PaneDropZones.tsx b/packages/ui/src/features/browser-tabs/panes/PaneDropZones.tsx new file mode 100644 index 0000000000..11a26a97c9 --- /dev/null +++ b/packages/ui/src/features/browser-tabs/panes/PaneDropZones.tsx @@ -0,0 +1,76 @@ +import { useDroppable } from "@dnd-kit/react"; +import type { SplitDropDirection } from "@posthog/shared"; +import type { CSSProperties } from "react"; +import { usePaneDragStore } from "./paneDragStore"; + +type PaneZone = SplitDropDirection | "center"; + +/** Hit-zone geometry: four 20% edge strips + the middle (PanelDropZones' + * proven layout). */ +const ZONE_SIZE = "20%"; +const HIT_STYLES: Record = { + top: { top: 0, left: 0, right: 0, height: ZONE_SIZE }, + bottom: { bottom: 0, left: 0, right: 0, height: ZONE_SIZE }, + left: { top: 0, left: 0, bottom: 0, width: ZONE_SIZE }, + right: { top: 0, right: 0, bottom: 0, width: ZONE_SIZE }, + center: { + top: ZONE_SIZE, + left: ZONE_SIZE, + right: ZONE_SIZE, + bottom: ZONE_SIZE, + }, +}; + +/** Preview geometry: the RESULTING pane (VS Code style) — half the pane on an + * edge drop, the whole pane on a center (merge-here) drop. */ +const PREVIEW_CLASSES: Record = { + left: "inset-y-0 left-0 w-1/2", + right: "inset-y-0 right-0 w-1/2", + top: "inset-x-0 top-0 h-1/2", + bottom: "inset-x-0 bottom-0 h-1/2", + center: "inset-0", +}; + +function Zone({ paneId, zone }: { paneId: string; zone: PaneZone }) { + const { ref, isDropTarget } = useDroppable({ + id: `browser-pane-zone-${paneId}-${zone}`, + data: { type: "browser-pane-zone", paneId, zone }, + }); + return ( + <> + {/* Invisible hit strip. */} +
+ {/* Translucent preview of the resulting pane, shown while hovered. */} + {isDropTarget ? ( +
+ ) : null} + + ); +} + +/** + * Merge drop zones overlaying one pane's content, mounted only while a + * browser-tab pill drag is live. Dropping tab B on a zone merges B INTO the + * active tab as a split next to this pane (edges pick the side; center merges + * to the right of this pane) — B's pill disappears and the active tab gains + * its panes. Dragging the ACTIVE tab's own pill mounts no zones (a tab can't + * merge into itself) — enforced by the caller (PaneChrome checks the drag + * against the active tab). + */ +export function PaneDropZones({ paneId }: { paneId: string }) { + const drag = usePaneDragStore((s) => s.drag); + if (!drag) return null; + return ( +
+ {(["center", "left", "right", "top", "bottom"] as const).map((zone) => ( + + ))} +
+ ); +} diff --git a/packages/ui/src/features/browser-tabs/panes/PaneTreeRenderer.tsx b/packages/ui/src/features/browser-tabs/panes/PaneTreeRenderer.tsx new file mode 100644 index 0000000000..7138b5e821 --- /dev/null +++ b/packages/ui/src/features/browser-tabs/panes/PaneTreeRenderer.tsx @@ -0,0 +1,146 @@ +import { useHostTRPC } from "@posthog/host-router/react"; +import { + collectLeafPaneIds, + type PaneLayoutNode, + primaryWindow, + setPaneSizes, +} from "@posthog/shared"; +import { useMutation } from "@tanstack/react-query"; +import { Fragment, useRef } from "react"; +import { Panel, PanelGroup, PanelResizeHandle } from "react-resizable-panels"; +import { applyLocalTransform, persistWrite } from "../tabsSync"; +import { useTabsSnapshot } from "../useBrowserTabs"; +import { BrowserPane } from "./BrowserPane"; +import { usePaneDragStore } from "./paneDragStore"; +import { RootDropZones } from "./RootDropZones"; + +const MIN_PANE_SIZE = 15; +const PERSIST_DEBOUNCE_MS = 300; + +/** + * Renders the ACTIVE TAB's pane layout: a lone leaf renders its pane directly + * (single-pane mode — no group wrapper, pixel-identical to a plain content + * area), a split renders a resizable PanelGroup recursively (the + * GroupNodeRenderer pattern from the task-detail panels feature). Switching + * tabs swaps the whole tree; inactive tabs' panes unmount but their routers + * stay cached in the pane router registry, so their locations survive. + * + * Sizes: panels stay UNCONTROLLED (defaultSize); onLayout writes each split's + * live sizes into a ref keyed by path, and only the resize-handle's drag-end + * commits — one applyLocalTransform + one debounced persistWrite. Per-frame + * writes into the domain mirror would churn every snapshot subscriber and + * hold the tabsSync in-flight gate open (dropping remote pushes) for the + * whole gesture. + */ +export function PaneTreeRenderer() { + const snapshot = useTabsSnapshot(); + const trpc = useHostTRPC(); + const setPaneSizesMutation = useMutation( + trpc.browserTabs.setPaneSizes.mutationOptions(), + ); + const liveSizes = useRef(new Map()); + const persistTimer = useRef | null>(null); + const drag = usePaneDragStore((s) => s.drag); + + const win = primaryWindow(snapshot); + const activeTab = win?.activeTabId + ? snapshot.tabs.find((t) => t.id === win.activeTabId) + : undefined; + if (!win || !activeTab) return null; + const tabId = activeTab.id; + const multiPane = activeTab.layout.type === "split"; + + const commitSizes = (path: number[]) => { + const sizes = liveSizes.current.get(path.join(".")); + if (!sizes) return; + const fractions = sizes.map((s) => s / 100); + applyLocalTransform((s) => setPaneSizes(s, tabId, path, fractions)); + if (persistTimer.current) clearTimeout(persistTimer.current); + // Trailing debounce also covers keyboard-driven handle resizes, which + // have no drag-end. + persistTimer.current = setTimeout(() => { + void persistWrite(() => + setPaneSizesMutation.mutateAsync({ tabId, path, sizes: fractions }), + ); + }, PERSIST_DEBOUNCE_MS); + }; + + const renderNode = (node: PaneLayoutNode, path: number[]) => { + if (node.type === "leaf") { + return ( + + ); + } + const pathKey = path.join("."); + // Key the group on its structural signature so a merge/close remounts it + // with fresh defaultSizes (panels are uncontrolled). + const signature = node.children + .map((c) => collectLeafPaneIds(c).join("+")) + .join("|"); + return ( + liveSizes.current.set(pathKey, sizes)} + className="p-1" + > + {node.children.map((child, i) => ( + + + {renderNode(child, [...path, i])} + + {i < node.children.length - 1 && ( + // Chrome-style divider: a visible gutter between panes with a + // small centered grab pill (the whole gutter is the hit area). + { + if (!isDragging) commitSizes(path); + }} + // The `!` overrides globals.css's 1px hairline for the + // task-detail panels ([data-panel-resize-handle-enabled]). + className={`group flex items-center justify-center bg-background ${ + node.direction === "row" ? "w-2!" : "h-2!" + }`} + > +
+ + )} + + ))} + + ); + }; + + // Root edge zones merge the dragged tab at this tab's layout root; a tab + // can't merge into itself (the DnD provider also never arms the drag store + // for the active tab's own pill — this is belt-and-braces for a tab switch + // that races the drag). + const rootZonesEnabled = !!drag && drag.tabId !== tabId; + + return ( +
+ {renderNode(activeTab.layout, [])} + +
+ ); +} diff --git a/packages/ui/src/features/browser-tabs/panes/RootDropZones.tsx b/packages/ui/src/features/browser-tabs/panes/RootDropZones.tsx new file mode 100644 index 0000000000..59e40940f9 --- /dev/null +++ b/packages/ui/src/features/browser-tabs/panes/RootDropZones.tsx @@ -0,0 +1,63 @@ +import { CollisionPriority } from "@dnd-kit/abstract"; +import { useDroppable } from "@dnd-kit/react"; +import type { SplitDropDirection } from "@posthog/shared"; +import type { CSSProperties } from "react"; +import { usePaneDragStore } from "./paneDragStore"; + +/** Thin edge strips over the WHOLE content area for merging at the active + * tab's layout root. High collision priority so they beat the outermost + * panes' 20% edge zones where the two overlap. */ +const EDGE = "18px"; +const HIT_STYLES: Record = { + top: { top: 0, left: 0, right: 0, height: EDGE }, + bottom: { bottom: 0, left: 0, right: 0, height: EDGE }, + left: { top: 0, left: 0, bottom: 0, width: EDGE }, + right: { top: 0, right: 0, bottom: 0, width: EDGE }, +}; + +const PREVIEW_CLASSES: Record = { + left: "inset-y-0 left-0 w-1/2", + right: "inset-y-0 right-0 w-1/2", + top: "inset-x-0 top-0 h-1/2", + bottom: "inset-x-0 bottom-0 h-1/2", +}; + +function RootZone({ zone }: { zone: SplitDropDirection }) { + const { ref, isDropTarget } = useDroppable({ + id: `browser-root-zone-${zone}`, + data: { type: "browser-root-zone", zone }, + collisionPriority: CollisionPriority.High, + }); + return ( + <> +
+ {isDropTarget ? ( +
+ ) : null} + + ); +} + +/** + * Content-area edge drops that merge the dragged tab at the active tab's + * layout ROOT (the whole content area splits; nav and title bar stay put). + * Mounted only while a pill drag is live, and suppressed when the dragged + * pill is the active tab's own (a tab can't merge into itself). + */ +export function RootDropZones({ enabled }: { enabled: boolean }) { + const drag = usePaneDragStore((s) => s.drag); + if (!drag || !enabled) return null; + return ( +
+ {(["left", "right", "top", "bottom"] as const).map((zone) => ( + + ))} +
+ ); +} diff --git a/packages/ui/src/features/browser-tabs/panes/paneDragStore.ts b/packages/ui/src/features/browser-tabs/panes/paneDragStore.ts new file mode 100644 index 0000000000..d736b10248 --- /dev/null +++ b/packages/ui/src/features/browser-tabs/panes/paneDragStore.ts @@ -0,0 +1,18 @@ +import { create } from "zustand"; + +/** + * Transient view state for a live browser-tab pill drag (pattern: + * tabReorderStore). While set, the active tab's panes mount their merge drop + * zones and the root edge zones mount over the content area. Never in the + * snapshot mirror — a server push mid-drag must not clobber it, and the shell + * must not re-render per dragover. + */ +interface PaneDragState { + drag: { tabId: string } | null; + setDrag: (drag: { tabId: string } | null) => void; +} + +export const usePaneDragStore = create((set) => ({ + drag: null, + setDrag: (drag) => set({ drag }), +})); diff --git a/packages/ui/src/features/browser-tabs/tabHref.ts b/packages/ui/src/features/browser-tabs/tabHref.ts new file mode 100644 index 0000000000..e512c8f5cf --- /dev/null +++ b/packages/ui/src/features/browser-tabs/tabHref.ts @@ -0,0 +1,57 @@ +import type { PaneIdentity } from "@posthog/shared"; +import { channelSectionFor } from "@posthog/ui/features/canvas/channelSections"; + +/** + * A pane identity's canonical href — used to seed a pane router's initial + * memory-history entry (boot restore, a merged tab's panes mounting, the + * blank tab backfilled by closing the last tab), so restore and + * click-navigation land on the same routes. + */ +export function hrefForIdentity(identity: PaneIdentity): string { + if (identity.taskId && identity.channelId) { + return `/website/${identity.channelId}/tasks/${identity.taskId}`; + } + if (identity.taskId) { + return `/code/tasks/${identity.taskId}`; + } + if (identity.dashboardId && identity.channelId) { + return `/website/${identity.channelId}/dashboards/${identity.dashboardId}`; + } + if (identity.channelId) { + // Section keys are the route segments; unknown/stale sections (e.g. from + // a since-removed tab type) fall back to the channel home. + const section = channelSectionFor(identity.channelSection); + return section + ? `/website/${identity.channelId}/${section.key}` + : `/website/${identity.channelId}`; + } + switch (identity.appView) { + case "home": + return "/code/home"; + case "inbox": + return "/code/inbox"; + case "agents": + return "/code/agents"; + case "skills": + return "/skills"; + case "mcp-servers": + return "/mcp-servers"; + case "command-center": + return "/command-center"; + default: + // Blank pane (or an unknown appView from a newer session): the Code + // new-task screen. Boot never lands a blank pane on /website — its index + // would redirect to channels[0] and hijack the blank. + return "/code"; + } +} + +/** + * Where a freshly minted blank pane's router starts: the channels new-tab + * page (/website renders BlankTabView for a blank pane) when channels are on, + * else the Code new-task screen. Used by the strip's new-tab handler, which + * pre-seeds the pane router so the new tab paints its landing immediately. + */ +export function defaultBlankPaneHref(channelsEnabled: boolean): string { + return channelsEnabled ? "/website" : "/code"; +} diff --git a/packages/ui/src/features/browser-tabs/tabsSync.test.ts b/packages/ui/src/features/browser-tabs/tabsSync.test.ts index a5a90a96f7..7a0323270e 100644 --- a/packages/ui/src/features/browser-tabs/tabsSync.test.ts +++ b/packages/ui/src/features/browser-tabs/tabsSync.test.ts @@ -21,12 +21,22 @@ function snap(tabIds: string[]): TabsSnapshot { tabs: tabIds.map((id, i) => ({ id, windowId: "w1", + layout: { type: "leaf" as const, paneId: `${id}-pane` }, + focusedPaneId: `${id}-pane`, + position: (i + 1) * 1000, + createdAt: i, + lastActiveAt: i, + })), + panes: tabIds.map((id, i) => ({ + id: `${id}-pane`, + tabId: id, + windowId: "w1", dashboardId: null, taskId: null, channelId: `c-${id}`, channelSection: null, appView: null, - position: (i + 1) * 1000, + scrollState: null, createdAt: i, lastActiveAt: i, })), diff --git a/packages/ui/src/router/RouterDevtools.tsx b/packages/ui/src/router/RouterDevtools.tsx index 8a3b697a76..737abec816 100644 --- a/packages/ui/src/router/RouterDevtools.tsx +++ b/packages/ui/src/router/RouterDevtools.tsx @@ -1,5 +1,8 @@ -import { lazy, Suspense } from "react"; -import { router } from "./router"; +import { lazy, Suspense, useSyncExternalStore } from "react"; +import { + getFocusedRouterOrNull, + subscribePaneRouters, +} from "./paneRouterRegistry"; // The genuine floating TanStack Router devtools overlay (drawer, drag-to-resize, // in-panel close button, open animation — the exact UI), but with its floating @@ -52,12 +55,21 @@ const LazyRouterDevtools = import.meta.env.DEV "@tanstack/react-router-devtools" ); return { - default: () => ( - - ), + default: () => { + // Attach to the FOCUSED pane's router (there is one per pane now), + // re-resolving when routers register/unregister. + const router = useSyncExternalStore( + subscribePaneRouters, + getFocusedRouterOrNull, + ); + if (!router) return null; + return ( + + ); + }, }; }) : () => null; diff --git a/packages/ui/src/router/createAppRouter.ts b/packages/ui/src/router/createAppRouter.ts new file mode 100644 index 0000000000..97cd89fb17 --- /dev/null +++ b/packages/ui/src/router/createAppRouter.ts @@ -0,0 +1,127 @@ +import { + createMemoryHistory, + createRouter as createTanStackRouter, +} from "@tanstack/react-router"; +import { readPaneLocation, writePaneLocation } from "./paneLocationPersistence"; +import { RouteNotFound } from "./RouteNotFound"; +import { RoutePending } from "./RoutePending"; +import { routeTree } from "./routeTree.gen"; + +/** + * Per-pane router factory. Every pane hosts its own router instance over the + * shared generated route tree, each with an in-memory history — per-pane + * back/forward falls out by construction, and no pane owns `window.location` + * (the app is Electron-hosted; nothing reads the URL bar). The previous + * window-wide hash router is gone. + * + * Location durability: memory history dies with the page, so every navigation + * writes the pane's current href to sessionStorage (see + * paneLocationPersistence) and boot restores it — this is what keeps Cmd+R and + * HMR full reloads on the same screen now that the hash no longer carries it. + */ +export function createAppRouter(opts: { paneId: string; initialHref: string }) { + const history = createMemoryHistory({ initialEntries: [opts.initialHref] }); + const router = createTanStackRouter({ + routeTree, + history, + // Which pane this router belongs to — read by PaneChrome via route + // context (__root is createRootRouteWithContext<{paneId}>). + context: { paneId: opts.paneId }, + defaultPreload: "intent", + // Preloads only warm code imports — never satisfy a navigation's loader. + // Loaders here are single-frame yields (see yieldToPaint) whose whole point + // is to run ON navigation so the pending skeleton paints; a hover-preloaded + // loader result would let the navigation commit synchronously and freeze the + // old screen through the destination's heavy mount again. + defaultPreloadStaleTime: 0, + // Show the route's pending UI the instant its loader is still resolving, so + // navigation commits immediately instead of stalling on the previous screen. + defaultPendingMs: 0, + // Don't hold the pending UI for the default 500ms minimum — skeletons paint + // for exactly the frame(s) a `yieldToPaint()` loader needs, then the real + // view replaces them as soon as it has rendered. + defaultPendingMinMs: 0, + defaultPendingComponent: RoutePending, + defaultNotFoundComponent: RouteNotFound, + scrollRestoration: false, + }); + + // Forward-availability + last-action tracker. It must live WITH the router + // (not in component state): the title bar swaps routers when pane focus + // moves and would otherwise lose the counter, and PaneChrome's reconcile + // effect reads the action that produced the current location. Installed + // before the persistence subscriber so listeners always observe an + // up-to-date max index. + let maxIndex = currentIndex(router); + let lastAction: HistoryActionType = null; + const listeners = new Set<() => void>(); + history.subscribe(({ location, action }) => { + const idx = location.state.__TSR_index; + // Only a PUSH wipes the forward stack, so it resets the newest to the + // current index. REPLACE mutates the current entry in place (index + // unchanged, forward entries intact) and BACK/GO just move within the + // existing stack, so both keep the max. + maxIndex = action.type === "PUSH" ? idx : Math.max(maxIndex, idx); + lastAction = action.type; + for (const listener of listeners) listener(); + writePaneLocation(opts.paneId, location.href); + }); + trackers.set(router, { + subscribe: (listener) => { + listeners.add(listener); + return () => listeners.delete(listener); + }, + canGoForward: () => currentIndex(router) < maxIndex, + lastAction: () => lastAction, + }); + + return router; +} + +export type AppRouter = ReturnType; + +function currentIndex(router: AppRouter): number { + return router.history.location.state.__TSR_index; +} + +/** The history action that produced the current location; null before the + * first navigation (the initial entry was not navigated to). */ +export type HistoryActionType = + | "PUSH" + | "REPLACE" + | "BACK" + | "FORWARD" + | "GO" + | null; + +export type PaneHistoryTracker = { + /** Fires on every history change of the pane. */ + subscribe: (listener: () => void) => () => void; + canGoForward: () => boolean; + lastAction: () => HistoryActionType; +}; + +const trackers = new WeakMap(); + +const NULL_TRACKER: PaneHistoryTracker = { + subscribe: () => () => {}, + canGoForward: () => false, + lastAction: () => null, +}; + +/** The forward tracker installed by {@link createAppRouter}. Routers created + * elsewhere (tests, Storybook) get an inert tracker. */ +export function getPaneHistoryTracker(router: AppRouter): PaneHistoryTracker { + return trackers.get(router) ?? NULL_TRACKER; +} + +/** Boot helper: the pane's persisted location, if this page session has one. */ +export function persistedPaneHref(paneId: string): string | null { + return readPaneLocation(paneId); +} + +declare module "@tanstack/react-router" { + interface Register { + router: AppRouter; + } +} diff --git a/packages/ui/src/router/paneLocationPersistence.ts b/packages/ui/src/router/paneLocationPersistence.ts new file mode 100644 index 0000000000..d54144c5bf --- /dev/null +++ b/packages/ui/src/router/paneLocationPersistence.ts @@ -0,0 +1,50 @@ +/** + * Per-pane location persistence. Pane routers use in-memory history, so a full + * reload (Cmd+R, HMR, renderer crash-restore) would otherwise lose every + * pane's location. Each navigation writes `paneId → href` here; boot prefers + * the persisted href over the pane's identity-derived one. + * + * sessionStorage on purpose: it survives reloads of this window but not an + * app relaunch — across launches the durable tabs snapshot (each pane's + * identity) is the source of truth, exactly like the old hash history + * (production loads carried no hash). + */ +const KEY = "posthog.paneLocations"; + +function read(): Record { + try { + const raw = sessionStorage.getItem(KEY); + if (!raw) return {}; + const parsed: unknown = JSON.parse(raw); + if (typeof parsed !== "object" || parsed === null) return {}; + return parsed as Record; + } catch { + return {}; + } +} + +function write(map: Record): void { + try { + sessionStorage.setItem(KEY, JSON.stringify(map)); + } catch { + // Quota/unavailable — location restore degrades to the identity href. + } +} + +export function readPaneLocation(paneId: string): string | null { + return read()[paneId] ?? null; +} + +export function writePaneLocation(paneId: string, href: string): void { + const map = read(); + if (map[paneId] === href) return; + map[paneId] = href; + write(map); +} + +export function removePaneLocation(paneId: string): void { + const map = read(); + if (!(paneId in map)) return; + delete map[paneId]; + write(map); +} diff --git a/packages/ui/src/router/paneRouterRegistry.ts b/packages/ui/src/router/paneRouterRegistry.ts new file mode 100644 index 0000000000..1ae1263f52 --- /dev/null +++ b/packages/ui/src/router/paneRouterRegistry.ts @@ -0,0 +1,87 @@ +// Leaf module holding the live pane→router map so imperative callers +// (navigationBridge, deep-link handlers, store actions) can reach a router +// WITHOUT a static `import { router } from "./router"`. +// +// That static import creates a cycle: +// router.ts → routeTree.gen.ts → __root.tsx → hooks → navigationBridge → router.ts +// Under `autoCodeSplitting` each route's component becomes its own module that +// re-enters the cycle, and the TDZ ("Cannot access 'rootRouteImport' before +// initialization") leaves code-split route chunks stuck loading. +// +// The router `import type` below is erased at build time; the core-store import +// is runtime but core never imports the route tree, so the cycle stays open. +// +// Every pane hosts its own router instance (an active tab's panes each render +// their own route). "The" router for imperative navigation is the FOCUSED +// pane's — the active tab's focused pane — resolved here from the tabs mirror. +import { browserTabsStore } from "@posthog/core/browser-tabs/browserTabsStore"; +import { primaryWindow } from "@posthog/shared"; +import type { AppRouter } from "./createAppRouter"; + +export type { AppRouter } from "./createAppRouter"; + +const paneRouters = new Map(); +const listeners = new Set<() => void>(); +let version = 0; + +function notify(): void { + version++; + for (const listener of listeners) listener(); +} + +/** Monotonic registration counter — a useSyncExternalStore snapshot for + * consumers that re-resolve the focused router when the map changes. */ +export function getPaneRoutersVersion(): number { + return version; +} + +export function setPaneRouter(paneId: string, router: AppRouter): void { + paneRouters.set(paneId, router); + notify(); +} + +export function removePaneRouter(paneId: string): void { + paneRouters.delete(paneId); + notify(); +} + +export function getPaneRouter(paneId: string): AppRouter | null { + return paneRouters.get(paneId) ?? null; +} + +/** The focused pane id: the active tab's `focusedPaneId`, from the mirror. */ +export function focusedPaneId(): string | null { + const snapshot = browserTabsStore.getState().snapshot; + const win = primaryWindow(snapshot); + if (!win?.activeTabId) return null; + const tab = snapshot.tabs.find((t) => t.id === win.activeTabId); + return tab?.focusedPaneId ?? null; +} + +/** + * The focused pane's router: the active tab's focused pane when a router is + * registered under that id, else — while the app runs a single router not yet + * keyed by a real pane id (boot, unit tests) — the sole registered instance. + * Null before any router exists; callers treat null as "no router, nothing to + * navigate". + */ +export function getFocusedRouterOrNull(): AppRouter | null { + const paneId = focusedPaneId(); + if (paneId) { + const exact = paneRouters.get(paneId); + if (exact) return exact; + } + if (paneRouters.size === 1) { + return paneRouters.values().next().value ?? null; + } + return null; +} + +/** Fires when a pane router is registered or removed. (Focus changes are + * observed via the tabs mirror; combine both for a reactive focused router.) */ +export function subscribePaneRouters(listener: () => void): () => void { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; +} diff --git a/packages/ui/src/router/router.ts b/packages/ui/src/router/router.ts deleted file mode 100644 index ad98875925..0000000000 --- a/packages/ui/src/router/router.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { - createHashHistory, - createRouter as createTanStackRouter, -} from "@tanstack/react-router"; -import { RouteNotFound } from "./RouteNotFound"; -import { RoutePending } from "./RoutePending"; -import { setRouter } from "./routerRef"; -import { routeTree } from "./routeTree.gen"; - -export const router = createTanStackRouter({ - routeTree, - history: createHashHistory(), - defaultPreload: "intent", - // Preloads only warm code imports — never satisfy a navigation's loader. - // Loaders here are single-frame yields (see yieldToPaint) whose whole point - // is to run ON navigation so the pending skeleton paints; a hover-preloaded - // loader result would let the navigation commit synchronously and freeze the - // old screen through the destination's heavy mount again. - defaultPreloadStaleTime: 0, - // Show the route's pending UI the instant its loader is still resolving, so - // navigation commits immediately instead of stalling on the previous screen. - defaultPendingMs: 0, - // Don't hold the pending UI for the default 500ms minimum — skeletons paint - // for exactly the frame(s) a `yieldToPaint()` loader needs, then the real - // view replaces them as soon as it has rendered. - defaultPendingMinMs: 0, - defaultPendingComponent: RoutePending, - defaultNotFoundComponent: RouteNotFound, - scrollRestoration: false, -}); - -// Publish the instance to the leaf ref so imperative callers reach it without a -// static import of this module (which would re-create the route-tree cycle). -setRouter(router); - -declare module "@tanstack/react-router" { - interface Register { - router: typeof router; - } -} diff --git a/packages/ui/src/router/routerRef.ts b/packages/ui/src/router/routerRef.ts index b0796aa1a7..43bdd23e3a 100644 --- a/packages/ui/src/router/routerRef.ts +++ b/packages/ui/src/router/routerRef.ts @@ -1,34 +1,24 @@ -// Leaf module holding the live router singleton so imperative callers -// (navigationBridge, deep-link handlers, store actions) can reach the router -// WITHOUT a static `import { router } from "./router"`. -// -// That static import creates a cycle: -// router.ts → routeTree.gen.ts → __root.tsx → hooks → navigationBridge → router.ts -// Under `autoCodeSplitting` each route's component becomes its own module that -// re-enters the cycle, and the TDZ ("Cannot access 'rootRouteImport' before -// initialization") leaves code-split route chunks stuck loading. -// -// The `import type` below is erased at build time, so this module has no runtime -// imports and cannot participate in the cycle. -import type { router as RouterInstance } from "./router"; +// Leaf accessor for imperative navigation helpers (navigationBridge, +// deep-link handlers, store actions). Historically this held the single app +// router; with tab-owned split panes every pane hosts its own router, so "the" +// router is the FOCUSED pane's, resolved through the pane router registry. +// Kept as a separate module so callers stay out of the route-tree import +// cycle (see paneRouterRegistry.ts for the cycle description). +import type { AppRouter } from "./createAppRouter"; +import { getFocusedRouterOrNull } from "./paneRouterRegistry"; -let routerInstance: typeof RouterInstance | null = null; - -export function setRouter(instance: typeof RouterInstance): void { - routerInstance = instance; -} - -export function getRouter(): typeof RouterInstance { - if (!routerInstance) { +export function getRouter(): AppRouter { + const router = getFocusedRouterOrNull(); + if (!router) { throw new Error("Router accessed before initialization"); } - return routerInstance; + return router; } // Nullable accessor for imperative navigation helpers that must not throw when -// the router isn't mounted yet (early boot, unit tests). In the running app the -// instance is always set before these fire; callers treat null as "no router, -// nothing to navigate". -export function getRouterOrNull(): typeof RouterInstance | null { - return routerInstance; +// no pane router is mounted yet (early boot, unit tests). In the running app a +// focused pane's router always exists before these fire; callers treat null as +// "no router, nothing to navigate". +export function getRouterOrNull(): AppRouter | null { + return getFocusedRouterOrNull(); } diff --git a/packages/ui/src/router/routes/__root.tsx b/packages/ui/src/router/routes/__root.tsx index f94695bec0..e13e74b40e 100644 --- a/packages/ui/src/router/routes/__root.tsx +++ b/packages/ui/src/router/routes/__root.tsx @@ -1,532 +1,305 @@ +import { XIcon } from "@phosphor-icons/react"; +import { useHostTRPC } from "@posthog/host-router/react"; import { - ArrowSquareOut, - CaretLeftIcon, - CaretRightIcon, -} from "@phosphor-icons/react"; -import { useHostTRPC, useHostTRPCClient } from "@posthog/host-router/react"; -import { Button, ButtonGroup } from "@posthog/quill"; + Button, + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@posthog/quill"; import { - BILLING_FLAG, - HOME_TAB_FLAG, - PROJECT_BLUEBIRD_FLAG, - SYNC_CLOUD_TASKS_FLAG, + closePane as closePaneTransform, + decidePaneNavigation, + type PaneIdentity, + paneIdentityOf, + setFocusedPane, + setPaneTarget, + setWindowActiveTab, } from "@posthog/shared"; -import { ANALYTICS_EVENTS } from "@posthog/shared/analytics-events"; -import { isContentlessTask } from "@posthog/shared/domain-types"; -import { DeepLinkApprovalModal } from "@posthog/ui/features/agent-applications/components/DeepLinkApprovalModal"; -import { useApprovalDeepLink } from "@posthog/ui/features/agent-applications/hooks/useApprovalDeepLink"; -import { useAuthStateValue } from "@posthog/ui/features/auth/store"; -import { UsageButton } from "@posthog/ui/features/billing/UsageButton"; -import { UsageLimitModal } from "@posthog/ui/features/billing/UsageLimitModal"; +import { isAppView } from "@posthog/ui/features/browser-tabs/appViews"; import { BlankTabView } from "@posthog/ui/features/browser-tabs/BlankTabView"; -import { BrowserTabStrip } from "@posthog/ui/features/browser-tabs/BrowserTabStrip"; -import { BrowserTabsDndProvider } from "@posthog/ui/features/browser-tabs/BrowserTabsDnd"; -import { useActiveTabIsBlank } from "@posthog/ui/features/browser-tabs/useBrowserTabs"; -import { ChannelsSidebar } from "@posthog/ui/features/canvas/components/ChannelsSidebar"; -import { useChannelsSidebarStore } from "@posthog/ui/features/canvas/components/channelsSidebarStore"; +import { PaneDropZones } from "@posthog/ui/features/browser-tabs/panes/PaneDropZones"; +import { usePaneDragStore } from "@posthog/ui/features/browser-tabs/panes/paneDragStore"; +import { hrefForIdentity } from "@posthog/ui/features/browser-tabs/tabHref"; import { - FeedbackModal, - type FeedbackModalMode, -} from "@posthog/ui/features/canvas/components/FeedbackModal"; -import { useCanvasDeepLink } from "@posthog/ui/features/canvas/hooks/useCanvasDeepLink"; -import { useChannelDeepLink } from "@posthog/ui/features/canvas/hooks/useChannelDeepLink"; -import { CommandMenu } from "@posthog/ui/features/command/CommandMenu"; -import { GlobalFilePicker } from "@posthog/ui/features/command/GlobalFilePicker"; -import { KeyboardShortcutsSheet } from "@posthog/ui/features/command/KeyboardShortcutsSheet"; -import { ConnectivityBanner } from "@posthog/ui/features/connectivity/ConnectivityBanner"; -import { useNewTaskDeepLink } from "@posthog/ui/features/deep-links/useNewTaskDeepLink"; -import { useOpenTargetDeepLink } from "@posthog/ui/features/deep-links/useOpenTargetDeepLink"; -import { useTaskDeepLink } from "@posthog/ui/features/deep-links/useTaskDeepLink"; -import { useFeatureFlag } from "@posthog/ui/features/feature-flags/useFeatureFlag"; -import { useInboxDeepLink } from "@posthog/ui/features/inbox/hooks/useInboxDeepLink"; -import { useIntegrations } from "@posthog/ui/features/integrations/useIntegrations"; -import { useScoutDeepLink } from "@posthog/ui/features/scouts/hooks/useScoutDeepLink"; -import { useSetupDiscovery } from "@posthog/ui/features/setup/useSetupDiscovery"; -import { - beginSidebarPeek, - cancelSidebarPeek, - endSidebarPeek, - useSidebarPeekStore, -} from "@posthog/ui/features/sidebar/sidebarPeekStore"; -import { useSidebarStore } from "@posthog/ui/features/sidebar/sidebarStore"; -import { useSidebarData } from "@posthog/ui/features/sidebar/useSidebarData"; -import { useVisualTaskOrder } from "@posthog/ui/features/sidebar/useVisualTaskOrder"; -import { ExistingWorktreeDialog } from "@posthog/ui/features/task-detail/components/ExistingWorktreeDialog"; -import { RemoteBranchCheckoutDialog } from "@posthog/ui/features/task-detail/components/RemoteBranchCheckoutDialog"; -import { useTasks } from "@posthog/ui/features/tasks/useTasks"; -import { TourOverlay } from "@posthog/ui/features/tour/components/TourOverlay"; -import { UpdateAvailableModal } from "@posthog/ui/features/updates/UpdateAvailableModal"; -import { WhatsNewModal } from "@posthog/ui/features/updates/WhatsNewModal"; -import { useWorkspaces } from "@posthog/ui/features/workspace/useWorkspace"; -import LogosLandscape from "@posthog/ui/primitives/Logo"; + applyLocalTransform, + persistWrite, + readMirror, +} from "@posthog/ui/features/browser-tabs/tabsSync"; +import { useTabsSnapshot } from "@posthog/ui/features/browser-tabs/useBrowserTabs"; +import { channelSectionFor } from "@posthog/ui/features/canvas/channelSections"; +import { getPaneHistoryTracker } from "@posthog/ui/router/createAppRouter"; import { useAppView } from "@posthog/ui/router/useAppView"; -import { openTask, openTaskInput } from "@posthog/ui/router/useOpenTask"; -import { track } from "@posthog/ui/shell/analytics"; import { ContentHeader } from "@posthog/ui/shell/ContentHeader"; -import { useCommandMenuStore } from "@posthog/ui/shell/commandMenuStore"; -import { GlobalEventHandlers } from "@posthog/ui/shell/GlobalEventHandlers"; -import { HedgehogMode } from "@posthog/ui/shell/HedgehogMode"; -import { logger } from "@posthog/ui/shell/logger"; -import { onFeatureFlagsLoaded } from "@posthog/ui/shell/posthogAnalyticsImpl"; -import { SpaceSwitcher } from "@posthog/ui/shell/SpaceSwitcher"; -import { useShortcutsSheetStore } from "@posthog/ui/shell/shortcutsSheetStore"; -import { openUrlInBrowser } from "@posthog/ui/utils/browser"; -import { isWindows } from "@posthog/ui/utils/platform"; -import { getPostHogUrl } from "@posthog/ui/utils/urls"; import { Box, Flex } from "@radix-ui/themes"; -import { useQueryClient } from "@tanstack/react-query"; +import { useMutation } from "@tanstack/react-query"; import { - createRootRoute, + createRootRouteWithContext, Outlet, - useCanGoBack, + useParams, useRouter, useRouterState, } from "@tanstack/react-router"; -import { SidebarClose, SidebarOpen } from "lucide-react"; -import { useEffect, useRef, useState } from "react"; +import { useEffect, useMemo } from "react"; +import { createPortal } from "react-dom"; // The router devtools render their genuine floating overlay, mounted by the // app's dev toolbar with the floating logo hidden so the toolbar owns the // trigger — see RouterDevtools. -const log = logger.scope("root-route"); - -// On Windows the frameless window overlays the min/max/close controls on the -// top-right of the title bar (see window.ts titleBarOverlay). Reserve that strip -// so the tab strip / PostHog Web button never render under the native controls. -const WINDOWS_TITLEBAR_INSET = 140; +/** Per-pane router context, supplied by createAppRouter. */ +interface PaneRouterContext { + paneId: string; +} -export const Route = createRootRoute({ - component: RootLayout, +export const Route = createRootRouteWithContext()({ + component: PaneChrome, }); -function RootLayout() { - const view = useAppView(); +/** + * The root of ONE pane's route tree: the shared content header and the routed + * outlet, plus the effect that reconciles this pane's location back into the + * tab snapshot. Window-level chrome (title bar with the tab strip, sidebar, + * global modals, deep links) lives OUTSIDE the router in AppShell — each pane + * of the active tab hosts its own router, and anything here mounts once per + * pane. + */ +function PaneChrome() { + const { paneId } = Route.useRouteContext(); const router = useRouter(); - const canGoBack = useCanGoBack(); - // Width of the Channels sidebar below — used to right-align the back/forward - // buttons in the title bar with the sidebar's (and project switcher's) right edge. - const channelsSidebarWidth = useChannelsSidebarStore((state) => state.width); - // Suppress the title-bar width transition during a live drag so it tracks - // the sidebar frame-for-frame; when the sidebar toggles open/closed, both - // animate with the same curve (see ResizableSidebar). - const sidebarIsResizing = useChannelsSidebarStore( - (state) => state.isResizing, - ); - // Forward availability isn't exposed by the router (and history.length counts - // pre-app entries, so it can't be compared to __TSR_index). Track the newest - // index we've reached: only a PUSH wipes the forward stack, so it resets the - // newest to the current index. REPLACE mutates the current entry in place - // (index unchanged, forward entries intact) and BACK/GO just move within the - // existing stack, so both keep the max. Forward is live while below it. - const historyIndex = useRouterState({ - select: (s) => s.location.state.__TSR_index, - }); - const [newestIndex, setNewestIndex] = useState(historyIndex); - useEffect(() => { - return router.history.subscribe(({ location, action }) => { - const idx = location.state.__TSR_index; - setNewestIndex((prev) => - action.type === "PUSH" ? idx : Math.max(prev, idx), - ); - }); - }, [router]); - const canGoForward = historyIndex < newestIndex; - - // Feedback modal shown as an intercept before "PostHog Web" opens the web - // app, routing once the modal is submitted or skipped. - const [feedbackMode, setFeedbackMode] = useState( - null, - ); - const currentProjectId = useAuthStateValue((s) => s.currentProjectId); - - // The user's current project on the correct cloud (region comes from - // cloudRegion via getPostHogUrl), falling back to the account root. `null` - // when the region is unknown — the "PostHog Web" button is disabled then, so - // a click can never silently no-op. - const posthogWebUrl = getPostHogUrl( - currentProjectId ? `/project/${currentProjectId}` : "/", - ); - - // "PostHog Web" opens the feedback modal first and performs its navigation - // only once the modal is submitted or skipped. - const handleFeedbackFinished = () => { - const finishedMode = feedbackMode; - setFeedbackMode(null); - if (finishedMode === "posthog-web" && posthogWebUrl) { - void openUrlInBrowser(posthogWebUrl); - } - }; - - const handleOpenPostHogWeb = () => { - track(ANALYTICS_EVENTS.POSTHOG_WEB_OPENED); - setFeedbackMode("posthog-web"); - }; - const { - isOpen: commandMenuOpen, - setOpen: setCommandMenuOpen, - toggle: toggleCommandMenu, - } = useCommandMenuStore(); - const { - isOpen: shortcutsSheetOpen, - close: closeShortcutsSheet, - toggle: toggleShortcutsSheet, - } = useShortcutsSheetStore(); - const { data: tasks } = useTasks(); - const { data: workspaces, isFetched: workspacesFetched } = useWorkspaces(); const trpc = useHostTRPC(); - const hostClient = useHostTRPCClient(); - const queryClient = useQueryClient(); - const reconcilingTaskIds = useRef>(new Set()); - const billingEnabled = useFeatureFlag(BILLING_FLAG); - const syncCloudTasksEnabled = useFeatureFlag(SYNC_CLOUD_TASKS_FLAG); - const homeTabEnabled = useFeatureFlag(HOME_TAB_FLAG); - // "PostHog Web" is a channels-world affordance — show it only while the user - // is actually seeing channels (toggle on, which itself requires the flag). - const bluebirdEnabled = useFeatureFlag( - PROJECT_BLUEBIRD_FLAG, - import.meta.env.DEV, - ); - const channelsToggleOn = useSidebarStore((s) => s.channelsEnabled); - const channelsEnabled = channelsToggleOn && bluebirdEnabled; - // When the sidebar is collapsed (Cmd+B) the title bar's left block shrinks to - // fit its own controls so the tab strip flushes left with the content pane. - const sidebarOpen = useSidebarStore((s) => s.open); - const toggleSidebar = useSidebarStore((s) => s.toggle); - const sidebarPeek = useSidebarPeekStore((s) => s.peek); - // Toggling makes any hover-peek redundant (opening replaces the overlay; - // closing must not leave it lingering under the pointer). - const handleToggleSidebar = (): void => { - cancelSidebarPeek(); - toggleSidebar(); + const snapshot = useTabsSnapshot(); + const params = useParams({ strict: false }) as { + channelId?: string; + dashboardId?: string; + taskId?: string; }; + const pathname = useRouterState({ select: (s) => s.location.pathname }); + const view = useAppView(); + const routeAppView = isAppView(view.type) ? view.type : null; - const sidebarData = useSidebarData({ activeView: view }); - const visualTaskOrder = useVisualTaskOrder(sidebarData); - const activeTaskId = - view.type === "task-detail" && view.taskId ? view.taskId : null; - - useIntegrations(); - useTaskDeepLink(); - useOpenTargetDeepLink(); - useInboxDeepLink(); - useScoutDeepLink(); - useCanvasDeepLink(); - useChannelDeepLink(); - const approvalDeepLink = useApprovalDeepLink(); - useSetupDiscovery(); - useNewTaskDeepLink(); + // The active channel sub-section (artifacts/history/context) is the route + // segment after the channelId. Null when on the channel home or a + // non-section route (canvas/task), so a channel-home pane keys by name. + const routeChannelSection = useMemo(() => { + if (!params.channelId) return null; + const seg = pathname.split("/")[3] ?? null; + return channelSectionFor(seg)?.key ?? null; + }, [pathname, params.channelId]); - // hydrateTask is no longer needed — the URL is the source of truth and the - // task cache populates the route automatically. + const setPaneTargetMutation = useMutation( + trpc.browserTabs.setPaneTarget.mutationOptions(), + ); + const setActiveTabMutation = useMutation( + trpc.browserTabs.setActiveTab.mutationOptions(), + ); + const setFocusedPaneMutation = useMutation( + trpc.browserTabs.setFocusedPane.mutationOptions(), + ); + const closePaneMutation = useMutation( + trpc.browserTabs.closePane.mutationOptions(), + ); + // Reconcile this pane's location into the snapshot on every location + // change: an ordinary navigation points the pane at the route's identity + // (setPaneTarget — the pane's location IS its content pointer), and a PUSH + // to a page already open in another pane focuses that pane's tab instead of + // duplicating it (decidePaneNavigation). + // + // Keyed on the LOCATION only — the route is the command stream; the mirror + // is state this effect reconciles against, read fresh via readMirror() + // rather than subscribed to. Running on mirror changes is actively wrong + // under local-first sync (see tabsSync.ts). useEffect(() => { - if (!syncCloudTasksEnabled) return; - if (!tasks || !workspaces || !workspacesFetched) return; - const missing = tasks.filter( - (t) => - t.latest_run?.environment === "cloud" && - !workspaces[t.id] && - !reconcilingTaskIds.current.has(t.id) && - !isContentlessTask(t), - ); - if (missing.length === 0) return; - const missingIds = missing.map((t) => t.id); - for (const id of missingIds) reconcilingTaskIds.current.add(id); - // Single batched IPC instead of one mutation per task — with many cloud - // tasks the per-task pattern saturates the main thread at boot. - hostClient.workspace.reconcileCloudWorkspaces - .mutate({ taskIds: missingIds }) - .then((result) => { - for (const id of missingIds) reconcilingTaskIds.current.delete(id); - if (result.created.length > 0) { - void queryClient.invalidateQueries({ - queryKey: trpc.workspace.getAll.queryKey(), - }); - } - }) - .catch((err) => { - for (const id of missingIds) reconcilingTaskIds.current.delete(id); - log.warn("Failed to reconcile cloud workspaces", err); - }); + const mirror = readMirror(); + const pane = mirror.panes.find((p) => p.id === paneId); + if (!pane) return; + const routeIdentity: PaneIdentity = { + dashboardId: params.dashboardId ?? null, + taskId: params.taskId ?? null, + channelId: params.channelId ?? null, + channelSection: routeChannelSection, + appView: routeAppView, + }; + const decision = decidePaneNavigation({ + paneIdentity: paneIdentityOf(pane), + routeIdentity, + otherOpenPanes: mirror.panes + .filter((p) => p.windowId === pane.windowId && p.id !== paneId) + .map((p) => ({ + tabId: p.tabId, + paneId: p.id, + identity: paneIdentityOf(p), + })), + historyAction: getPaneHistoryTracker(router).lastAction(), + }); + switch (decision.type) { + case "replacePane": { + const target = { paneId, ...routeIdentity }; + // Synchronous local apply keeps re-entrant runs (and the /website + // index redirect guard) from ever seeing the pre-navigation target. + applyLocalTransform((s) => + setPaneTarget(s, { ...target, now: Date.now }), + ); + void persistWrite(() => setPaneTargetMutation.mutateAsync(target)); + break; + } + case "activateTab": { + // The page already lives in another pane → focus its tab + pane, and + // put THIS pane's history back on its own identity (the navigation + // landed here before the dedup decision could). + applyLocalTransform((s) => + setFocusedPane( + setWindowActiveTab(s, pane.windowId, decision.tabId), + decision.tabId, + decision.paneId, + ), + ); + void persistWrite(() => + setActiveTabMutation.mutateAsync({ + windowId: pane.windowId, + tabId: decision.tabId, + }), + ); + void persistWrite(() => + setFocusedPaneMutation.mutateAsync({ + tabId: decision.tabId, + paneId: decision.paneId, + }), + ); + router.history.replace(hrefForIdentity(paneIdentityOf(pane))); + break; + } + case "noop": + break; + } }, [ - syncCloudTasksEnabled, - tasks, - workspaces, - workspacesFetched, - queryClient, - hostClient, - trpc, + paneId, + params.channelId, + params.dashboardId, + params.taskId, + routeChannelSection, + routeAppView, + router, + setPaneTargetMutation.mutateAsync, + setActiveTabMutation.mutateAsync, + setFocusedPaneMutation.mutateAsync, ]); - // The /code/home route is only reachable while the home-tab flag is on, but - // flags resolve asynchronously – a restored route (or a flag flipping off - // mid-session) can leave us on home without access. Redirect to the new-task - // screen once flags have loaded and home is gated off. - const [flagsLoaded, setFlagsLoaded] = useState(false); - useEffect(() => onFeatureFlagsLoaded(() => setFlagsLoaded(true)), []); - useEffect(() => { - if (flagsLoaded && !homeTabEnabled && view.type === "home") { - openTaskInput(); - } - }, [flagsLoaded, homeTabEnabled, view.type]); - - // Settings is a full-page route — drop the app chrome (header/sidebar/ - // space-switcher) so the panel occupies the full window. + // Settings is a full-window surface: it stays a route inside the pane's + // history (so closeSettings()'s history.back() exits it exactly as before), + // but renders through a portal covering the whole window — panes and chrome + // stay mounted (terminals keep running) underneath. const isSettingsRoute = useRouterState({ select: (s) => s.matches.some((m) => m.routeId.startsWith("/settings")), }); - // The Bluebird chrome is the app shell for every non-settings route now. The - // /website (Channels) routes own their own in-pane header (WebsiteLayout), so - // the shared ContentHeader is mounted only outside that space. + // The /website (Channels) routes own their own in-pane header + // (WebsiteLayout), so the shared ContentHeader is mounted only outside that + // space. const onWebsitePath = useRouterState({ select: (s) => s.location.pathname === "/website" || s.location.pathname.startsWith("/website/"), }); - // The /website (Channels) routes stay registered regardless of the flag, so a - // stale URL, a restored session, or a persisted channel browser tab could - // strand a flag-off user on the channel layout with no way back (the Channels - // toggle is hidden and ContentHeader is suppressed on /website). Once flags - // resolve, send them back to Code. - useEffect(() => { - if (flagsLoaded && !bluebirdEnabled && onWebsitePath) { - openTaskInput(); - } - }, [flagsLoaded, bluebirdEnabled, onWebsitePath]); - - // A blank browser tab (the "+" new-tab page) shows an empty placeholder — but - // ONLY on the channels index. Inside a channel (`/website/$channelId…`) the - // route owns the content (channel home, inbox, artifacts, a canvas, …), so the - // placeholder must never replace it, otherwise channel navigation looks dead. + // A blank pane (a fresh "+" tab) shows an empty placeholder — but ONLY on + // the channels index. Inside a channel (`/website/$channelId…`) the route + // owns the content, so the placeholder must never replace it, otherwise + // channel navigation looks dead. const onChannelsIndex = useRouterState({ select: (s) => s.location.pathname === "/website", }); - const activeTabBlank = useActiveTabIsBlank(); - const showBlankTab = onChannelsIndex && activeTabBlank; + const mirrorPane = snapshot.panes.find((p) => p.id === paneId); + const paneIsBlankNow = + !!mirrorPane && + mirrorPane.dashboardId == null && + mirrorPane.taskId == null && + mirrorPane.channelId == null && + mirrorPane.appView == null; + const showBlankTab = onChannelsIndex && paneIsBlankNow; + + // This pane's owner tab. The hover close-X only exists on multi-pane tabs + // (closing the last pane is closing the tab — the strip owns that), and the + // merge drop zones are suppressed while the dragged pill IS this tab (a tab + // can't merge into itself). + const ownerTab = mirrorPane + ? snapshot.tabs.find((t) => t.id === mirrorPane.tabId) + : undefined; + const isMultiPane = ownerTab?.layout.type === "split"; + const drag = usePaneDragStore((s) => s.drag); + const zonesActive = !!drag && !!ownerTab && drag.tabId !== ownerTab.id; if (isSettingsRoute) { - return ( - - + return createPortal( + - - - (open ? null : closeShortcutsSheet())} - /> - - {billingEnabled && } - - - - - + , + document.body, ); } return ( - // DnD scope for the tab strip's drag-to-reorder (pill sortables live in - // the title bar; the provider must sit above them). - - - {/* Full-width title bar: a window-drag region carrying the PostHog - mark. The left section matches the sidebar width so the tab strip - starts flush with the content pane; its padding clears the macOS - stoplights. */} - - + {/* The /website space renders its own header (WebsiteLayout); everywhere + else the shared header carries the view title and, on a task, its + action row. */} + {!onWebsitePath && } + + {showBlankTab ? : } + {/* Merge drop zones over the CONTENT slot only. */} + {zonesActive ? : null} + {/* Hover close-X: removes this pane from its (multi-pane) tab. Rides + the pane wrapper's `group` hover (BrowserPane). */} + {isMultiPane && ownerTab ? ( + { + void persistWrite(() => closePaneMutation.mutateAsync(input)); }} - > - - - - - - - - - - - - - - {/* Tabs work in both spaces: channel tabs under /website and plain - task tabs in the Code experience. The strip's route→tab effect - noops on param-less routes (inbox, agents, new-task), so it's safe - to mount everywhere. */} - - {/* Gated so an empty right-side group can't claim a no-drag rect - in the title bar for nothing — every pixel without controls - should drag the window. */} - {(billingEnabled || channelsEnabled) && ( - - - {channelsEnabled && ( - - )} - - )} - - - - {/* Invisible hover gutter: while the sidebar is collapsed, resting - the pointer on the window's left edge peeks the sidebar out as an - overlay. The panel (z-50) slides over this strip, so its own - hover handlers take over keeping the peek alive. */} - {!sidebarOpen && ( - { - // A drag-to-close sweeps the pointer through this strip — - // peeking then would fight the drag. - if (!sidebarIsResizing) beginSidebarPeek(); - }} - onMouseLeave={() => endSidebarPeek()} - /> - )} - {/* Scrim under the peeked nav: dims the content while the overlay is - out. Purely visual (pointer-transparent) and paired with the - panel's slide — same 200ms ease-out — so they read as one unit. */} - {!sidebarOpen && ( - - )} - - {/* Content sits in a bordered, rounded card inset from the window - edges — the framed pane from the design. */} - - + ) : null} + + + ); +} + +function ClosePaneButton({ + tabId, + paneId, + onClose, +}: { + tabId: string; + paneId: string; + onClose: (input: { tabId: string; paneId: string }) => void; +}) { + const handleClick = () => { + applyLocalTransform((s) => closePaneTransform(s, tabId, paneId)); + onClose({ tabId, paneId }); + }; + return ( + + + - - {/* The /website space renders its own header (WebsiteLayout); - everywhere else the shared header carries the view title - and, on a task, its action row. */} - {!onWebsitePath && } - - {showBlankTab ? : } - - - - - - - - (open ? null : closeShortcutsSheet())} - /> - - {/* Renders nothing — wires the ⌥↑/⌥↓ task-cycling shortcuts. */} - + } - onNavigateToTask={openTask} - onNewTask={openTaskInput} /> - - {billingEnabled && } - - - - - {approvalDeepLink.pending ? ( - - ) : null} - - - - + Close pane + + ); } diff --git a/packages/ui/src/router/useFocusedPaneRouter.ts b/packages/ui/src/router/useFocusedPaneRouter.ts new file mode 100644 index 0000000000..c73c334bb5 --- /dev/null +++ b/packages/ui/src/router/useFocusedPaneRouter.ts @@ -0,0 +1,30 @@ +import { browserTabsStore } from "@posthog/core/browser-tabs/browserTabsStore"; +import { primaryWindow } from "@posthog/shared"; +import { useSyncExternalStore } from "react"; +import { useStore } from "zustand"; +import { + type AppRouter, + getFocusedRouterOrNull, + getPaneRoutersVersion, + subscribePaneRouters, +} from "./paneRouterRegistry"; + +/** + * The focused pane's router, reactively: re-resolves when the active tab or + * its focused pane changes (tabs mirror) or when pane routers + * register/unregister. AppShell binds its RouterContextProvider to this, + * which is what makes every chrome hook (sidebar, title bar, useAppView) + * follow the focused pane. + */ +export function useFocusedPaneRouter(): AppRouter | null { + useStore(browserTabsStore, (s) => { + const win = primaryWindow(s.snapshot); + if (!win?.activeTabId) return null; + return ( + s.snapshot.tabs.find((t) => t.id === win.activeTabId)?.focusedPaneId ?? + null + ); + }); + useSyncExternalStore(subscribePaneRouters, getPaneRoutersVersion); + return getFocusedRouterOrNull(); +} diff --git a/packages/ui/src/router/usePaneHistoryControls.ts b/packages/ui/src/router/usePaneHistoryControls.ts new file mode 100644 index 0000000000..1c3007fa7e --- /dev/null +++ b/packages/ui/src/router/usePaneHistoryControls.ts @@ -0,0 +1,26 @@ +import { useCanGoBack, useRouter } from "@tanstack/react-router"; +import { useMemo, useSyncExternalStore } from "react"; +import { getPaneHistoryTracker } from "./createAppRouter"; + +/** + * Back/forward availability + actions for the pane router in context. In the + * shell that context is the FOCUSED pane's router (via AppShell's + * RouterContextProvider), so the title-bar buttons drive whichever pane the + * user is working in — and survive focus swaps, because the forward tracker + * lives with each router (see createAppRouter), not in component state. + */ +export function usePaneHistoryControls() { + const router = useRouter(); + const canGoBack = useCanGoBack(); + const tracker = useMemo(() => getPaneHistoryTracker(router), [router]); + const canGoForward = useSyncExternalStore( + tracker.subscribe, + tracker.canGoForward, + ); + return { + canGoBack, + canGoForward, + back: () => router.history.back(), + forward: () => router.history.forward(), + }; +} diff --git a/packages/ui/src/shell/App.tsx b/packages/ui/src/shell/App.tsx index 4963ab3970..7321846ae5 100644 --- a/packages/ui/src/shell/App.tsx +++ b/packages/ui/src/shell/App.tsx @@ -1,5 +1,12 @@ +import { browserTabsStore } from "@posthog/core/browser-tabs/browserTabsStore"; import { ToastProvider } from "@posthog/quill"; -import { EXTERNAL_LINKS, isNotAuthenticatedError } from "@posthog/shared"; +import { + EXTERNAL_LINKS, + focusedPaneOfTab, + isNotAuthenticatedError, + paneIdentityOf, + primaryWindow, +} from "@posthog/shared"; import { ANALYTICS_EVENTS } from "@posthog/shared/analytics-events"; import { AiApprovalScreen } from "@posthog/ui/features/ai-approval/AiApprovalScreen"; import { useOptionalAuthenticatedClient } from "@posthog/ui/features/auth/authClient"; @@ -12,6 +19,8 @@ import { InviteCodeScreen } from "@posthog/ui/features/auth/components/InviteCod import { ScopeReauthPrompt } from "@posthog/ui/features/auth/components/ScopeReauthPrompt"; import { useAuthSession } from "@posthog/ui/features/auth/useAuthSession"; import { useIsOrgAdmin } from "@posthog/ui/features/auth/useOrgRole"; +import { PaneTreeRenderer } from "@posthog/ui/features/browser-tabs/panes/PaneTreeRenderer"; +import { hrefForIdentity } from "@posthog/ui/features/browser-tabs/tabHref"; import { CanvasGenerationToaster } from "@posthog/ui/features/canvas/freeform/useCanvasGenerationToasts"; import { AddDirectoryDialog } from "@posthog/ui/features/folder-picker/AddDirectoryDialog"; import { ErrorDetailsDialog } from "@posthog/ui/features/notifications/ErrorDetailsDialog"; @@ -20,13 +29,19 @@ import { useOnboardingStore } from "@posthog/ui/features/onboarding/onboardingSt import { SettingsDialog } from "@posthog/ui/features/settings/SettingsDialog"; import { UpdateBanner } from "@posthog/ui/features/sidebar/components/UpdateBanner"; import { PendingPromptRecovery } from "@posthog/ui/features/task-detail/components/PendingPromptRecovery"; -import { router } from "@posthog/ui/router/router"; +import { + type AppRouter, + createAppRouter, + persistedPaneHref, +} from "@posthog/ui/router/createAppRouter"; +import { setPaneRouter } from "@posthog/ui/router/paneRouterRegistry"; +import { useFocusedPaneRouter } from "@posthog/ui/router/useFocusedPaneRouter"; import { AppLoadingScreen } from "@posthog/ui/shell/AppLoadingScreen"; +import { AppShell } from "@posthog/ui/shell/AppShell"; import { track } from "@posthog/ui/shell/analytics"; import { ErrorBoundary } from "@posthog/ui/shell/ErrorBoundary"; import { openExternalUrl } from "@posthog/ui/shell/openExternal"; import { useAppVisibilityWatchdog } from "@posthog/ui/shell/useAppVisibilityWatchdog"; -import { RouterProvider } from "@tanstack/react-router"; import { AnimatePresence, motion } from "framer-motion"; import { type ReactNode, useEffect, useRef, useState } from "react"; @@ -89,40 +104,72 @@ function App({ devToolbar }: AppProps) { !needsInviteCode && !needsAiApproval; - // Run the initial route's loaders before the router ever mounts, so the boot - // loading screen holds until the route is ready. The router turns loader - // errors into route error UI itself; the catch is only unhandled-rejection - // hygiene. Resets when the user leaves the main app (logout, gates) so - // re-entry loads fresh. - const [initialRouteLoaded, setInitialRouteLoaded] = useState(false); + // Create the focused pane's router once the tabs mirror has seeded — pane + // routers use memory history, and the initial entry comes from the pane's + // persisted location (Cmd+R / HMR restore) or its identity's canonical + // href. The initial route's loaders run before the router ever mounts, so + // the boot loading screen holds until the route is ready. The router turns + // loader errors into route error UI itself; the catch is only + // unhandled-rejection hygiene. The instance (and its history) survives + // leaving the main app (logout, gates) — re-entry reuses it. + const [paneRouter, setPaneRouterInstance] = useState(null); + const creatingRouter = useRef(false); useEffect(() => { - if (!readyForMainApp) { - setInitialRouteLoaded(false); - return; - } - if (initialRouteLoaded) return; + if (!readyForMainApp || paneRouter || creatingRouter.current) return; let cancelled = false; - void router - .load() - .catch(() => undefined) - .finally(() => { - if (!cancelled) setInitialRouteLoaded(true); + let unsubscribe: (() => void) | undefined; + const tryCreate = (): boolean => { + const snapshot = browserTabsStore.getState().snapshot; + const win = primaryWindow(snapshot); + const activeTab = win?.activeTabId + ? snapshot.tabs.find((t) => t.id === win.activeTabId) + : undefined; + const pane = activeTab + ? focusedPaneOfTab(snapshot, activeTab) + : undefined; + if (!pane) return false; // mirror not seeded yet + creatingRouter.current = true; + const initialHref = + persistedPaneHref(pane.id) ?? hrefForIdentity(paneIdentityOf(pane)); + const instance = createAppRouter({ paneId: pane.id, initialHref }); + setPaneRouter(pane.id, instance); + void instance + .load() + .catch(() => undefined) + .finally(() => { + creatingRouter.current = false; + if (!cancelled) setPaneRouterInstance(instance); + }); + return true; + }; + if (!tryCreate()) { + unsubscribe = browserTabsStore.subscribe(() => { + if (tryCreate()) { + unsubscribe?.(); + unsubscribe = undefined; + } }); + } return () => { cancelled = true; + unsubscribe?.(); }; - }, [readyForMainApp, initialRouteLoaded]); + }, [readyForMainApp, paneRouter]); + + // The shell's router context follows the focused pane once panes exist; + // the boot-created router is the fallback until then. + const focusedRouter = useFocusedPaneRouter(); const mainRef = useRef(null); // Mirrors the "main" branch of renderContent() below; keep the two in sync. - const showingMainApp = readyForMainApp && initialRouteLoaded; + const showingMainApp = readyForMainApp && paneRouter !== null; useAppVisibilityWatchdog(mainRef, showingMainApp); // Single gate for every state where the whole app is still loading. if ( !isBootstrapped || isCheckingAccess || - (readyForMainApp && !initialRouteLoaded) + (readyForMainApp && paneRouter === null) ) { return ; } @@ -179,9 +226,15 @@ function App({ devToolbar }: AppProps) { ); } + if (!paneRouter) return null; // unreachable: gated by the loading screen return ( - + {/* Window chrome outside the route tree; the active tab's pane tree + (one RouterProvider per pane) renders inside it. The shell's own + router context follows the focused pane. */} + + + {/* Surfaces a toast when a backgrounded canvas generation finishes, from anywhere in the app. Sibling of the router so it stays mounted across every route (not just the canvas space). Renders null. */} diff --git a/packages/ui/src/shell/AppShell.tsx b/packages/ui/src/shell/AppShell.tsx new file mode 100644 index 0000000000..558fa70cb4 --- /dev/null +++ b/packages/ui/src/shell/AppShell.tsx @@ -0,0 +1,476 @@ +import { + ArrowSquareOut, + CaretLeftIcon, + CaretRightIcon, +} from "@phosphor-icons/react"; +import { useHostTRPC, useHostTRPCClient } from "@posthog/host-router/react"; +import { Button, ButtonGroup } from "@posthog/quill"; +import { + BILLING_FLAG, + HOME_TAB_FLAG, + PROJECT_BLUEBIRD_FLAG, + SYNC_CLOUD_TASKS_FLAG, +} from "@posthog/shared"; +import { ANALYTICS_EVENTS } from "@posthog/shared/analytics-events"; +import { isContentlessTask } from "@posthog/shared/domain-types"; +import { DeepLinkApprovalModal } from "@posthog/ui/features/agent-applications/components/DeepLinkApprovalModal"; +import { useApprovalDeepLink } from "@posthog/ui/features/agent-applications/hooks/useApprovalDeepLink"; +import { useAuthStateValue } from "@posthog/ui/features/auth/store"; +import { UsageButton } from "@posthog/ui/features/billing/UsageButton"; +import { UsageLimitModal } from "@posthog/ui/features/billing/UsageLimitModal"; +import { BrowserTabStrip } from "@posthog/ui/features/browser-tabs/BrowserTabStrip"; +import { BrowserTabsDndProvider } from "@posthog/ui/features/browser-tabs/BrowserTabsDnd"; +import { ChannelsSidebar } from "@posthog/ui/features/canvas/components/ChannelsSidebar"; +import { useChannelsSidebarStore } from "@posthog/ui/features/canvas/components/channelsSidebarStore"; +import { + FeedbackModal, + type FeedbackModalMode, +} from "@posthog/ui/features/canvas/components/FeedbackModal"; +import { useCanvasDeepLink } from "@posthog/ui/features/canvas/hooks/useCanvasDeepLink"; +import { useChannelDeepLink } from "@posthog/ui/features/canvas/hooks/useChannelDeepLink"; +import { CommandMenu } from "@posthog/ui/features/command/CommandMenu"; +import { GlobalFilePicker } from "@posthog/ui/features/command/GlobalFilePicker"; +import { KeyboardShortcutsSheet } from "@posthog/ui/features/command/KeyboardShortcutsSheet"; +import { ConnectivityBanner } from "@posthog/ui/features/connectivity/ConnectivityBanner"; +import { useNewTaskDeepLink } from "@posthog/ui/features/deep-links/useNewTaskDeepLink"; +import { useOpenTargetDeepLink } from "@posthog/ui/features/deep-links/useOpenTargetDeepLink"; +import { useTaskDeepLink } from "@posthog/ui/features/deep-links/useTaskDeepLink"; +import { useFeatureFlag } from "@posthog/ui/features/feature-flags/useFeatureFlag"; +import { useInboxDeepLink } from "@posthog/ui/features/inbox/hooks/useInboxDeepLink"; +import { useIntegrations } from "@posthog/ui/features/integrations/useIntegrations"; +import { useScoutDeepLink } from "@posthog/ui/features/scouts/hooks/useScoutDeepLink"; +import { useSetupDiscovery } from "@posthog/ui/features/setup/useSetupDiscovery"; +import { + beginSidebarPeek, + cancelSidebarPeek, + endSidebarPeek, + useSidebarPeekStore, +} from "@posthog/ui/features/sidebar/sidebarPeekStore"; +import { useSidebarStore } from "@posthog/ui/features/sidebar/sidebarStore"; +import { useSidebarData } from "@posthog/ui/features/sidebar/useSidebarData"; +import { useVisualTaskOrder } from "@posthog/ui/features/sidebar/useVisualTaskOrder"; +import { ExistingWorktreeDialog } from "@posthog/ui/features/task-detail/components/ExistingWorktreeDialog"; +import { RemoteBranchCheckoutDialog } from "@posthog/ui/features/task-detail/components/RemoteBranchCheckoutDialog"; +import { useTasks } from "@posthog/ui/features/tasks/useTasks"; +import { TourOverlay } from "@posthog/ui/features/tour/components/TourOverlay"; +import { UpdateAvailableModal } from "@posthog/ui/features/updates/UpdateAvailableModal"; +import { WhatsNewModal } from "@posthog/ui/features/updates/WhatsNewModal"; +import { useWorkspaces } from "@posthog/ui/features/workspace/useWorkspace"; +import LogosLandscape from "@posthog/ui/primitives/Logo"; +import type { AppRouter } from "@posthog/ui/router/paneRouterRegistry"; +import { useAppView } from "@posthog/ui/router/useAppView"; +import { openTask, openTaskInput } from "@posthog/ui/router/useOpenTask"; +import { usePaneHistoryControls } from "@posthog/ui/router/usePaneHistoryControls"; +import { track } from "@posthog/ui/shell/analytics"; +import { useCommandMenuStore } from "@posthog/ui/shell/commandMenuStore"; +import { GlobalEventHandlers } from "@posthog/ui/shell/GlobalEventHandlers"; +import { HedgehogMode } from "@posthog/ui/shell/HedgehogMode"; +import { logger } from "@posthog/ui/shell/logger"; +import { onFeatureFlagsLoaded } from "@posthog/ui/shell/posthogAnalyticsImpl"; +import { SpaceSwitcher } from "@posthog/ui/shell/SpaceSwitcher"; +import { useShortcutsSheetStore } from "@posthog/ui/shell/shortcutsSheetStore"; +import { openUrlInBrowser } from "@posthog/ui/utils/browser"; +import { isWindows } from "@posthog/ui/utils/platform"; +import { getPostHogUrl } from "@posthog/ui/utils/urls"; +import { Box, Flex } from "@radix-ui/themes"; +import { useQueryClient } from "@tanstack/react-query"; +import { RouterContextProvider, useRouterState } from "@tanstack/react-router"; +import { SidebarClose, SidebarOpen } from "lucide-react"; +import { type ReactNode, useEffect, useRef, useState } from "react"; + +const log = logger.scope("app-shell"); + +// On Windows the frameless window overlays the min/max/close controls on the +// top-right of the title bar (see window.ts titleBarOverlay). Reserve that strip +// so the tab strip / PostHog Web button never render under the native controls. +const WINDOWS_TITLEBAR_INSET = 140; + +/** + * The window chrome, rendered OUTSIDE the route tree: title bar (logo, + * back/forward, the browser-tab strip, PostHog Web), connectivity banner, + * channels sidebar, global modals/menus, deep-link handlers, and every + * window-level side effect that must run exactly once. `children` is the + * routed content (the active tab's pane tree — a RouterProvider per pane), + * mounted in the inset content card. + * + * The whole shell sits inside a `RouterContextProvider` bound to the FOCUSED + * pane's router (the active tab's focused pane), so every existing router + * hook in the sidebar and title bar (useAppView, useRouterState, useCanGoBack, + * Link) reads the focused pane and retargets automatically when focus moves — + * chrome gets focused-pane semantics without rewriting its hooks. + */ +export function AppShell({ + router, + children, +}: { + router: AppRouter; + children: ReactNode; +}) { + return ( + + {children} + + ); +} + +function AppShellChrome({ children }: { children: ReactNode }) { + const view = useAppView(); + // Back/forward for the focused pane's history (the router in context). + const { + canGoBack, + canGoForward, + back: goBack, + forward: goForward, + } = usePaneHistoryControls(); + // Width of the Channels sidebar below — used to right-align the back/forward + // buttons in the title bar with the sidebar's (and project switcher's) right edge. + const channelsSidebarWidth = useChannelsSidebarStore((state) => state.width); + // Suppress the title-bar width transition during a live drag so it tracks + // the sidebar frame-for-frame; when the sidebar toggles open/closed, both + // animate with the same curve (see ResizableSidebar). + const sidebarIsResizing = useChannelsSidebarStore( + (state) => state.isResizing, + ); + + // Feedback modal shown as an intercept before "PostHog Web" opens the web + // app, routing once the modal is submitted or skipped. + const [feedbackMode, setFeedbackMode] = useState( + null, + ); + const currentProjectId = useAuthStateValue((s) => s.currentProjectId); + + // The user's current project on the correct cloud (region comes from + // cloudRegion via getPostHogUrl), falling back to the account root. `null` + // when the region is unknown — the "PostHog Web" button is disabled then, so + // a click can never silently no-op. + const posthogWebUrl = getPostHogUrl( + currentProjectId ? `/project/${currentProjectId}` : "/", + ); + + // "PostHog Web" opens the feedback modal first and performs its navigation + // only once the modal is submitted or skipped. + const handleFeedbackFinished = () => { + const finishedMode = feedbackMode; + setFeedbackMode(null); + if (finishedMode === "posthog-web" && posthogWebUrl) { + void openUrlInBrowser(posthogWebUrl); + } + }; + + const handleOpenPostHogWeb = () => { + track(ANALYTICS_EVENTS.POSTHOG_WEB_OPENED); + setFeedbackMode("posthog-web"); + }; + const { + isOpen: commandMenuOpen, + setOpen: setCommandMenuOpen, + toggle: toggleCommandMenu, + } = useCommandMenuStore(); + const { + isOpen: shortcutsSheetOpen, + close: closeShortcutsSheet, + toggle: toggleShortcutsSheet, + } = useShortcutsSheetStore(); + const { data: tasks } = useTasks(); + const { data: workspaces, isFetched: workspacesFetched } = useWorkspaces(); + const trpc = useHostTRPC(); + const hostClient = useHostTRPCClient(); + const queryClient = useQueryClient(); + const reconcilingTaskIds = useRef>(new Set()); + const billingEnabled = useFeatureFlag(BILLING_FLAG); + const syncCloudTasksEnabled = useFeatureFlag(SYNC_CLOUD_TASKS_FLAG); + const homeTabEnabled = useFeatureFlag(HOME_TAB_FLAG); + // "PostHog Web" is a channels-world affordance — show it only while the user + // is actually seeing channels (toggle on, which itself requires the flag). + const bluebirdEnabled = useFeatureFlag( + PROJECT_BLUEBIRD_FLAG, + import.meta.env.DEV, + ); + const channelsToggleOn = useSidebarStore((s) => s.channelsEnabled); + const channelsEnabled = channelsToggleOn && bluebirdEnabled; + // When the sidebar is collapsed (Cmd+B) the title bar's left block shrinks to + // fit its own controls so the tab strip flushes left with the content pane. + const sidebarOpen = useSidebarStore((s) => s.open); + const toggleSidebar = useSidebarStore((s) => s.toggle); + const sidebarPeek = useSidebarPeekStore((s) => s.peek); + // Toggling makes any hover-peek redundant (opening replaces the overlay; + // closing must not leave it lingering under the pointer). + const handleToggleSidebar = (): void => { + cancelSidebarPeek(); + toggleSidebar(); + }; + + const sidebarData = useSidebarData({ activeView: view }); + const visualTaskOrder = useVisualTaskOrder(sidebarData); + const activeTaskId = + view.type === "task-detail" && view.taskId ? view.taskId : null; + + useIntegrations(); + useTaskDeepLink(); + useOpenTargetDeepLink(); + useInboxDeepLink(); + useScoutDeepLink(); + useCanvasDeepLink(); + useChannelDeepLink(); + const approvalDeepLink = useApprovalDeepLink(); + useSetupDiscovery(); + useNewTaskDeepLink(); + + useEffect(() => { + if (!syncCloudTasksEnabled) return; + if (!tasks || !workspaces || !workspacesFetched) return; + const missing = tasks.filter( + (t) => + t.latest_run?.environment === "cloud" && + !workspaces[t.id] && + !reconcilingTaskIds.current.has(t.id) && + !isContentlessTask(t), + ); + if (missing.length === 0) return; + const missingIds = missing.map((t) => t.id); + for (const id of missingIds) reconcilingTaskIds.current.add(id); + // Single batched IPC instead of one mutation per task — with many cloud + // tasks the per-task pattern saturates the main thread at boot. + hostClient.workspace.reconcileCloudWorkspaces + .mutate({ taskIds: missingIds }) + .then((result) => { + for (const id of missingIds) reconcilingTaskIds.current.delete(id); + if (result.created.length > 0) { + void queryClient.invalidateQueries({ + queryKey: trpc.workspace.getAll.queryKey(), + }); + } + }) + .catch((err) => { + for (const id of missingIds) reconcilingTaskIds.current.delete(id); + log.warn("Failed to reconcile cloud workspaces", err); + }); + }, [ + syncCloudTasksEnabled, + tasks, + workspaces, + workspacesFetched, + queryClient, + hostClient, + trpc, + ]); + + // The /code/home route is only reachable while the home-tab flag is on, but + // flags resolve asynchronously – a restored route (or a flag flipping off + // mid-session) can leave us on home without access. Redirect to the new-task + // screen once flags have loaded and home is gated off. + const [flagsLoaded, setFlagsLoaded] = useState(false); + useEffect(() => onFeatureFlagsLoaded(() => setFlagsLoaded(true)), []); + useEffect(() => { + if (flagsLoaded && !homeTabEnabled && view.type === "home") { + openTaskInput(); + } + }, [flagsLoaded, homeTabEnabled, view.type]); + + const onWebsitePath = useRouterState({ + select: (s) => + s.location.pathname === "/website" || + s.location.pathname.startsWith("/website/"), + }); + + // The /website (Channels) routes stay registered regardless of the flag, so a + // stale URL, a restored session, or a persisted channel browser tab could + // strand a flag-off user on the channel layout with no way back (the Channels + // toggle is hidden and ContentHeader is suppressed on /website). Once flags + // resolve, send them back to Code. + useEffect(() => { + if (flagsLoaded && !bluebirdEnabled && onWebsitePath) { + openTaskInput(); + } + }, [flagsLoaded, bluebirdEnabled, onWebsitePath]); + + return ( + // DnD scope for the tab strip's drag-to-reorder and the merge drop zones — + // the provider must span the title-bar pills and every pane. + + + {/* Full-width title bar: a window-drag region carrying the PostHog + mark. The left section matches the sidebar width so the tab strip + starts flush with the content pane; its padding clears the macOS + stoplights. */} + + + + + + + + + + + + + + + + {/* One strip for the window. Each pill is a whole tab (which may + hold several panes — the pill then carries a layout glyph). */} + + {/* Gated so an empty right-side group can't claim a no-drag rect + in the title bar for nothing — every pixel without controls + should drag the window. */} + {(billingEnabled || channelsEnabled) && ( + + + {channelsEnabled && ( + + )} + + )} + + + + {/* Invisible hover gutter: while the sidebar is collapsed, resting + the pointer on the window's left edge peeks the sidebar out as an + overlay. The panel (z-50) slides over this strip, so its own + hover handlers take over keeping the peek alive. */} + {!sidebarOpen && ( + { + // A drag-to-close sweeps the pointer through this strip — + // peeking then would fight the drag. + if (!sidebarIsResizing) beginSidebarPeek(); + }} + onMouseLeave={() => endSidebarPeek()} + /> + )} + {/* Scrim under the peeked nav: dims the content while the overlay is + out. Purely visual (pointer-transparent) and paired with the + panel's slide — same 200ms ease-out — so they read as one unit. */} + {!sidebarOpen && ( + + )} + + {/* Content sits in a bordered, rounded card inset from the window + edges — the framed pane from the design. The active tab's pane + tree renders inside it. */} + + + {children} + + + + + + (open ? null : closeShortcutsSheet())} + /> + + {/* Renders nothing — wires the ⌥↑/⌥↓ task-cycling shortcuts. */} + + + {billingEnabled && } + + + + + {approvalDeepLink.pending ? ( + + ) : null} + + + + + ); +} diff --git a/packages/workspace-server/src/db/migrations/0021_tab_owned_panes.sql b/packages/workspace-server/src/db/migrations/0021_tab_owned_panes.sql new file mode 100644 index 0000000000..d3f7dc4c61 --- /dev/null +++ b/packages/workspace-server/src/db/migrations/0021_tab_owned_panes.sql @@ -0,0 +1,44 @@ +-- Tab-owned panes (v2 split-pane model): tabs gain a `layout` tree + +-- `focused_pane_id`; identity columns move onto new `browser_panes` rows (one +-- backfilled per tab). +-- +-- The first two statements heal dev profiles that dogfooded the pre-merge v1 +-- split-pane branch (window-owned panes): v1 created its own `browser_panes` +-- shape (window_id/active_tab_id) and DROPPED `browser_windows.active_tab_id`. +-- The ADD fails with "duplicate column name" on healthy v0 DBs, which the +-- migration runner tolerates globally; the DROP TABLE IF EXISTS is a no-op +-- there. v1 pane rows are dev-only data and are discarded (v1's extra +-- `browser_tabs.pane_id` / `browser_windows.layout|focused_pane_id` columns +-- are left behind as inert cruft — SQLite has no DROP COLUMN IF EXISTS, and +-- drizzle only reads declared columns). +ALTER TABLE `browser_windows` ADD `active_tab_id` text;--> statement-breakpoint +DROP TABLE IF EXISTS `browser_panes`;--> statement-breakpoint +CREATE TABLE `browser_panes` ( + `id` text PRIMARY KEY NOT NULL, + `tab_id` text NOT NULL, + `window_id` text NOT NULL, + `dashboard_id` text, + `task_id` text, + `channel_id` text, + `channel_section` text, + `app_view` text, + `scroll_state` text, + `created_at` integer NOT NULL, + `last_active_at` integer NOT NULL, + FOREIGN KEY (`tab_id`) REFERENCES `browser_tabs`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +CREATE INDEX `browser_panes_tab_idx` ON `browser_panes` (`tab_id`);--> statement-breakpoint +ALTER TABLE `browser_tabs` ADD `layout` text;--> statement-breakpoint +ALTER TABLE `browser_tabs` ADD `focused_pane_id` text;--> statement-breakpoint +INSERT INTO `browser_panes` (`id`, `tab_id`, `window_id`, `dashboard_id`, `task_id`, `channel_id`, `channel_section`, `app_view`, `scroll_state`, `created_at`, `last_active_at`) + SELECT `id` || '-pane', `id`, `window_id`, `dashboard_id`, `task_id`, `channel_id`, `channel_section`, `app_view`, `scroll_state`, `created_at`, `last_active_at` FROM `browser_tabs`;--> statement-breakpoint +UPDATE `browser_tabs` SET + `layout` = json_object('type', 'leaf', 'paneId', `id` || '-pane'), + `focused_pane_id` = `id` || '-pane';--> statement-breakpoint +ALTER TABLE `browser_tabs` DROP COLUMN `dashboard_id`;--> statement-breakpoint +ALTER TABLE `browser_tabs` DROP COLUMN `task_id`;--> statement-breakpoint +ALTER TABLE `browser_tabs` DROP COLUMN `channel_id`;--> statement-breakpoint +ALTER TABLE `browser_tabs` DROP COLUMN `channel_section`;--> statement-breakpoint +ALTER TABLE `browser_tabs` DROP COLUMN `scroll_state`;--> statement-breakpoint +ALTER TABLE `browser_tabs` DROP COLUMN `app_view`; diff --git a/packages/workspace-server/src/db/migrations/meta/0021_snapshot.json b/packages/workspace-server/src/db/migrations/meta/0021_snapshot.json new file mode 100644 index 0000000000..5ebf211300 --- /dev/null +++ b/packages/workspace-server/src/db/migrations/meta/0021_snapshot.json @@ -0,0 +1,1098 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "1af8a236-597b-4dfa-9e4c-6256438a7e78", + "prevId": "e99d8d2c-d74b-4dea-9261-b5923c869d7a", + "tables": { + "archives": { + "name": "archives", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "branch_name": { + "name": "branch_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "checkpoint_id": { + "name": "checkpoint_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "archived_at": { + "name": "archived_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": { + "archives_workspaceId_unique": { + "name": "archives_workspaceId_unique", + "columns": ["workspace_id"], + "isUnique": true + } + }, + "foreignKeys": { + "archives_workspace_id_workspaces_id_fk": { + "name": "archives_workspace_id_workspaces_id_fk", + "tableFrom": "archives", + "tableTo": "workspaces", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "auth_org_project_preferences": { + "name": "auth_org_project_preferences", + "columns": { + "account_key": { + "name": "account_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "cloud_region": { + "name": "cloud_region", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_selected_project_id": { + "name": "last_selected_project_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": { + "auth_org_project_account_region_org_idx": { + "name": "auth_org_project_account_region_org_idx", + "columns": ["account_key", "cloud_region", "org_id"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "auth_preferences": { + "name": "auth_preferences", + "columns": { + "account_key": { + "name": "account_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "cloud_region": { + "name": "cloud_region", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_selected_project_id": { + "name": "last_selected_project_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_selected_org_id": { + "name": "last_selected_org_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": { + "auth_preferences_account_region_idx": { + "name": "auth_preferences_account_region_idx", + "columns": ["account_key", "cloud_region"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "auth_sessions": { + "name": "auth_sessions", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "refresh_token_encrypted": { + "name": "refresh_token_encrypted", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "cloud_region": { + "name": "cloud_region", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "selected_project_id": { + "name": "selected_project_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "scope_version": { + "name": "scope_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "autoresearch_runs": { + "name": "autoresearch_runs", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "task_id": { + "name": "task_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ended_at": { + "name": "ended_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "data": { + "name": "data", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": { + "autoresearch_runs_task_id_idx": { + "name": "autoresearch_runs_task_id_idx", + "columns": ["task_id"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "browser_panes": { + "name": "browser_panes", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tab_id": { + "name": "tab_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "window_id": { + "name": "window_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "dashboard_id": { + "name": "dashboard_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "task_id": { + "name": "task_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "channel_section": { + "name": "channel_section", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "app_view": { + "name": "app_view", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "scroll_state": { + "name": "scroll_state", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_active_at": { + "name": "last_active_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "browser_panes_tab_idx": { + "name": "browser_panes_tab_idx", + "columns": ["tab_id"], + "isUnique": false + } + }, + "foreignKeys": { + "browser_panes_tab_id_browser_tabs_id_fk": { + "name": "browser_panes_tab_id_browser_tabs_id_fk", + "tableFrom": "browser_panes", + "tableTo": "browser_tabs", + "columnsFrom": ["tab_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "browser_tabs": { + "name": "browser_tabs", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "window_id": { + "name": "window_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "layout": { + "name": "layout", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "focused_pane_id": { + "name": "focused_pane_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_active_at": { + "name": "last_active_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "browser_tabs_window_idx": { + "name": "browser_tabs_window_idx", + "columns": ["window_id"], + "isUnique": false + } + }, + "foreignKeys": { + "browser_tabs_window_id_browser_windows_id_fk": { + "name": "browser_tabs_window_id_browser_windows_id_fk", + "tableFrom": "browser_tabs", + "tableTo": "browser_windows", + "columnsFrom": ["window_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "browser_windows": { + "name": "browser_windows", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "is_primary": { + "name": "is_primary", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "bounds": { + "name": "bounds", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "active_tab_id": { + "name": "active_tab_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "claude_session_imports": { + "name": "claude_session_imports", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "source_session_id": { + "name": "source_session_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "imported_session_id": { + "name": "imported_session_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "task_id": { + "name": "task_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "repo_path": { + "name": "repo_path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source_mtime_ms": { + "name": "source_mtime_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source_size_bytes": { + "name": "source_size_bytes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source_last_entry_uuid": { + "name": "source_last_entry_uuid", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": { + "claude_session_imports_importedSessionId_unique": { + "name": "claude_session_imports_importedSessionId_unique", + "columns": ["imported_session_id"], + "isUnique": true + }, + "claude_session_imports_source_idx": { + "name": "claude_session_imports_source_idx", + "columns": ["source_session_id"], + "isUnique": false + }, + "claude_session_imports_task_idx": { + "name": "claude_session_imports_task_idx", + "columns": ["task_id"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "default_additional_directories": { + "name": "default_additional_directories", + "columns": { + "path": { + "name": "path", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "repositories": { + "name": "repositories", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "remote_url": { + "name": "remote_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_accessed_at": { + "name": "last_accessed_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": { + "repositories_path_unique": { + "name": "repositories_path_unique", + "columns": ["path"], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "suspensions": { + "name": "suspensions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "branch_name": { + "name": "branch_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "checkpoint_id": { + "name": "checkpoint_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "suspended_at": { + "name": "suspended_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": { + "suspensions_workspaceId_unique": { + "name": "suspensions_workspaceId_unique", + "columns": ["workspace_id"], + "isUnique": true + } + }, + "foreignKeys": { + "suspensions_workspace_id_workspaces_id_fk": { + "name": "suspensions_workspace_id_workspaces_id_fk", + "tableFrom": "suspensions", + "tableTo": "workspaces", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "task_metadata": { + "name": "task_metadata", + "columns": { + "task_id": { + "name": "task_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "pinned_at": { + "name": "pinned_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_viewed_at": { + "name": "last_viewed_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_activity_at": { + "name": "last_activity_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "archived_at": { + "name": "archived_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "workspaces": { + "name": "workspaces", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "task_id": { + "name": "task_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "repository_id": { + "name": "repository_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "mode": { + "name": "mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "linked_branch": { + "name": "linked_branch", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "pinned_at": { + "name": "pinned_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_viewed_at": { + "name": "last_viewed_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_activity_at": { + "name": "last_activity_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "additional_directories": { + "name": "additional_directories", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'[]'" + }, + "pr_url": { + "name": "pr_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "pr_state": { + "name": "pr_state", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "pr_urls": { + "name": "pr_urls", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'[]'" + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": { + "workspaces_taskId_unique": { + "name": "workspaces_taskId_unique", + "columns": ["task_id"], + "isUnique": true + }, + "workspaces_repository_id_idx": { + "name": "workspaces_repository_id_idx", + "columns": ["repository_id"], + "isUnique": false + } + }, + "foreignKeys": { + "workspaces_repository_id_repositories_id_fk": { + "name": "workspaces_repository_id_repositories_id_fk", + "tableFrom": "workspaces", + "tableTo": "repositories", + "columnsFrom": ["repository_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "worktrees": { + "name": "worktrees", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": { + "worktrees_workspaceId_unique": { + "name": "worktrees_workspaceId_unique", + "columns": ["workspace_id"], + "isUnique": true + } + }, + "foreignKeys": { + "worktrees_workspace_id_workspaces_id_fk": { + "name": "worktrees_workspace_id_workspaces_id_fk", + "tableFrom": "worktrees", + "tableTo": "workspaces", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} diff --git a/packages/workspace-server/src/db/migrations/meta/_journal.json b/packages/workspace-server/src/db/migrations/meta/_journal.json index 25b5e27f0f..0d4ad30168 100644 --- a/packages/workspace-server/src/db/migrations/meta/_journal.json +++ b/packages/workspace-server/src/db/migrations/meta/_journal.json @@ -148,6 +148,13 @@ "when": 1783685997328, "tag": "0020_repair_browser_tabs_schema", "breakpoints": true + }, + { + "idx": 21, + "version": "6", + "when": 1783800060350, + "tag": "0021_tab_owned_panes", + "breakpoints": true } ] } diff --git a/packages/workspace-server/src/db/repositories/browser-tabs-repository.ts b/packages/workspace-server/src/db/repositories/browser-tabs-repository.ts index 84bb44488f..1d363c2d9c 100644 --- a/packages/workspace-server/src/db/repositories/browser-tabs-repository.ts +++ b/packages/workspace-server/src/db/repositories/browser-tabs-repository.ts @@ -1,8 +1,12 @@ -import type { TabsSnapshot } from "@posthog/shared"; +import { + type PaneLayoutNode, + paneLayoutNodeSchema, + type TabsSnapshot, +} from "@posthog/shared"; import { asc } from "drizzle-orm"; import { inject, injectable } from "inversify"; import { DATABASE_SERVICE } from "../identifiers"; -import { browserTabs, browserWindows } from "../schema"; +import { browserPanes, browserTabs, browserWindows } from "../schema"; import type { DatabaseService } from "../service"; /** @@ -33,6 +37,7 @@ export class BrowserTabsRepository implements IBrowserTabsRepository { .orderBy(asc(browserWindows.position)) .all(); const tabRows = this.db.select().from(browserTabs).all(); + const paneRows = this.db.select().from(browserPanes).all(); return { windows: windowRows.map((w) => ({ @@ -41,18 +46,37 @@ export class BrowserTabsRepository implements IBrowserTabsRepository { bounds: w.bounds ?? null, activeTabId: w.activeTabId ?? null, })), - tabs: tabRows.map((t) => ({ - id: t.id, - windowId: t.windowId, - dashboardId: t.dashboardId, - taskId: t.taskId ?? null, - channelId: t.channelId ?? null, - channelSection: t.channelSection ?? null, - appView: t.appView ?? null, - position: t.position, - scrollState: t.scrollState ?? null, - createdAt: t.createdAt, - lastActiveAt: t.lastActiveAt, + tabs: tabRows.map((t) => { + // A corrupt/null layout (the column is nullable at the SQL level) + // degrades to a leaf of the focused pane; ensureSnapshotIntegrity in + // the service reconciles the leaf↔pane bijection from there. + const parsed = paneLayoutNodeSchema.safeParse(t.layout); + const focusedPaneId = t.focusedPaneId ?? `${t.id}-pane`; + const layout: PaneLayoutNode = parsed.success + ? parsed.data + : { type: "leaf", paneId: focusedPaneId }; + return { + id: t.id, + windowId: t.windowId, + layout, + focusedPaneId, + position: t.position, + createdAt: t.createdAt, + lastActiveAt: t.lastActiveAt, + }; + }), + panes: paneRows.map((p) => ({ + id: p.id, + tabId: p.tabId, + windowId: p.windowId, + dashboardId: p.dashboardId, + taskId: p.taskId ?? null, + channelId: p.channelId ?? null, + channelSection: p.channelSection ?? null, + appView: p.appView ?? null, + scrollState: p.scrollState ?? null, + createdAt: p.createdAt, + lastActiveAt: p.lastActiveAt, })), }; } @@ -60,7 +84,8 @@ export class BrowserTabsRepository implements IBrowserTabsRepository { save(snapshot: TabsSnapshot): void { const now = Date.now(); this.db.transaction((tx) => { - // Tabs first (FK), then windows. + // Children first (FK): panes, then tabs, then windows. + tx.delete(browserPanes).run(); tx.delete(browserTabs).run(); tx.delete(browserWindows).run(); @@ -85,19 +110,34 @@ export class BrowserTabsRepository implements IBrowserTabsRepository { snapshot.tabs.map((t) => ({ id: t.id, windowId: t.windowId, - dashboardId: t.dashboardId, - taskId: t.taskId ?? null, - channelId: t.channelId ?? null, - channelSection: t.channelSection ?? null, - appView: t.appView ?? null, + layout: t.layout, + focusedPaneId: t.focusedPaneId, position: t.position, - scrollState: t.scrollState ?? null, createdAt: t.createdAt, lastActiveAt: t.lastActiveAt, })), ) .run(); } + if (snapshot.panes.length > 0) { + tx.insert(browserPanes) + .values( + snapshot.panes.map((p) => ({ + id: p.id, + tabId: p.tabId, + windowId: p.windowId, + dashboardId: p.dashboardId, + taskId: p.taskId ?? null, + channelId: p.channelId ?? null, + channelSection: p.channelSection ?? null, + appView: p.appView ?? null, + scrollState: p.scrollState ?? null, + createdAt: p.createdAt, + lastActiveAt: p.lastActiveAt, + })), + ) + .run(); + } }); } } diff --git a/packages/workspace-server/src/db/schema.ts b/packages/workspace-server/src/db/schema.ts index 4f2e47de9d..282d3c58b0 100644 --- a/packages/workspace-server/src/db/schema.ts +++ b/packages/workspace-server/src/db/schema.ts @@ -224,9 +224,10 @@ export const browserWindows = sqliteTable("browser_windows", { }); /** - * Open tabs in the Channels canvas surface. A tab references a canvas - * (dashboard) and the channel it belongs to; display is resolved at render. - * `scrollState` is reserved/unwired for later per-tab state (scroll restore). + * Open tabs in the Channels canvas surface. A tab is a strip unit that OWNS a + * split-pane layout (a bare leaf for the common single-pane tab); what a tab + * shows lives on its pane rows (`browser_panes`). The pill renders the focused + * pane's identity. */ export const browserTabs = sqliteTable( "browser_tabs", @@ -235,24 +236,50 @@ export const browserTabs = sqliteTable( windowId: text() .notNull() .references(() => browserWindows.id, { onDelete: "cascade" }), - /** Canvas this tab shows. Null for a task tab or a blank tab. */ + /** Root of the tab's pane layout tree, JSON PaneLayoutNode. */ + layout: text({ mode: "json" }).$type().notNull(), + /** Pane owning focus within this tab; a leaf of `layout`. */ + focusedPaneId: text().notNull(), + /** Gap-spaced ordering key within a window. */ + position: integer().notNull(), + /** Epoch ms. */ + createdAt: integer().notNull(), + lastActiveAt: integer().notNull(), + }, + (t) => [index("browser_tabs_window_idx").on(t.windowId)], +); + +/** + * Panes: the content units inside a tab's split layout. A pane references a + * canvas (dashboard) / task / channel / app view; display is resolved at + * render. `windowId` is denormalised from the owning tab so window-scoped + * identity dedup needs no join. `scrollState` is reserved/unwired for later + * per-pane state (scroll restore). + */ +export const browserPanes = sqliteTable( + "browser_panes", + { + id: id(), + tabId: text() + .notNull() + .references(() => browserTabs.id, { onDelete: "cascade" }), + windowId: text().notNull(), + /** Canvas this pane shows. Null for a task pane or a blank pane. */ dashboardId: text(), - /** Task this tab shows. Null for a canvas tab or a blank tab. */ + /** Task this pane shows. Null for a canvas pane or a blank pane. */ taskId: text(), channelId: text(), /** Channel sub-section (inbox/artifacts/history/context). Null = channel - * home, or a non-channel tab. */ + * home, or a non-channel pane. */ channelSection: text(), /** Top-level app page (inbox/agents/skills/mcp-servers/command-center/home). - * Null = a canvas / task / channel / blank tab. */ + * Null = a canvas / task / channel / blank pane. */ appView: text(), - /** Gap-spaced ordering key within a window. */ - position: integer().notNull(), - /** Reserved/unwired. Opaque JSON for future per-tab state. */ + /** Reserved/unwired. Opaque JSON for future per-pane state. */ scrollState: text({ mode: "json" }).$type(), /** Epoch ms. */ createdAt: integer().notNull(), lastActiveAt: integer().notNull(), }, - (t) => [index("browser_tabs_window_idx").on(t.windowId)], + (t) => [index("browser_panes_tab_idx").on(t.tabId)], ); diff --git a/packages/workspace-server/src/services/browser-tabs/schemas.ts b/packages/workspace-server/src/services/browser-tabs/schemas.ts index e565a1b89a..9c436e7ce1 100644 --- a/packages/workspace-server/src/services/browser-tabs/schemas.ts +++ b/packages/workspace-server/src/services/browser-tabs/schemas.ts @@ -1,37 +1,49 @@ -import { type TabsSnapshot, tabsSnapshotSchema } from "@posthog/shared"; +import { + splitDropDirectionSchema, + type TabsSnapshot, + tabsSnapshotSchema, +} from "@posthog/shared"; import { z } from "zod"; -/** tRPC output: the full durable tab/window snapshot. */ +/** tRPC output: the full durable tab/window/pane snapshot. */ export const browserTabsSnapshotOutput = tabsSnapshotSchema; -export const openOrFocusTabInput = z.object({ - windowId: z.string(), +const paneIdentityFields = { dashboardId: z.string().nullable().default(null), taskId: z.string().nullable().default(null), channelId: z.string().nullable().default(null), channelSection: z.string().nullable().default(null), appView: z.string().nullable().default(null), - // Renderer-minted id for a tab this call may create, so the optimistic local - // apply and the persisted state agree on the id (local-first tab sync). +}; + +export const openOrFocusTabInput = z.object({ + windowId: z.string(), + ...paneIdentityFields, + // Renderer-minted ids for the tab/pane this call may create, so the + // optimistic local apply and the persisted state agree (local-first sync). tabId: z.string().optional(), + paneId: z.string().optional(), }); export const newBlankTabInput = z.object({ windowId: z.string(), - // Renderer-minted id (see openOrFocusTabInput.tabId). + // Renderer-minted ids (see openOrFocusTabInput). tabId: z.string().optional(), + paneId: z.string().optional(), }); -export const setTabTargetInput = z.object({ - tabId: z.string(), - dashboardId: z.string().nullable().default(null), - taskId: z.string().nullable().default(null), - channelId: z.string().nullable().default(null), - channelSection: z.string().nullable().default(null), - appView: z.string().nullable().default(null), +export const setPaneTargetInput = z.object({ + paneId: z.string(), + ...paneIdentityFields, }); -export const closeTabInput = z.object({ tabId: z.string() }); +export const closeTabInput = z.object({ + tabId: z.string(), + // Renderer-minted ids for the blank tab backfilled when this close empties + // the primary window's strip. + blankTabId: z.string().optional(), + blankPaneId: z.string().optional(), +}); export const closeTabsInput = z.object({ tabIds: z.array(z.string()), @@ -40,6 +52,20 @@ export const closeTabsInput = z.object({ focusTabId: z.string().nullable().default(null), }); +export const closePaneInput = z.object({ + tabId: z.string(), + paneId: z.string(), +}); + +export const mergeTabIntoTabInput = z.object({ + windowId: z.string(), + sourceTabId: z.string(), + targetTabId: z.string(), + /** Pane the drop zone belonged to; null = the layout root (edge drop). */ + targetPaneId: z.string().nullable().default(null), + direction: splitDropDirectionSchema, +}); + export const setTabOrderInput = z.object({ windowId: z.string(), tabIds: z.array(z.string()), @@ -50,6 +76,18 @@ export const setActiveTabInput = z.object({ tabId: z.string().nullable(), }); +export const setFocusedPaneInput = z.object({ + tabId: z.string(), + paneId: z.string(), +}); + +export const setPaneSizesInput = z.object({ + tabId: z.string(), + /** Child-index path from the layout root to the split being resized. */ + path: z.array(z.number().int().min(0)), + sizes: z.array(z.number()), +}); + export enum BrowserTabsEvent { SnapshotChange = "snapshotChange", } diff --git a/packages/workspace-server/src/services/browser-tabs/service.test.ts b/packages/workspace-server/src/services/browser-tabs/service.test.ts index b050510152..d7afaa5e41 100644 --- a/packages/workspace-server/src/services/browser-tabs/service.test.ts +++ b/packages/workspace-server/src/services/browser-tabs/service.test.ts @@ -1,23 +1,34 @@ -import type { BrowserTab, TabsSnapshot } from "@posthog/shared"; +import type { BrowserPane, BrowserTab, TabsSnapshot } from "@posthog/shared"; import { describe, expect, it } from "vitest"; import type { IBrowserTabsRepository } from "../../db/repositories/browser-tabs-repository"; import { BrowserTabsService } from "./service"; -const blankTab = (overrides: Partial = {}): BrowserTab => ({ - id: "tab-1", +const blankPane = (overrides: Partial = {}): BrowserPane => ({ + id: "pane-1", + tabId: "tab-1", windowId: "win-1", dashboardId: null, taskId: null, channelId: null, channelSection: null, appView: null, - position: 100, scrollState: null, createdAt: 1, lastActiveAt: 1, ...overrides, }); +const blankTab = (overrides: Partial = {}): BrowserTab => ({ + id: "tab-1", + windowId: "win-1", + layout: { type: "leaf", paneId: "pane-1" }, + focusedPaneId: "pane-1", + position: 100, + createdAt: 1, + lastActiveAt: 1, + ...overrides, +}); + class FakeRepository implements IBrowserTabsRepository { saved: TabsSnapshot | null = null; constructor(private readonly initial: TabsSnapshot) {} @@ -31,7 +42,7 @@ class FakeRepository implements IBrowserTabsRepository { describe("BrowserTabsService boot invariants", () => { const bootCases: [string, TabsSnapshot][] = [ - ["empty store", { windows: [], tabs: [] }], + ["empty store", { windows: [], tabs: [], panes: [] }], [ "window persisted with zero tabs", { @@ -39,12 +50,23 @@ describe("BrowserTabsService boot invariants", () => { { id: "win-1", isPrimary: true, bounds: null, activeTabId: null }, ], tabs: [], + panes: [], + }, + ], + [ + "tab persisted with zero panes", + { + windows: [ + { id: "win-1", isPrimary: true, bounds: null, activeTabId: "tab-1" }, + ], + tabs: [blankTab()], + panes: [], }, ], ]; it.each(bootCases)( - "seeds a primary window with at least one tab (%s)", + "seeds a primary window with at least one tab and pane (%s)", (_name, initial) => { const repo = new FakeRepository(initial); const service = new BrowserTabsService(repo); @@ -54,6 +76,9 @@ describe("BrowserTabsService boot invariants", () => { expect(primary).toBeDefined(); expect(snapshot.tabs.length).toBeGreaterThanOrEqual(1); expect(snapshot.tabs[0]?.windowId).toBe(primary?.id); + expect( + snapshot.panes.filter((p) => p.tabId === snapshot.tabs[0]?.id), + ).not.toHaveLength(0); // The healed snapshot is persisted so the invariant survives a restart. expect(repo.saved).toEqual(snapshot); }, @@ -65,6 +90,7 @@ describe("BrowserTabsService boot invariants", () => { { id: "win-1", isPrimary: true, bounds: null, activeTabId: "tab-1" }, ], tabs: [blankTab()], + panes: [blankPane()], }; const repo = new FakeRepository(initial); const service = new BrowserTabsService(repo); @@ -76,7 +102,7 @@ describe("BrowserTabsService boot invariants", () => { describe("BrowserTabsService window-id healing", () => { it("newBlankTab lands in the primary window when the given id is unknown", () => { - const repo = new FakeRepository({ windows: [], tabs: [] }); + const repo = new FakeRepository({ windows: [], tabs: [], panes: [] }); const service = new BrowserTabsService(repo); const primaryId = service.getPrimaryWindowId(); @@ -90,7 +116,7 @@ describe("BrowserTabsService window-id healing", () => { }); it("openOrFocus lands in the primary window when the given id is unknown", () => { - const repo = new FakeRepository({ windows: [], tabs: [] }); + const repo = new FakeRepository({ windows: [], tabs: [], panes: [] }); const service = new BrowserTabsService(repo); const primaryId = service.getPrimaryWindowId(); @@ -99,10 +125,36 @@ describe("BrowserTabsService window-id healing", () => { dashboardId: "dash-1", taskId: null, channelId: null, + channelSection: null, + appView: null, tabId: "tab-open", + paneId: "pane-open", }); const created = snapshot.tabs.find((t) => t.id === "tab-open"); expect(created?.windowId).toBe(primaryId); + expect(snapshot.panes.find((p) => p.id === "pane-open")?.windowId).toBe( + primaryId, + ); + }); +}); + +describe("BrowserTabsService replay idempotency", () => { + it("newBlankTab with an already-persisted minted id is a no-op", () => { + const repo = new FakeRepository({ windows: [], tabs: [], panes: [] }); + const service = new BrowserTabsService(repo); + const primaryId = service.getPrimaryWindowId(); + + const first = service.newBlankTab({ + windowId: primaryId, + tabId: "tab-new", + paneId: "pane-new", + }); + const replay = service.newBlankTab({ + windowId: primaryId, + tabId: "tab-new", + paneId: "pane-new", + }); + expect(replay).toBe(first); }); }); diff --git a/packages/workspace-server/src/services/browser-tabs/service.ts b/packages/workspace-server/src/services/browser-tabs/service.ts index 3b3e33e813..e7d8cbb76b 100644 --- a/packages/workspace-server/src/services/browser-tabs/service.ts +++ b/packages/workspace-server/src/services/browser-tabs/service.ts @@ -1,14 +1,18 @@ import { - type BrowserWindow, + closePane, closeTab, closeTabs, + ensureSnapshotIntegrity, + mergeTabIntoTab, newBlankTab, openOrFocusTab, + type SplitDropDirection, + setFocusedPane, + setPaneSizes, + setPaneTarget, setTabOrder, - setTabTarget, setWindowActiveTab, type TabsSnapshot, - type TabTarget, TypedEventEmitter, } from "@posthog/shared"; import { inject, injectable } from "inversify"; @@ -19,31 +23,53 @@ import { BrowserTabsEvent, type BrowserTabsEvents } from "./schemas"; const makeId = () => crypto.randomUUID(); const now = () => Date.now(); +/** Identity fields a pane can point at, as the tRPC inputs deliver them. */ +type PaneIdentityInput = { + dashboardId: string | null; + taskId: string | null; + channelId: string | null; + channelSection: string | null; + appView: string | null; +}; + export interface IBrowserTabsService { getSnapshot(): TabsSnapshot; getPrimaryWindowId(): string; openOrFocus( - input: TabTarget & { + input: PaneIdentityInput & { windowId: string; - channelId: string | null; - channelSection?: string | null; - appView?: string | null; tabId?: string; + paneId?: string; }, ): TabsSnapshot; - newBlankTab(input: { windowId: string; tabId?: string }): TabsSnapshot; - setTabTarget( - input: TabTarget & { - tabId: string; - channelId: string | null; - channelSection?: string | null; - appView?: string | null; - }, - ): TabsSnapshot; - close(tabId: string): TabsSnapshot; + newBlankTab(input: { + windowId: string; + tabId?: string; + paneId?: string; + }): TabsSnapshot; + setPaneTarget(input: PaneIdentityInput & { paneId: string }): TabsSnapshot; + close(input: { + tabId: string; + blankTabId?: string; + blankPaneId?: string; + }): TabsSnapshot; closeMany(tabIds: string[], focusTabId?: string | null): TabsSnapshot; + closePane(input: { tabId: string; paneId: string }): TabsSnapshot; + mergeTabIntoTab(input: { + windowId: string; + sourceTabId: string; + targetTabId: string; + targetPaneId: string | null; + direction: SplitDropDirection; + }): TabsSnapshot; setOrder(input: { windowId: string; tabIds: string[] }): TabsSnapshot; setActiveTab(input: { windowId: string; tabId: string | null }): TabsSnapshot; + setFocusedPane(input: { tabId: string; paneId: string }): TabsSnapshot; + setPaneSizes(input: { + tabId: string; + path: number[]; + sizes: number[]; + }): TabsSnapshot; snapshotChangeEvents( signal: AbortSignal | undefined, ): AsyncIterable; @@ -70,30 +96,12 @@ export class BrowserTabsService super(); this.setMaxListeners(0); const loaded = this.repo.load(); - const seeded = this.ensureAtLeastOneTab(this.ensurePrimaryWindow(loaded)); - if (seeded !== loaded) this.repo.save(seeded); - this.snapshot = seeded; - } - - /** Guarantee a primary window exists so the first open has somewhere to land. */ - private ensurePrimaryWindow(snapshot: TabsSnapshot): TabsSnapshot { - if (snapshot.windows.some((w) => w.isPrimary)) return snapshot; - const primary: BrowserWindow = { - id: makeId(), - isPrimary: true, - bounds: null, - activeTabId: null, - }; - return { ...snapshot, windows: [primary, ...snapshot.windows] }; - } - - /** The strip must never boot empty: seed a blank tab when none survived. */ - private ensureAtLeastOneTab(snapshot: TabsSnapshot): TabsSnapshot { - if (snapshot.tabs.length > 0) return snapshot; - const primary = snapshot.windows.find((w) => w.isPrimary); - if (!primary) return snapshot; - return newBlankTab(snapshot, { windowId: primary.id, makeId, now }) - .snapshot; + // Heal every boot-time invariant in one pass (primary window exists, + // >= 1 tab, layout↔pane bijection, valid focus). Re-persist only when + // something actually changed (reference inequality). + const healed = ensureSnapshotIntegrity(loaded, { makeId, now }); + if (healed !== loaded) this.repo.save(healed); + this.snapshot = healed; } /** Creation targets heal a stale window id (a mirror seeded before a schema @@ -121,60 +129,80 @@ export class BrowserTabsService } openOrFocus( - input: TabTarget & { + input: PaneIdentityInput & { windowId: string; - channelId: string | null; - channelSection?: string | null; - appView?: string | null; tabId?: string; + paneId?: string; }, ): TabsSnapshot { - // Honor a renderer-minted id so the caller's optimistic apply and this - // persisted state agree on the id. Dedup-by-identity still applies first, - // so a replay of the same open focuses the existing tab. - const providedId = input.tabId; + // Renderer-minted ids ride through so the caller's optimistic apply and + // this persisted state agree. Dedup-by-identity still applies first, so a + // replay of the same open focuses the existing tab. const { snapshot } = openOrFocusTab(this.snapshot, { ...input, windowId: this.resolveWindowId(input.windowId), - makeId: providedId ? () => providedId : makeId, + makeId, now, }); return this.commit(snapshot); } - newBlankTab(input: { windowId: string; tabId?: string }): TabsSnapshot { - const providedId = input.tabId; + newBlankTab(input: { + windowId: string; + tabId?: string; + paneId?: string; + }): TabsSnapshot { // Idempotent on the renderer-minted id: a replay of the same call (blank // tabs have no identity to dedup on) must not append a second tab. - if (providedId && this.snapshot.tabs.some((t) => t.id === providedId)) { + if (input.tabId && this.snapshot.tabs.some((t) => t.id === input.tabId)) { return this.snapshot; } const { snapshot } = newBlankTab(this.snapshot, { windowId: this.resolveWindowId(input.windowId), - makeId: providedId ? () => providedId : makeId, + tabId: input.tabId, + paneId: input.paneId, + makeId, now, }); return this.commit(snapshot); } - setTabTarget( - input: TabTarget & { - tabId: string; - channelId: string | null; - channelSection?: string | null; - appView?: string | null; - }, - ): TabsSnapshot { - return this.commit(setTabTarget(this.snapshot, { ...input, now })); + setPaneTarget(input: PaneIdentityInput & { paneId: string }): TabsSnapshot { + return this.commit(setPaneTarget(this.snapshot, { ...input, now })); } - close(tabId: string): TabsSnapshot { - const { snapshot } = closeTab(this.snapshot, tabId); + close(input: { + tabId: string; + blankTabId?: string; + blankPaneId?: string; + }): TabsSnapshot { + const { snapshot } = closeTab(this.snapshot, input.tabId, { + makeId, + now, + blankTabId: input.blankTabId, + blankPaneId: input.blankPaneId, + }); return this.commit(snapshot); } closeMany(tabIds: string[], focusTabId?: string | null): TabsSnapshot { - return this.commit(closeTabs(this.snapshot, tabIds, focusTabId)); + return this.commit( + closeTabs(this.snapshot, tabIds, focusTabId, { makeId, now }), + ); + } + + closePane(input: { tabId: string; paneId: string }): TabsSnapshot { + return this.commit(closePane(this.snapshot, input.tabId, input.paneId)); + } + + mergeTabIntoTab(input: { + windowId: string; + sourceTabId: string; + targetTabId: string; + targetPaneId: string | null; + direction: SplitDropDirection; + }): TabsSnapshot { + return this.commit(mergeTabIntoTab(this.snapshot, { ...input, now })); } setOrder(input: { windowId: string; tabIds: string[] }): TabsSnapshot { @@ -187,7 +215,7 @@ export class BrowserTabsService windowId: string; tabId: string | null; }): TabsSnapshot { - // Validated: a tabId that doesn't exist in the window (a stale history tag + // Validated: a tabId that doesn't exist in the window (a stale mirror // replayed after the tab closed) is ignored rather than persisted as a // dangling activeTabId — that dangle makes every later navigation look like // "no active tab" and silently open new tabs. @@ -196,6 +224,27 @@ export class BrowserTabsService return this.commit(next); } + setFocusedPane(input: { tabId: string; paneId: string }): TabsSnapshot { + const next = setFocusedPane(this.snapshot, input.tabId, input.paneId); + if (next === this.snapshot) return this.snapshot; + return this.commit(next); + } + + setPaneSizes(input: { + tabId: string; + path: number[]; + sizes: number[]; + }): TabsSnapshot { + const next = setPaneSizes( + this.snapshot, + input.tabId, + input.path, + input.sizes, + ); + if (next === this.snapshot) return this.snapshot; + return this.commit(next); + } + snapshotChangeEvents( signal: AbortSignal | undefined, ): AsyncIterable { From 3ed4d8501c9ee934396123c7c6e088a407d89488 Mon Sep 17 00:00:00 2001 From: Adam Leith Date: Sun, 12 Jul 2026 12:15:44 +0100 Subject: [PATCH 3/7] feat(browser-tabs): gate split-pane merge behind feature flag MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tab-owned panes ships dark: posthog-code-tab-split-panes (default off) must be enabled for pill drags to arm merge drop zones or the drag-end merge branch to run. Reorder is unaffected; existing multi-pane tabs still render and close. No DEV override — off means off in dev too. Co-Authored-By: Claude Fable 5 --- packages/shared/src/flags.ts | 3 +++ packages/ui/src/features/browser-tabs/AGENTS.md | 6 ++++++ .../src/features/browser-tabs/BrowserTabsDnd.tsx | 14 ++++++++++++-- 3 files changed, 21 insertions(+), 2 deletions(-) diff --git a/packages/shared/src/flags.ts b/packages/shared/src/flags.ts index 76b9613c23..837ce8b3c6 100644 --- a/packages/shared/src/flags.ts +++ b/packages/shared/src/flags.ts @@ -12,3 +12,6 @@ export const DISCOVERY_RUN_FLAG = "posthog-code-discovery-run"; export const PROJECT_BLUEBIRD_FLAG = "project-bluebird"; export const TASKS_PREWARM_SANDBOX_FLAG = "tasks-prewarm-sandbox"; export const GLM_MODEL_FLAG = "posthog-code-glm-model"; +// Gates tab-owned split panes: dragging a tab pill onto pane/root drop zones +// to merge it into the active tab as a split. Off = no drop zones, no merge. +export const TAB_SPLIT_PANES_FLAG = "posthog-code-tab-split-panes"; diff --git a/packages/ui/src/features/browser-tabs/AGENTS.md b/packages/ui/src/features/browser-tabs/AGENTS.md index cf598fbe11..6c5fbf49ef 100644 --- a/packages/ui/src/features/browser-tabs/AGENTS.md +++ b/packages/ui/src/features/browser-tabs/AGENTS.md @@ -100,6 +100,12 @@ pane { tabId, identity… } — content unit; one router each Cmd+1-9 switch (channels on). All are store mutations — no navigation. ### Splitting (merge a tab into another) +- **Flag-gated** by `TAB_SPLIT_PANES_FLAG` (`posthog-code-tab-split-panes`, + `@posthog/shared`). Off (default): `BrowserTabsDnd` never arms + `paneDragStore`, so no merge zones mount and pill drags can only reorder; + the drag-end merge branch is also guarded. Existing multi-pane tabs still + render (and can be closed back to single-pane) — only creating new splits + is gated. No `import.meta.env.DEV` override — off means off in dev too. - **Drag tab B's pill onto the content area** while tab A is active: 5 zones per pane (4 edges = split that side, center = merge to the right of that pane) + 4 thin root-edge zones (split at the layout root). Drop → B's whole diff --git a/packages/ui/src/features/browser-tabs/BrowserTabsDnd.tsx b/packages/ui/src/features/browser-tabs/BrowserTabsDnd.tsx index 438c893ba6..b3c21776ad 100644 --- a/packages/ui/src/features/browser-tabs/BrowserTabsDnd.tsx +++ b/packages/ui/src/features/browser-tabs/BrowserTabsDnd.tsx @@ -6,9 +6,11 @@ import { primaryWindow, type SplitDropDirection, setTabOrder, + TAB_SPLIT_PANES_FLAG, } from "@posthog/shared"; import { useMutation } from "@tanstack/react-query"; import { type ReactNode, useRef } from "react"; +import { useFeatureFlag } from "../feature-flags/useFeatureFlag"; import { reorderWithinGroup, storedOrderIds } from "./displayOrder"; import { usePaneDragStore } from "./panes/paneDragStore"; import { usePinnedTabsStore } from "./pinnedTabsStore"; @@ -44,6 +46,9 @@ export function BrowserTabsDndProvider({ children }: { children: ReactNode }) { const mergeMutation = useMutation( trpc.browserTabs.mergeTabIntoTab.mutationOptions(), ); + // Split panes are flag-gated: when off, merge drop zones never arm, so a + // pill drag can only reorder within the strip. + const splitPanesEnabled = useFeatureFlag(TAB_SPLIT_PANES_FLAG); /** Stored order captured at dragstart — used to skip a no-op persist. */ const initialOrder = useRef(null); @@ -58,7 +63,7 @@ export function BrowserTabsDndProvider({ children }: { children: ReactNode }) { useTabReorderStore.getState().setPreviewOrder(order); // Arm the merge drop zones — but never for the ACTIVE tab's own pill // (a tab can't merge into itself; PaneChrome double-checks per pane). - if (src.tabId !== win.activeTabId) { + if (splitPanesEnabled && src.tabId !== win.activeTabId) { usePaneDragStore.getState().setDrag({ tabId: src.tabId }); } }; @@ -122,7 +127,12 @@ export function BrowserTabsDndProvider({ children }: { children: ReactNode }) { direction: tgt.zone as SplitDropDirection, } : null; - if (merge && win.activeTabId && win.activeTabId !== tabId) { + if ( + splitPanesEnabled && + merge && + win.activeTabId && + win.activeTabId !== tabId + ) { const targetTabId = win.activeTabId; const input = { windowId: win.id, From e58879552d6a182b681a3cbfa02f39854b27b874 Mon Sep 17 00:00:00 2001 From: Adam Leith Date: Sun, 12 Jul 2026 12:16:31 +0100 Subject: [PATCH 4/7] fix(db): skip destructive 0021 re-run when schema is ahead Re-running 0021 on a post-0021 schema threw (backfill reads browser_tabs.dashboard_id, which 0021 drops) after its DROP TABLE wiped live browser_panes rows. Guarded migrations probe the pre-migration schema; a failed probe records the migration as applied and skips it, so a ledger-behind-schema DB boots intact. Co-Authored-By: Claude Fable 5 --- .../workspace-server/src/db/migrate.test.ts | 25 +++++++++++++++++++ packages/workspace-server/src/db/migrate.ts | 21 ++++++++++++++++ 2 files changed, 46 insertions(+) diff --git a/packages/workspace-server/src/db/migrate.test.ts b/packages/workspace-server/src/db/migrate.test.ts index 279aa23140..51fd148c7a 100644 --- a/packages/workspace-server/src/db/migrate.test.ts +++ b/packages/workspace-server/src/db/migrate.test.ts @@ -87,6 +87,31 @@ describe("runMigrations", () => { expect(ledgerMax(sqlite)).toBe(latest); }); + // 0021 is guarded: re-running it would wipe browser_panes and its backfill + // reads columns the migration itself drops. With the ledger behind the + // schema it must be recorded and skipped, not re-executed. + it("skips a re-run of 0021 without wiping pane rows", () => { + const PANES_TIMESTAMP = 1783800060350; + runMigrations(sqlite, MIGRATIONS_FOLDER); + sqlite.exec(` + INSERT INTO browser_windows (id, created_at, updated_at) VALUES ('w1', 0, 0); + INSERT INTO browser_tabs (id, window_id, layout, focused_pane_id, position, created_at, last_active_at) + VALUES ('t1', 'w1', '{"type":"leaf","paneId":"p1"}', 'p1', 0, 0, 0); + INSERT INTO browser_panes (id, tab_id, window_id, created_at, last_active_at) + VALUES ('p1', 't1', 'w1', 0, 0); + `); + + sqlite + .prepare("DELETE FROM __drizzle_migrations WHERE created_at = ?") + .run(PANES_TIMESTAMP); + expect(ledgerHas(sqlite, PANES_TIMESTAMP)).toBe(false); + + expect(() => runMigrations(sqlite, MIGRATIONS_FOLDER)).not.toThrow(); + const paneIds = sqlite.prepare("SELECT id FROM browser_panes").all(); + expect(paneIds).toEqual([{ id: "p1" }]); + expect(ledgerHas(sqlite, PANES_TIMESTAMP)).toBe(true); + }); + it("re-applies a missing mid-history ledger entry", () => { runMigrations(sqlite, MIGRATIONS_FOLDER); diff --git a/packages/workspace-server/src/db/migrate.ts b/packages/workspace-server/src/db/migrate.ts index ba6acc1b9d..4d79ad51fc 100644 --- a/packages/workspace-server/src/db/migrate.ts +++ b/packages/workspace-server/src/db/migrate.ts @@ -18,6 +18,18 @@ const BEST_EFFORT_MIGRATIONS = new Set([ 1783685997328, // 0020_repair_browser_tabs_schema ]); +// Guarded migrations are DESTRUCTIVE to re-run, so when the schema is already +// past a migration's precondition (ledger behind schema — an amended migration +// on a branch) the probe fails and the migration is recorded as applied and +// skipped instead of re-executed. Probes must be reads that succeed on the +// pre-migration schema and fail after it. +const MIGRATION_GUARDS = new Map([ + // 0021_tab_owned_panes: its backfill reads browser_tabs.dashboard_id (gone + // after the migration), and its DROP TABLE browser_panes would wipe live + // pane rows on a re-run. + [1783800060350, "SELECT dashboard_id FROM browser_tabs LIMIT 0"], +]); + export function runMigrations( sqlite: SqliteDatabase, migrationsFolder: string, @@ -44,6 +56,15 @@ export function runMigrations( if (appliedTimestamps.has(migration.folderMillis)) { continue; } + const guard = MIGRATION_GUARDS.get(migration.folderMillis); + if (guard) { + try { + sqlite.exec(guard); + } catch { + recordMigration.run(migration.hash, migration.folderMillis); + continue; + } + } const bestEffort = BEST_EFFORT_MIGRATIONS.has(migration.folderMillis); for (const statement of migration.sql) { try { From 52d9d40920251e7bfbcdefaa90dac33f4804a933 Mon Sep 17 00:00:00 2001 From: Adam Leith Date: Sun, 12 Jul 2026 12:16:32 +0100 Subject: [PATCH 5/7] fix(router): devtools follow pane focus, unconditional hook useSyncExternalStore lived in an anonymous default: arrow (rules-of-hooks error) and only subscribed to router registration, so focus moves between registered panes left the devtools on the stale router. Use useFocusedPaneRouter in a named component: registry + tabs-mirror subscription. Co-Authored-By: Claude Fable 5 --- packages/ui/src/router/RouterDevtools.tsx | 39 ++++++++++------------- 1 file changed, 17 insertions(+), 22 deletions(-) diff --git a/packages/ui/src/router/RouterDevtools.tsx b/packages/ui/src/router/RouterDevtools.tsx index 737abec816..8dc29a7e75 100644 --- a/packages/ui/src/router/RouterDevtools.tsx +++ b/packages/ui/src/router/RouterDevtools.tsx @@ -1,8 +1,5 @@ -import { lazy, Suspense, useSyncExternalStore } from "react"; -import { - getFocusedRouterOrNull, - subscribePaneRouters, -} from "./paneRouterRegistry"; +import { lazy, Suspense } from "react"; +import { useFocusedPaneRouter } from "./useFocusedPaneRouter"; // The genuine floating TanStack Router devtools overlay (drawer, drag-to-resize, // in-panel close button, open animation — the exact UI), but with its floating @@ -54,23 +51,21 @@ const LazyRouterDevtools = import.meta.env.DEV const { TanStackRouterDevtools } = await import( "@tanstack/react-router-devtools" ); - return { - default: () => { - // Attach to the FOCUSED pane's router (there is one per pane now), - // re-resolving when routers register/unregister. - const router = useSyncExternalStore( - subscribePaneRouters, - getFocusedRouterOrNull, - ); - if (!router) return null; - return ( - - ); - }, - }; + // Named so rules-of-hooks recognizes it as a component (an anonymous + // `default:` arrow is not). + function FocusedPaneDevtools() { + // Attach to the FOCUSED pane's router (there is one per pane now), + // re-resolving on focus moves and router register/unregister. + const router = useFocusedPaneRouter(); + if (!router) return null; + return ( + + ); + } + return { default: FocusedPaneDevtools }; }) : () => null; From 1a8e99f754cd919f89f7a35c196882c438fa6ffc Mon Sep 17 00:00:00 2001 From: Adam Leith Date: Sun, 12 Jul 2026 12:16:33 +0100 Subject: [PATCH 6/7] chore(skills): remove quill-code skill Co-Authored-By: Claude Fable 5 --- .claude/skills/quill-code/SKILL.md | 84 ------------------- .../skills/quill-code/scripts/sync-quill.sh | 49 ----------- 2 files changed, 133 deletions(-) delete mode 100644 .claude/skills/quill-code/SKILL.md delete mode 100755 .claude/skills/quill-code/scripts/sync-quill.sh diff --git a/.claude/skills/quill-code/SKILL.md b/.claude/skills/quill-code/SKILL.md deleted file mode 100644 index 5a9afef1f3..0000000000 --- a/.claude/skills/quill-code/SKILL.md +++ /dev/null @@ -1,84 +0,0 @@ ---- -name: quill-code -description: Edit the @posthog/quill design system locally and consume the change in this repo (posthog-code) before it is published to npm. Use when changing quill components/primitives/tokens, when a quill change must be tested inside the Code app, or when the user mentions quill, the design system, the .local-quill tarball, or the @posthog/quill pnpm override. ---- - -# quill-code - -`@posthog/quill` is **not** in this repo. It is a published catalog dependency whose -source lives in the main PostHog monorepo at `../posthog/packages/quill`. To test an -unpublished quill change inside this repo (posthog-code), you build quill, pack it to a -tarball, and point a pnpm `overrides` entry at that tarball. This is a **temporary -local-dev** state — revert before merging (see below). - -## Quill layout (where to edit) - -`../posthog/packages/quill` is the **workspace** (`@posthog/quill-workspace`). It -contains sub-packages, each a layer of the design system: - -- `packages/primitives/src` — base components (Card, Badge, Button, Progress, …) -- `packages/components/src` — composed components (DataTable, DateTimePicker, Metric) -- `packages/blocks/src` — **product-level blocks** (e.g. `ExperimentCard`). Add the - file here and export it from `packages/blocks/src/index.ts`. -- `packages/quill/src` — the **aggregate** that re-exports all layers as `@posthog/quill`. - -A new export in any sub-package flows to `@posthog/quill` automatically on build. - -## The loop (every quill change) - -1. Edit/add the component in the right sub-package (above) and export it from that - package's `src/index.ts`. -2. Re-sync into this repo: - - ```bash - .claude/skills/quill-code/scripts/sync-quill.sh - ``` - - This builds the **whole quill workspace** (recursive `pnpm build` at the workspace - root — it rebuilds the sub-packages BEFORE the aggregate bundles them), packs it - into `.local-quill/` as a content-hashed `posthog-quill-local-.tgz`, rewrites - the override line in `pnpm-workspace.yaml`, deletes stale tarballs, and runs - `pnpm install`. - - > Building only the aggregate (`packages/quill/packages/quill`) re-bundles the - > sub-packages' **stale** `dist/`, so edits to primitives/components/blocks are - > silently dropped. Always build at the workspace root — the script does this. -3. Verify in the Code app (`pnpm dev`, or the `test-electron-app` skill). Repeat from 1. - -After every quill edit you **must** re-run the sync — the app consumes the tarball, not -the quill source, so unsynced edits are invisible here. - -If quill lives elsewhere, set `QUILL_DIR=/abs/path/to/posthog/packages/quill/packages/quill`. - -## Why a tarball, not `link:` - -`link:` symlinks into the mono's `node_modules` and drags in its **React 18** types, -colliding with this repo's **React 19** (dual-React → broken typecheck + -invalid-hook-call at runtime). The tarball is copied into this repo's store and deduped -against React 19. The filename is **content-hashed** because pnpm pins a tarball by -integrity, so a stable filename gets cached stale across re-syncs. - -## The override (what the script rewrites) - -In `pnpm-workspace.yaml`, under `overrides:`: - -```yaml -'@posthog/quill': file:./.local-quill/posthog-quill-local-.tgz -``` - -There is also a permanent pin you should leave alone: - -```yaml -'@posthog/quill>@base-ui/react': ^1.3.0 # quill ships a broken catalog: dep; do not remove -``` - -## Reverting (before merge) - -The override is local-dev only. Once the quill change is published to npm: - -1. Bump the catalog version in `pnpm-workspace.yaml` (`'@posthog/quill': 0.3.0-beta.x`) - to the published version. -2. Restore the override line to point back at the catalog, or remove the `file:` override. -3. `pnpm install`. - -Do not commit a `file:./.local-quill/...` override or the `.local-quill/` tarballs. diff --git a/.claude/skills/quill-code/scripts/sync-quill.sh b/.claude/skills/quill-code/scripts/sync-quill.sh deleted file mode 100755 index 6e12ef3806..0000000000 --- a/.claude/skills/quill-code/scripts/sync-quill.sh +++ /dev/null @@ -1,49 +0,0 @@ -#!/usr/bin/env bash -# Build local @posthog/quill, pack it to a content-hashed tarball, point the -# pnpm override at it, and reinstall. See SKILL.md for the why. -set -euo pipefail - -# code repo root = dir containing pnpm-workspace.yaml, walking up from this script -CODE_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && while [ ! -f pnpm-workspace.yaml ] && [ "$PWD" != / ]; do cd ..; done && pwd)" -[ -f "$CODE_ROOT/pnpm-workspace.yaml" ] || { echo "could not find pnpm-workspace.yaml above $0" >&2; exit 1; } - -QUILL_DIR="${QUILL_DIR:-$CODE_ROOT/../posthog/packages/quill/packages/quill}" -[ -d "$QUILL_DIR" ] || { echo "quill source not found: $QUILL_DIR (set QUILL_DIR=...)" >&2; exit 1; } - -# The quill workspace root (@posthog/quill-workspace) is two levels up from the -# aggregate package. We MUST build there: its `pnpm build` runs the recursive -# `pnpm -r --filter '@posthog/quill-*' --filter '@posthog/quill' build`, which -# rebuilds the sub-packages (primitives, components, blocks, ...) BEFORE the -# aggregate bundles them. Building inside QUILL_DIR alone only re-bundles the -# sub-packages' STALE dist, so edits to blocks/primitives/etc. are silently lost. -QUILL_WS="${QUILL_WS:-$(cd "$QUILL_DIR/../.." && pwd)}" - -DEST="$CODE_ROOT/.local-quill" -WS="$CODE_ROOT/pnpm-workspace.yaml" - -echo "==> building quill workspace in $QUILL_WS" -( cd "$QUILL_WS" && pnpm build ) - -echo "==> packing tarball -> $DEST" -mkdir -p "$DEST" -( cd "$QUILL_DIR" && npm pack --pack-destination "$DEST" >/dev/null ) - -RAW="$(ls -t "$DEST"/posthog-quill-*.tgz | grep -v -- '-local-' | head -1)" -[ -n "$RAW" ] || { echo "npm pack produced no posthog-quill-*.tgz" >&2; exit 1; } - -HASH="$(md5 -q "$RAW" | cut -c1-8)" -HASHED="posthog-quill-local-$HASH.tgz" -cp "$RAW" "$DEST/$HASHED" -rm -f "$RAW" -# drop stale local tarballs so the pnpm store can't resolve an old integrity -find "$DEST" -name 'posthog-quill-local-*.tgz' ! -name "$HASHED" -delete - -echo "==> pointing override at .local-quill/$HASHED" -# single override line: '@posthog/quill': file:./.local-quill/... -sed -i '' -E "s#('@posthog/quill': file:\./\.local-quill/)[^']*#\1$HASHED#" "$WS" -grep -q "$HASHED" "$WS" || { echo "failed to rewrite override line in $WS" >&2; exit 1; } - -echo "==> pnpm install" -( cd "$CODE_ROOT" && pnpm install ) - -echo "==> done. @posthog/quill now resolves to .local-quill/$HASHED" From f38555a7cc63e4f15904497037f20f7bdd2c99e5 Mon Sep 17 00:00:00 2001 From: Adam Leith Date: Tue, 14 Jul 2026 10:59:06 +0100 Subject: [PATCH 7/7] fix(panes): scope header breadcrumb store by pane The header store was a single global slot; with one router per pane every pane's header bar rendered the last writer's breadcrumb, so navigating the focused pane relabelled all panes to the same channel (and an unmounting pane's cleanup wiped a sibling's header). Key content by paneId, resolved from the pane router's root context; readers select their own pane's entry. Co-Authored-By: Claude Fable 5 --- .../canvas/components/WebsiteLayout.tsx | 4 +- packages/ui/src/hooks/useSetHeaderContent.ts | 10 +++-- packages/ui/src/shell/ContentHeader.tsx | 4 +- packages/ui/src/shell/headerStore.ts | 39 +++++++++++++++++-- 4 files changed, 45 insertions(+), 12 deletions(-) diff --git a/packages/ui/src/features/canvas/components/WebsiteLayout.tsx b/packages/ui/src/features/canvas/components/WebsiteLayout.tsx index 4a97189dc7..b896fc5fd7 100644 --- a/packages/ui/src/features/canvas/components/WebsiteLayout.tsx +++ b/packages/ui/src/features/canvas/components/WebsiteLayout.tsx @@ -34,7 +34,7 @@ import { import { copyCanvasLink } from "@posthog/ui/features/canvas/utils/copyCanvasLink"; import { toast } from "@posthog/ui/primitives/toast"; import { track } from "@posthog/ui/shell/analytics"; -import { useHeaderStore } from "@posthog/ui/shell/headerStore"; +import { usePaneHeaderContent } from "@posthog/ui/shell/headerStore"; import { Box, Flex } from "@radix-ui/themes"; import { Outlet, @@ -295,7 +295,7 @@ export function WebsiteLayout() { // Command Center) are channel-less and push their title into the shared // header store. With no code HeaderRow here, surface that title in this bar so // the mirrored pages read the same as in Code. - const headerContent = useHeaderStore((s) => s.content); + const headerContent = usePaneHeaderContent(); const channelId = params.channelId; const dashboardId = params.dashboardId; diff --git a/packages/ui/src/hooks/useSetHeaderContent.ts b/packages/ui/src/hooks/useSetHeaderContent.ts index 09516a9a49..867cfce5ad 100644 --- a/packages/ui/src/hooks/useSetHeaderContent.ts +++ b/packages/ui/src/hooks/useSetHeaderContent.ts @@ -1,14 +1,16 @@ -import { useHeaderStore } from "@posthog/ui/shell/headerStore"; +import { useHeaderStore, usePaneId } from "@posthog/ui/shell/headerStore"; import { type ReactNode, useLayoutEffect } from "react"; export function useSetHeaderContent(content: ReactNode) { + const paneId = usePaneId(); const setContent = useHeaderStore((state) => state.setContent); useLayoutEffect(() => { - setContent(content); + if (!paneId) return; + setContent(paneId, content); return () => { - setContent(null); + setContent(paneId, null); }; - }, [content, setContent]); + }, [paneId, content, setContent]); } diff --git a/packages/ui/src/shell/ContentHeader.tsx b/packages/ui/src/shell/ContentHeader.tsx index c66e3728fa..cfcddc6c1d 100644 --- a/packages/ui/src/shell/ContentHeader.tsx +++ b/packages/ui/src/shell/ContentHeader.tsx @@ -22,7 +22,7 @@ import { useTasks } from "@posthog/ui/features/tasks/useTasks"; import { useWorkspace } from "@posthog/ui/features/workspace/useWorkspace"; import { Tooltip } from "@posthog/ui/primitives/Tooltip"; import { useAppView } from "@posthog/ui/router/useAppView"; -import { useHeaderStore } from "@posthog/ui/shell/headerStore"; +import { usePaneHeaderContent } from "@posthog/ui/shell/headerStore"; import { Flex } from "@radix-ui/themes"; import { useState } from "react"; @@ -135,7 +135,7 @@ function TaskDiffStatsBadge({ task }: { task: Task }) { // The /website space keeps its own header (WebsiteLayout), so this is mounted // only outside it. export function ContentHeader() { - const content = useHeaderStore((state) => state.content); + const content = usePaneHeaderContent(); const view = useAppView(); const activeTaskId = view.type === "task-detail" ? view.taskId : undefined; diff --git a/packages/ui/src/shell/headerStore.ts b/packages/ui/src/shell/headerStore.ts index 8778cddaeb..9c57aa63cc 100644 --- a/packages/ui/src/shell/headerStore.ts +++ b/packages/ui/src/shell/headerStore.ts @@ -1,12 +1,43 @@ +import { useRouteContext } from "@tanstack/react-router"; import type { ReactNode } from "react"; import { create } from "zustand"; +// Header content is keyed by PANE: every pane hosts its own router (tab-owned +// split panes), and each pane's scene pushes its own breadcrumb. A single +// global slot would make every pane's header bar render the last writer's +// content (and an unmounting pane's cleanup would wipe a sibling's header). interface HeaderStore { - content: ReactNode; - setContent: (content: ReactNode) => void; + contentByPane: Record; + setContent: (paneId: string, content: ReactNode) => void; } export const useHeaderStore = create((set) => ({ - content: null, - setContent: (content) => set({ content }), + contentByPane: {}, + setContent: (paneId, content) => + set((state) => { + if (content == null) { + if (!(paneId in state.contentByPane)) return state; + const { [paneId]: _removed, ...rest } = state.contentByPane; + return { contentByPane: rest }; + } + return { contentByPane: { ...state.contentByPane, [paneId]: content } }; + }), })); + +/** The pane this component renders in — from the pane router's root context. + * Null outside a pane router (unit tests, Storybook). */ +export function usePaneId(): string | null { + const context = useRouteContext({ strict: false }) as { + paneId?: string; + } | null; + return context?.paneId ?? null; +} + +/** This pane's header content (the "# channel / leaf" breadcrumb its scene + * pushed), resolved against the calling component's own pane router. */ +export function usePaneHeaderContent(): ReactNode { + const paneId = usePaneId(); + return useHeaderStore((state) => + paneId ? (state.contentByPane[paneId] ?? null) : null, + ); +}