Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion FORK.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
23 changes: 16 additions & 7 deletions apps/web/src/components/SplitPaneDropHint.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -11,6 +11,7 @@ export function SplitPaneDropHint(props: {
}) {
const { className, position, style } = props;
const isLeft = position === "before";
const isBelow = position === "below";

return (
<div
Expand All @@ -19,25 +20,33 @@ export function SplitPaneDropHint(props: {
"pointer-events-none absolute z-40 overflow-hidden bg-primary/[0.045] ring-2 ring-inset ring-primary/75",
className,
)}
data-split-pane-drop-hint={isLeft ? "left" : "right"}
data-split-pane-drop-hint={isBelow ? "below" : isLeft ? "left" : "right"}
style={style}
>
<div
aria-hidden
className={cn("absolute inset-y-0 w-1/2 bg-primary/10", isLeft ? "left-0" : "right-0")}
className={cn(
"absolute bg-primary/10",
isBelow ? "inset-x-0 bottom-0 h-1/2" : "inset-y-0 w-1/2",
isBelow ? "" : isLeft ? "left-0" : "right-0",
)}
/>
<div
aria-hidden
className="absolute inset-y-0 left-1/2 border-l-2 border-dashed border-primary/70"
className={cn(
"absolute border-dashed border-primary/70",
isBelow ? "inset-x-0 top-1/2 border-t-2" : "inset-y-0 left-1/2 border-l-2",
)}
/>
<div
className={cn(
"absolute inset-y-0 flex w-1/2 items-center justify-center px-3",
isLeft ? "left-0" : "right-0",
"absolute flex items-center justify-center px-3",
isBelow ? "inset-x-0 bottom-0 h-1/2" : "inset-y-0 w-1/2",
isBelow ? "" : isLeft ? "left-0" : "right-0",
)}
>
<span className="rounded-md bg-primary px-3 py-1.5 text-center text-xs font-medium text-primary-foreground shadow-sm">
Drop to place on the {isLeft ? "left" : "right"}
Drop to place on the {isBelow ? "bottom" : isLeft ? "left" : "right"}
</span>
</div>
</div>
Expand Down
83 changes: 74 additions & 9 deletions apps/web/src/components/SplitThreadWorkspace.test.tsx
Original file line number Diff line number Diff line change
@@ -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 }));
Expand All @@ -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<HTMLElement["getBoundingClientRect"]>,
({ 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", () => {
Expand Down
Loading
Loading