diff --git a/FORK.md b/FORK.md index 8e2de2494c96..e1ef5fd9d31a 100644 --- a/FORK.md +++ b/FORK.md @@ -106,7 +106,7 @@ Capture jobs that first lose the workspace-mutation race or fail can be re-enque The fork supports up to four visible thread panes in an equal full-height grid, with focused-pane routing, a shared toolbar, one right panel, and controlled ownership of global keyboard, preview, and composer behavior. Panes can be opened or detached from the sidebar, safely reconcile draft promotion/archive/deletion, and animate layout changes without leaving stale portals or listeners. -Split membership is persisted as multiple ordered local groups with active state and stable group colors. Selecting any member restores its group; the current and legacy sidebars preserve group tinting, support pane and thread drag placement, and show complete left/right drop intent. The group tint is textured with an inline desaturated SVG grain so the color-coded row reads as a surface rather than a flat slab. The grain is a masked pseudo-element rather than a second background layer, because background layers cannot carry their own mask and unmasked grain also covers the gradient's faded tail, flattening the row back into a uniform slab; the overlay shares the tint's fade axis and stops short of its extent so the texture is gone before the color is. Group hues carry a hue-dependent chroma: a flat chroma across the wheel does not read as a flat saturation, since yellow-green renders at full strength while red and blue are gamut-clipped, so chroma eases down around yellow-green. Rows supply only the hue and that chroma, leaving the stylesheet to compose per-theme lightness and strength — light mode takes a deeper, less translucent tint because the translucency that reads as a clear band on the near-black sidebar washes out against zinc-50. The current sidebar also names the other panes in each grouped thread's details tooltip, keeps displayed panes visible when settled or snoozed shelves are collapsed, and preserves split actions in its context menu. Right-panel ownership remains useful when focus moves to a pane with no surface of its own. +Split membership is persisted as multiple ordered local groups with active state and stable group colors. Selecting any member restores its group; the current and legacy sidebars preserve group tinting, support pane and thread drag placement, and show complete left/right drop intent. The workspace now supports up to ten panes in a 5-column by 2-row grid, with explicit bottom-row placement and persisted layout state. The group tint is textured with an inline desaturated SVG grain so the color-coded row reads as a surface rather than a flat slab. The grain is a masked pseudo-element rather than a second background layer, because background layers cannot carry their own mask and unmasked grain also covers the gradient's faded tail, flattening the row back into a uniform slab; the overlay shares the tint's fade axis and stops short of its extent so the texture is gone before the color is. Group hues carry a hue-dependent chroma: a flat chroma across the wheel does not read as a flat saturation, since yellow-green renders at full strength while red and blue are gamut-clipped, so chroma eases down around yellow-green. Rows supply only the hue and that chroma, leaving the stylesheet to compose per-theme lightness and strength — light mode takes a deeper, less translucent tint because the translucency that reads as a clear band on the near-black sidebar washes out against zinc-50. The current sidebar also names the other panes in each grouped thread's details tooltip, keeps displayed panes visible when settled or snoozed shelves are collapsed, and preserves split actions in its context menu. Right-panel ownership remains useful when focus moves to a pane with no surface of its own. **Implementation evidence:** `apps/web/src/splitViewStore.ts`, `apps/web/src/splitViewDrag.ts`, `apps/web/src/components/{SplitThreadWorkspace,SplitPaneDropHint,Sidebar,LegacySidebar,RightPanelTabs,ChatView,chat/ChatComposer}.tsx`, `apps/web/src/components/Sidebar.logic.ts`, `apps/web/src/index.css`, `apps/web/src/hooks/useThreadActions.ts`, and the chat routes. diff --git a/apps/web/src/components/SplitPaneDropHint.tsx b/apps/web/src/components/SplitPaneDropHint.tsx index 488cee232cf4..63bee2a408e3 100644 --- a/apps/web/src/components/SplitPaneDropHint.tsx +++ b/apps/web/src/components/SplitPaneDropHint.tsx @@ -2,7 +2,7 @@ import type { CSSProperties } from "react"; import { cn } from "~/lib/utils"; -export type SplitPaneDropSide = "before" | "after"; +export type SplitPaneDropSide = "before" | "after" | "below"; export function SplitPaneDropHint(props: { position: SplitPaneDropSide; @@ -11,6 +11,7 @@ export function SplitPaneDropHint(props: { }) { const { className, position, style } = props; const isLeft = position === "before"; + const isBelow = position === "below"; return (
- Drop to place on the {isLeft ? "left" : "right"} + Drop to place on the {isBelow ? "bottom" : isLeft ? "left" : "right"}
diff --git a/apps/web/src/components/SplitThreadWorkspace.test.tsx b/apps/web/src/components/SplitThreadWorkspace.test.tsx index fe72bd9e9dbd..055c07ca1eaf 100644 --- a/apps/web/src/components/SplitThreadWorkspace.test.tsx +++ b/apps/web/src/components/SplitThreadWorkspace.test.tsx @@ -1,3 +1,5 @@ +import { scopedThreadKey, scopeThreadRef } from "@t3tools/client-runtime/environment"; +import { EnvironmentId, ThreadId } from "@t3tools/contracts"; import { describe, expect, it, vi } from "vite-plus/test"; vi.mock("./ChatView", () => ({ default: () => null })); @@ -6,25 +8,88 @@ vi.mock("./DiffWorkerPoolProvider", () => ({ DiffWorkerPoolProvider: () => null import { resolveSplitPaneDropPosition, resolveSplitRightPanelOwner, - splitThreadGridColumnClassName, + splitThreadGridClassName, + splitThreadPaneGridClassName, } from "./SplitThreadWorkspace"; +import { resolveSplitPaneSlots } from "../splitViewStore"; -describe("splitThreadGridColumnClassName", () => { - it("keeps two through four panes in a single explicit column row", () => { - const classes = [2, 3, 4].map(splitThreadGridColumnClassName); +describe("splitThreadGridClassName", () => { + const refs = (count: number) => + Array.from({ length: count }, (_unused, index) => + scopeThreadRef(EnvironmentId.make("env"), ThreadId.make(`thread-${index + 1}`)), + ); - expect(classes).toEqual(["grid-cols-2", "grid-cols-3", "grid-cols-4"]); - expect(classes.join(" ")).not.toContain("grid-rows"); + it("uses one row until a column is stacked", () => { + expect(splitThreadGridClassName(resolveSplitPaneSlots(refs(3)))).toBe( + "grid-cols-3 grid-rows-1", + ); }); + it("keeps five columns and opens a second row once panes overflow", () => { + expect(splitThreadGridClassName(resolveSplitPaneSlots(refs(7)))).toBe( + "grid-cols-5 grid-rows-2", + ); + }); + + it("gives a column both cells until something is stacked under it", () => { + const paneRefs = refs(6); + const slots = resolveSplitPaneSlots(paneRefs); + + expect(slots.map(splitThreadPaneGridClassName)).toEqual([ + "col-start-1 row-start-1", + "col-start-2 row-start-1 row-span-2", + "col-start-3 row-start-1 row-span-2", + "col-start-4 row-start-1 row-span-2", + "col-start-5 row-start-1 row-span-2", + "col-start-1 row-start-2", + ]); + }); + + it("stacks a flagged pane under the column it follows", () => { + const paneRefs = refs(4); + const slots = resolveSplitPaneSlots(paneRefs, [scopedThreadKey(paneRefs[2]!)]); + + expect(slots.map(splitThreadPaneGridClassName)).toEqual([ + "col-start-1 row-start-1 row-span-2", + "col-start-2 row-start-1", + "col-start-2 row-start-2", + "col-start-3 row-start-1 row-span-2", + ]); + }); +}); + +describe("resolveSplitPaneDropPosition with a stackable column", () => { + const element = { + getBoundingClientRect: () => + ({ left: 100, width: 240, top: 0, height: 300 }) as ReturnType< + HTMLElement["getBoundingClientRect"] + >, + }; + + it("splits the column when the pointer sits in the lower third", () => { + expect(resolveSplitPaneDropPosition({ clientX: 180, clientY: 260 }, element, true)).toBe( + "below", + ); + }); + + it("keeps left and right placement for a column that is already stacked", () => { + expect(resolveSplitPaneDropPosition({ clientX: 180, clientY: 260 }, element, false)).toBe( + "before", + ); + }); +}); + +describe("splitThreadWorkspace drop placement", () => { it("uses the pane midpoint to describe before and after drop locations", () => { const element = { getBoundingClientRect: () => - ({ left: 100, width: 240 }) as ReturnType, + ({ left: 100, width: 240, top: 0, height: 300 }) as ReturnType< + HTMLElement["getBoundingClientRect"] + >, }; - expect(resolveSplitPaneDropPosition({ clientX: 180 }, element)).toBe("before"); - expect(resolveSplitPaneDropPosition({ clientX: 260 }, element)).toBe("after"); + expect(resolveSplitPaneDropPosition({ clientX: 180, clientY: 10 }, element)).toBe("before"); + expect(resolveSplitPaneDropPosition({ clientX: 260, clientY: 10 }, element)).toBe("after"); }); it("keeps an open right panel visible when a pane without one becomes active", () => { diff --git a/apps/web/src/components/SplitThreadWorkspace.tsx b/apps/web/src/components/SplitThreadWorkspace.tsx index 23e703ba7328..fb40fb7e730c 100644 --- a/apps/web/src/components/SplitThreadWorkspace.tsx +++ b/apps/web/src/components/SplitThreadWorkspace.tsx @@ -23,19 +23,22 @@ import { endSplitThreadDrag, hasSplitThreadDrag, readSplitThreadDrag, - subscribePointerSplitDropTarget, } from "../splitViewDrag"; import { + canStackUnderSplitPane, findSplitViewGroupForThread, MAX_SPLIT_VIEW_PANES, + resolveSplitPaneSlots, + selectActiveSplitGroup, selectActiveSplitPane, selectIsSplitViewActive, selectSplitPaneRefs, + SPLIT_VIEW_MAX_COLUMNS, useSplitViewStore, + type SplitPaneSlot, } from "../splitViewStore"; import { useRightPanelStore } from "../rightPanelStore"; import { - useAllEnvironmentProjectSnapshotsReady, useProject, useThread, useThreadRefs, @@ -63,7 +66,7 @@ interface DraftPane { threadRef: ScopedThreadRef; } -export type SplitPaneDropPosition = "before" | "after"; +export type SplitPaneDropPosition = "before" | "after" | "below"; interface SplitPaneDropTarget { paneKey: string; @@ -75,22 +78,47 @@ const SPLIT_PANE_ANIMATION_OPTIONS = { easing: "ease-out", } as const; -export function splitThreadGridColumnClassName(paneCount: number): string { - switch (paneCount) { - case 4: - return "grid-cols-4"; - case 3: - return "grid-cols-3"; - default: - return "grid-cols-2"; - } +const COLUMN_START_CLASSES = [ + "col-start-1", + "col-start-2", + "col-start-3", + "col-start-4", + "col-start-5", +] as const; +const COLUMN_COUNT_CLASSES = [ + "grid-cols-1", + "grid-cols-2", + "grid-cols-3", + "grid-cols-4", + "grid-cols-5", +] as const; + +/** Grid track classes for the whole pane grid. */ +export function splitThreadGridClassName(slots: readonly SplitPaneSlot[]): string { + const columns = slots.reduce((count, slot) => Math.max(count, slot.column + 1), 1); + const rows = slots.some((slot) => slot.row === 1) ? "grid-rows-2" : "grid-rows-1"; + return `${COLUMN_COUNT_CLASSES[Math.min(columns, SPLIT_VIEW_MAX_COLUMNS) - 1]} ${rows}`; +} + +/** Placement classes for one pane: its column, its row, and whether it fills both. */ +export function splitThreadPaneGridClassName(slot: SplitPaneSlot): string { + const column = COLUMN_START_CLASSES[Math.min(slot.column, SPLIT_VIEW_MAX_COLUMNS - 1)]; + if (slot.spansBothRows) return `${column} row-start-1 row-span-2`; + return `${column} ${slot.row === 0 ? "row-start-1" : "row-start-2"}`; } export function resolveSplitPaneDropPosition( - event: Pick, "clientX">, + event: Pick, "clientX" | "clientY">, element: Pick, + canStackUnder = false, ): SplitPaneDropPosition { const bounds = element.getBoundingClientRect(); + // The lower third of a full-height pane splits its column instead of opening + // a new one; a column that is already stacked has no room, so it keeps the + // plain left/right behaviour over its whole height. + if (canStackUnder && event.clientY >= bounds.top + bounds.height * (2 / 3)) { + return "below"; + } return event.clientX < bounds.left + bounds.width / 2 ? "before" : "after"; } @@ -134,12 +162,18 @@ function SplitThreadPane(props: { active: boolean; isRightPanelOwner: boolean; paneIndex: number; + gridClassName: string; + canStackUnder: boolean; dropTarget: SplitPaneDropTarget | null; canPlaceThread: (threadRef: ScopedThreadRef | null) => boolean; onActivate: () => void; onDetach: () => void; onDropTargetChange: (target: SplitPaneDropTarget | null) => void; - onPlaceThread: (threadRef: ScopedThreadRef, insertionIndex: number) => void; + onPlaceThread: ( + threadRef: ScopedThreadRef, + insertionIndex: number, + stackUnderColumn?: boolean, + ) => void; headerSlot: HTMLElement | null; rightPanelSlot: HTMLElement | null; }) { @@ -154,6 +188,8 @@ function SplitThreadPane(props: { onDetach, onDropTargetChange, onPlaceThread, + canStackUnder, + gridClassName, paneIndex, rightPanelSlot, threadRef, @@ -181,15 +217,14 @@ function SplitThreadPane(props: { return; } + finalizePromotedDraftThreadByRef(threadRef); if (active) { void navigate({ to: "/$environmentId/$threadId", params: buildThreadRouteParams(threadRef), replace: true, - }).then(() => finalizePromotedDraftThreadByRef(threadRef)); - return; + }); } - finalizePromotedDraftThreadByRef(threadRef); }, [active, draftPane, navigate, serverThreadStarted, threadRef]); const handleDragStart = useCallback( @@ -214,10 +249,10 @@ function SplitThreadPane(props: { event.dataTransfer.dropEffect = "move"; onDropTargetChange({ paneKey: threadKey, - position: resolveSplitPaneDropPosition(event, event.currentTarget), + position: resolveSplitPaneDropPosition(event, event.currentTarget, canStackUnder), }); }, - [canPlaceThread, onDropTargetChange, threadKey], + [canPlaceThread, canStackUnder, onDropTargetChange, threadKey], ); const handleDragLeave = useCallback( (event: DragEvent) => { @@ -235,10 +270,10 @@ function SplitThreadPane(props: { event.stopPropagation(); onDropTargetChange(null); if (!draggedRef || !canPlaceThread(draggedRef)) return; - const position = resolveSplitPaneDropPosition(event, event.currentTarget); - onPlaceThread(draggedRef, paneIndex + (position === "before" ? 0 : 1)); + const position = resolveSplitPaneDropPosition(event, event.currentTarget, canStackUnder); + onPlaceThread(draggedRef, paneIndex + (position === "before" ? 0 : 1), position === "below"); }, - [canPlaceThread, onDropTargetChange, onPlaceThread, paneIndex], + [canPlaceThread, canStackUnder, onDropTargetChange, onPlaceThread, paneIndex], ); if (!serverThread && !draftPane) { @@ -251,10 +286,10 @@ function SplitThreadPane(props: {
selectActiveSplitGroup(state)?.bottomPaneKeys, + ); + const paneSlots = useMemo( + () => resolveSplitPaneSlots(paneRefs, bottomPaneKeys ?? []), + [bottomPaneKeys, paneRefs], + ); const activePane = useSplitViewStore(selectActiveSplitPane); const splitActive = useSplitViewStore(selectIsSplitViewActive); - const allProjectSnapshotsReady = useAllEnvironmentProjectSnapshotsReady(); const serverThreadRefs = useThreadRefs(); const draftThreadsById = useComposerDraftStore((state) => state.draftThreadsByThreadKey); const [headerSlot, setHeaderSlot] = useState(null); @@ -351,17 +392,6 @@ export function SplitThreadWorkspace({ currentRouteRef }: SplitThreadWorkspacePr const rightPanelStateByThreadKey = useRightPanelStore((state) => state.byThreadKey); const [rightPanelOwnerThreadKey, setRightPanelOwnerThreadKey] = useState(null); const [dropTarget, setDropTarget] = useState(null); - useEffect( - () => - subscribePointerSplitDropTarget((target) => { - setDropTarget( - target?.kind === "split" && target.paneKey !== null && target.position !== null - ? { paneKey: target.paneKey, position: target.position } - : null, - ); - }), - [], - ); const paneGridAnimationRef = useRef<{ node: HTMLElement; controller: ReturnType; @@ -408,13 +438,15 @@ export function SplitThreadWorkspace({ currentRouteRef }: SplitThreadWorkspacePr ); useEffect(() => { - // A partially loaded environment list cannot prove that a saved pane is gone. - if (!allProjectSnapshotsReady) return; + // Persisted layouts are restored before thread bootstrap completes. Waiting + // for at least one known thread prevents that short loading interval from + // erasing a valid saved layout. + if (availablePaneRefs.length === 0) return; const fallback = useSplitViewStore.getState().reconcilePanes(availablePaneRefs); if (fallback) { navigateToPane(fallback); } - }, [allProjectSnapshotsReady, availablePaneRefs, navigateToPane]); + }, [availablePaneRefs, navigateToPane]); const activePaneKey = activePane ? scopedThreadKey(activePane) : null; useEffect(() => { @@ -500,19 +532,19 @@ export function SplitThreadWorkspace({ currentRouteRef }: SplitThreadWorkspacePr [paneRefs], ); const placeThread = useCallback( - (threadRef: ScopedThreadRef, insertionIndex: number) => { + (threadRef: ScopedThreadRef, insertionIndex: number, stackUnderColumn = false) => { const state = useSplitViewStore.getState(); const activePaneRefs = selectSplitPaneRefs(state); const isExistingPane = activePaneRefs.some( (paneRef) => scopedThreadKey(paneRef) === scopedThreadKey(threadRef), ); if (isExistingPane) { - state.movePane(threadRef, insertionIndex); + state.movePane(threadRef, insertionIndex, stackUnderColumn); return; } const anchor = currentRouteRef ?? activePaneRefs[0]; if (!anchor) return; - state.placePane(anchor, threadRef, insertionIndex); + state.placePane(anchor, threadRef, insertionIndex, stackUnderColumn); }, [currentRouteRef], ); @@ -577,7 +609,7 @@ export function SplitThreadWorkspace({ currentRouteRef }: SplitThreadWorkspacePr ref={attachPaneGridAutoAnimateRef} className={cn( "grid min-h-0 min-w-0 flex-1 gap-px overflow-hidden bg-border", - splitThreadGridColumnClassName(paneRefs.length), + splitThreadGridClassName(paneSlots), )} data-split-thread-grid onDragEnd={() => { @@ -597,6 +629,15 @@ export function SplitThreadWorkspace({ currentRouteRef }: SplitThreadWorkspacePr active={threadKey === activePaneKey} isRightPanelOwner={threadKey === rightPanelOwnerThreadKey} paneIndex={paneIndex} + gridClassName={splitThreadPaneGridClassName( + paneSlots[paneIndex] ?? { + paneRef: threadRef, + column: Math.min(paneIndex, SPLIT_VIEW_MAX_COLUMNS - 1), + row: 0, + spansBothRows: true, + }, + )} + canStackUnder={canStackUnderSplitPane(paneRefs, bottomPaneKeys, paneIndex)} dropTarget={dropTarget} canPlaceThread={canPlaceThread} onActivate={() => activatePane(threadRef)} diff --git a/apps/web/src/splitViewStore.test.ts b/apps/web/src/splitViewStore.test.ts index da8992141a1e..2baf0ba5872a 100644 --- a/apps/web/src/splitViewStore.test.ts +++ b/apps/web/src/splitViewStore.test.ts @@ -17,7 +17,9 @@ const THREAD_A = scopeThreadRef("environment-a" as never, ThreadId.make("thread- const THREAD_B = scopeThreadRef("environment-a" as never, ThreadId.make("thread-b")); const THREAD_C = scopeThreadRef("environment-a" as never, ThreadId.make("thread-c")); const THREAD_D = scopeThreadRef("environment-a" as never, ThreadId.make("thread-d")); -const THREAD_E = scopeThreadRef("environment-a" as never, ThreadId.make("thread-e")); +const CAPACITY_THREADS = Array.from({ length: MAX_SPLIT_VIEW_PANES + 1 }, (_, index) => + scopeThreadRef("environment-a" as never, ThreadId.make(`capacity-thread-${index + 1}`)), +); const THREAD_A_IN_OTHER_ENVIRONMENT = scopeThreadRef( "environment-b" as never, ThreadId.make("thread-a"), @@ -108,15 +110,25 @@ describe("splitViewStore", () => { it("caps new panes while still allowing existing panes to be focused", () => { const store = useSplitViewStore.getState(); - store.openInSplit(THREAD_A, THREAD_B); - store.openInSplit(THREAD_B, THREAD_C); - store.openInSplit(THREAD_C, THREAD_D); + const [firstThread, secondThread, ...remainingThreads] = CAPACITY_THREADS; + expect(firstThread).toBeDefined(); + expect(secondThread).toBeDefined(); + store.openInSplit(firstThread!, secondThread!); + for (const threadRef of remainingThreads.slice(0, MAX_SPLIT_VIEW_PANES - 2)) { + const currentRef = selectActiveSplitPane(useSplitViewStore.getState()); + expect(currentRef).not.toBeNull(); + store.openInSplit(currentRef!, threadRef); + } expect(paneKeys()).toHaveLength(MAX_SPLIT_VIEW_PANES); - expect(store.openInSplit(THREAD_D, THREAD_E)).toBe("at-capacity"); - expect(store.openInSplit(THREAD_D, THREAD_B)).toBe("activated"); + const activeRef = selectActiveSplitPane(useSplitViewStore.getState()); + const overflowThread = remainingThreads[MAX_SPLIT_VIEW_PANES - 2]; + expect(activeRef).not.toBeNull(); + expect(overflowThread).toBeDefined(); + expect(store.openInSplit(activeRef!, overflowThread!)).toBe("at-capacity"); + expect(store.openInSplit(activeRef!, secondThread!)).toBe("activated"); expect(paneKeys()).toHaveLength(MAX_SPLIT_VIEW_PANES); - expect(useSplitViewStore.getState().activeThreadKey).toBe(scopedThreadKey(THREAD_B)); + expect(useSplitViewStore.getState().activeThreadKey).toBe(scopedThreadKey(secondThread!)); }); it("selects the next pane when detaching the active pane", () => { diff --git a/apps/web/src/splitViewStore.ts b/apps/web/src/splitViewStore.ts index cf2502ee7639..c972ee17ad98 100644 --- a/apps/web/src/splitViewStore.ts +++ b/apps/web/src/splitViewStore.ts @@ -5,7 +5,7 @@ import { createJSONStorage, persist } from "zustand/middleware"; import { resolveStorage } from "./lib/storage"; -export const MAX_SPLIT_VIEW_PANES = 4; +export const MAX_SPLIT_VIEW_PANES = 10; export const SPLIT_VIEW_STORAGE_KEY = "t3code:split-view:v1"; export const SPLIT_VIEW_STORAGE_VERSION = 2; @@ -18,6 +18,13 @@ export interface SplitViewGroup { colorHue: number; /** Ordered thread refs rendered from left to right. */ paneRefs: readonly ScopedThreadRef[]; + /** + * Pane keys that sit in the bottom row. A bottom pane belongs to the column + * of the nearest preceding top pane, so the flat order still describes the + * whole layout and a column keeps its full height until something is + * actually stacked under it. + */ + bottomPaneKeys?: readonly string[]; } export interface SplitViewState { @@ -39,9 +46,14 @@ interface SplitViewStore extends SplitViewState { currentRef: ScopedThreadRef, targetRef: ScopedThreadRef, insertionIndex: number, + stackUnderColumn?: boolean, ) => OpenInSplitResult; /** Reorder a pane in the active split group. */ - movePane: (threadRef: ScopedThreadRef, insertionIndex: number) => void; + movePane: ( + threadRef: ScopedThreadRef, + insertionIndex: number, + stackUnderColumn?: boolean, + ) => void; /** Open the saved group containing this thread and focus the requested pane. */ resumeSplit: (threadRef: ScopedThreadRef) => void; /** Switch to a normal thread while retaining every saved group. */ @@ -135,6 +147,85 @@ export function isSplitViewActive(paneRefs: readonly ScopedThreadRef[]): boolean return paneRefs.length >= 2; } +export const SPLIT_VIEW_MAX_COLUMNS = 5; + +export interface SplitPaneSlot { + readonly paneRef: ScopedThreadRef; + /** Zero-based grid column. */ + readonly column: number; + /** Zero-based grid row: 0 is the top row, 1 the stacked one. */ + readonly row: 0 | 1; + /** True while nothing is stacked under this pane, so it fills both rows. */ + readonly spansBothRows: boolean; +} + +/** + * Place every pane on the 5x2 grid. Panes flagged bottom stack under the column + * opened by the nearest preceding top pane; a top pane with nothing under it + * keeps both of its cells. Panes past the fifth column fall into the leftmost + * free bottom cell, which is what the plain "add another pane" path relies on. + */ +export function resolveSplitPaneSlots( + paneRefs: readonly ScopedThreadRef[], + bottomPaneKeys: readonly string[] = [], +): readonly SplitPaneSlot[] { + const stacked = new Set(bottomPaneKeys); + const bottomByColumn = new Map(); + const slots: Array<{ paneRef: ScopedThreadRef; column: number; row: 0 | 1 }> = []; + let column = -1; + + const takeFreeBottomColumn = (): number | null => { + for (let candidate = 0; candidate <= column; candidate += 1) { + if (!bottomByColumn.has(candidate)) return candidate; + } + return null; + }; + + for (const paneRef of paneRefs) { + const key = scopedThreadKey(paneRef); + const wantsBottom = stacked.has(key) && column >= 0 && !bottomByColumn.has(column); + if (wantsBottom) { + bottomByColumn.set(column, slots.length); + slots.push({ paneRef, column, row: 1 }); + continue; + } + if (column + 1 < SPLIT_VIEW_MAX_COLUMNS) { + column += 1; + slots.push({ paneRef, column, row: 0 }); + continue; + } + const overflowColumn = takeFreeBottomColumn(); + if (overflowColumn === null) continue; + bottomByColumn.set(overflowColumn, slots.length); + slots.push({ paneRef, column: overflowColumn, row: 1 }); + } + + return slots.map((slot) => ({ + ...slot, + spansBothRows: slot.row === 0 && !bottomByColumn.has(slot.column), + })); +} + +/** Whether another pane can be stacked under the column this pane opens. */ +export function canStackUnderSplitPane( + paneRefs: readonly ScopedThreadRef[], + bottomPaneKeys: readonly string[] | undefined, + paneIndex: number, +): boolean { + const slots = resolveSplitPaneSlots(paneRefs, bottomPaneKeys ?? []); + const slot = slots[paneIndex]; + return slot !== undefined && slot.row === 0 && slot.spansBothRows; +} + +function retainBottomPaneKeys( + paneRefs: readonly ScopedThreadRef[], + bottomPaneKeys: readonly string[] | undefined, +): readonly string[] { + if (!bottomPaneKeys || bottomPaneKeys.length === 0) return []; + const paneKeys = new Set(paneRefs.map(scopedThreadKey)); + return bottomPaneKeys.filter((key) => paneKeys.has(key)); +} + export function selectSplitViewGroups(state: SplitViewState): readonly SplitViewGroup[] { return state.groups; } @@ -229,7 +320,12 @@ export function migratePersistedSplitViewState(persistedState: unknown): SplitVi const groups: SplitViewGroup[] = []; for (const [index, rawGroup] of rawGroups.entries()) { if (!rawGroup || typeof rawGroup !== "object") continue; - const candidate = rawGroup as { id?: unknown; colorHue?: unknown; paneRefs?: unknown }; + const candidate = rawGroup as { + id?: unknown; + colorHue?: unknown; + paneRefs?: unknown; + bottomPaneKeys?: unknown; + }; const paneRefs = parsePersistedPaneRefs(candidate.paneRefs).filter( (paneRef) => !usedThreadKeys.has(scopedThreadKey(paneRef)), ); @@ -250,7 +346,13 @@ export function migratePersistedSplitViewState(persistedState: unknown): SplitVi groups.every((group) => circularHueDistance(group.colorHue, requestedColorHue) >= 1) ? requestedColorHue : nextGroupColorHue(groups); - groups.push({ id, colorHue, paneRefs }); + const bottomPaneKeys = retainBottomPaneKeys( + paneRefs, + Array.isArray(candidate.bottomPaneKeys) + ? candidate.bottomPaneKeys.filter((key): key is string => typeof key === "string") + : [], + ); + groups.push({ id, colorHue, paneRefs, bottomPaneKeys }); } const legacyGroupId = @@ -299,7 +401,7 @@ export const useSplitViewStore = create()( return get().placePane(currentRef, targetRef, Number.MAX_SAFE_INTEGER); }, - placePane: (currentRef, targetRef, insertionIndex) => { + placePane: (currentRef, targetRef, insertionIndex, stackUnderColumn = false) => { const state = get(); const currentKey = scopedThreadKey(currentRef); const targetKey = scopedThreadKey(targetRef); @@ -341,10 +443,21 @@ export const useSplitViewStore = create()( const destinationId = destinationGroup?.id ?? nextGroupId(state.groups); const destinationColorHue = destinationGroup?.colorHue ?? nextGroupColorHue(state.groups); + const nextBottomPaneKeys = (() => { + const inherited = retainBottomPaneKeys( + panesWithoutTarget, + destinationGroup?.bottomPaneKeys, + ).filter((key) => key !== targetKey); + return stackUnderColumn ? [...inherited, targetKey] : inherited; + })(); const groups: SplitViewGroup[] = []; for (const group of state.groups) { if (group.id === destinationId) { - groups.push({ ...group, paneRefs: panesWithoutTarget }); + groups.push({ + ...group, + paneRefs: panesWithoutTarget, + bottomPaneKeys: nextBottomPaneKeys, + }); continue; } const filteredPaneRefs = group.paneRefs.filter( @@ -354,7 +467,11 @@ export const useSplitViewStore = create()( groups.push( filteredPaneRefs.length === group.paneRefs.length ? group - : { ...group, paneRefs: filteredPaneRefs }, + : { + ...group, + paneRefs: filteredPaneRefs, + bottomPaneKeys: retainBottomPaneKeys(filteredPaneRefs, group.bottomPaneKeys), + }, ); } } @@ -363,6 +480,7 @@ export const useSplitViewStore = create()( id: destinationId, colorHue: destinationColorHue, paneRefs: panesWithoutTarget, + bottomPaneKeys: nextBottomPaneKeys, }); } @@ -375,7 +493,7 @@ export const useSplitViewStore = create()( return existingIndex === targetIndex ? "activated" : "opened"; }, - movePane: (threadRef, insertionIndex) => { + movePane: (threadRef, insertionIndex, stackUnderColumn = false) => { const state = get(); const activeGroup = selectActiveSplitGroup(state); if (!activeGroup) return; @@ -392,7 +510,15 @@ export const useSplitViewStore = create()( paneRefs.length, ); paneRefs.splice(targetIndex, 0, movedPane); + const bottomPaneKeys = (() => { + const inherited = retainBottomPaneKeys(paneRefs, activeGroup.bottomPaneKeys).filter( + (key) => key !== threadKey, + ); + return stackUnderColumn ? [...inherited, threadKey] : inherited; + })(); + const wasStacked = (activeGroup.bottomPaneKeys ?? []).includes(threadKey); if ( + wasStacked === stackUnderColumn && paneRefs.every( (paneRef, index) => scopedThreadKey(paneRef) === scopedThreadKey(activeGroup.paneRefs[index]!), @@ -402,7 +528,7 @@ export const useSplitViewStore = create()( } set({ groups: state.groups.map((group) => - group.id === activeGroup.id ? { ...group, paneRefs } : group, + group.id === activeGroup.id ? { ...group, paneRefs, bottomPaneKeys } : group, ), }); }, @@ -481,7 +607,13 @@ export const useSplitViewStore = create()( : state.activeThreadKey; set({ groups: state.groups.map((candidate) => - candidate.id === group.id ? { ...candidate, paneRefs } : candidate, + candidate.id === group.id + ? { + ...candidate, + paneRefs, + bottomPaneKeys: retainBottomPaneKeys(paneRefs, candidate.bottomPaneKeys), + } + : candidate, ), ...(isActiveGroup ? { activeThreadKey, pendingNavigationThreadKey: activeThreadKey }