diff --git a/apps/ui/e2e/__screens__/split-view.png b/apps/ui/e2e/__screens__/split-view.png new file mode 100644 index 000000000..fbea106ef Binary files /dev/null and b/apps/ui/e2e/__screens__/split-view.png differ diff --git a/apps/ui/e2e/split-view.spec.ts b/apps/ui/e2e/split-view.spec.ts new file mode 100644 index 000000000..86c9d85f3 --- /dev/null +++ b/apps/ui/e2e/split-view.spec.ts @@ -0,0 +1,56 @@ +import { expect, test } from "@playwright/test"; + +// Two-pane split: W V splits the channel tab, the second pane navigates to an +// agent, and sending from the first pane's composer leaves the second alone. + +const TOPIC_PATH = "/channel/ch-svc-compass/topic/top-compass-acp"; + +test("W V splits a tab; a send in the first pane leaves the second pane put", async ({ + page, +}) => { + await page.goto(`/#${TOPIC_PATH}`); + const tabs = page.locator('.cx-tab-strip [role="tab"]'); + const panels = page.locator(".cx-split-pane:not([hidden]) > .view-panel"); + await expect(page.locator(".conv-composer input.field")).toBeVisible(); + + await tabs.first().click(); + await page.keyboard.press("w"); + await page.keyboard.press("v"); + await expect(panels).toHaveCount(2); + await expect(tabs).toHaveCount(1); + await expect(page.locator('[role="separator"]')).toBeVisible(); + const [first, second] = [panels.nth(0), panels.nth(1)]; + const firstBox = await first.boundingBox(); + const secondBox = await second.boundingBox(); + expect(firstBox?.y).toBe(secondBox?.y); + expect(firstBox?.x ?? 0).toBeLessThan(secondBox?.x ?? 0); + // The two panes and the 1px splitter fill main between them. + const fill = await page.evaluate(() => { + const box = document.querySelector(".cx-split-pane:not([hidden])"); + const parts = [...(box?.children ?? [])].map( + (el) => el.getBoundingClientRect().width, + ); + return { + sum: parts.reduce((a, b) => a + b, 0), + main: document.querySelector(".main")?.clientWidth ?? 0, + }; + }); + expect(Math.abs(fill.sum - fill.main)).toBeLessThanOrEqual(2); + + // The new second pane holds focus, so the sidebar navigates it alone. + await expect(second).toHaveAttribute("data-focused", ""); + await page.locator(".tree-agent", { hasText: "compass-ui" }).first().click(); + await expect(second.locator(".agent-view")).toBeVisible(); + await expect(first.locator(".conv-composer input.field")).toBeVisible(); + + const composer = first.locator(".conv-composer input.field"); + await composer.click(); + await expect(first).toHaveAttribute("data-focused", ""); + await composer.fill("from the first pane"); + await composer.press("Enter"); + + await expect(first.locator(".conv-composer")).toBeVisible(); + await expect(second.locator(".agent-view")).toBeVisible(); + await expect(second.locator(".conv-composer")).toHaveCount(0); + await expect(first).toHaveAttribute("data-focused", ""); +}); diff --git a/apps/ui/e2e/visual-smoke.spec.ts b/apps/ui/e2e/visual-smoke.spec.ts index b2de3f273..820dbcf05 100644 --- a/apps/ui/e2e/visual-smoke.spec.ts +++ b/apps/ui/e2e/visual-smoke.spec.ts @@ -242,4 +242,23 @@ test.describe("visual smoke — legacy-palette baseline", () => { scale: "css", }); }); + + test("split view — channel and agent side by side", async ({ page }) => { + await page.goto("/#/channel/ch-svc-compass/topic/top-compass-acp"); + await page.locator(".conv-composer").waitFor({ state: "visible" }); + await page.locator('.cx-tab-strip [role="tab"]').first().click(); + await page.keyboard.press("w"); + await page.keyboard.press("v"); + await page + .locator("aside.left .tree-agent", { hasText: "compass-ui" }) + .first() + .click(); + await page.locator(".cx-split-pane .agent-view").waitFor(); + await page.evaluate(() => document.fonts.ready); + await expect(page).toHaveScreenshot("split-view.png", { + fullPage: true, + animations: "disabled", + scale: "css", + }); + }); }); diff --git a/apps/ui/src/App.tsx b/apps/ui/src/App.tsx index 4d6b97815..0a7500f27 100644 --- a/apps/ui/src/App.tsx +++ b/apps/ui/src/App.tsx @@ -27,18 +27,18 @@ import { LeftSidebar } from "./components/LeftSidebar"; import { Palette } from "./components/Palette"; import { RightSidebar } from "./components/RightSidebar"; import { ShortcutsOverlay } from "./components/ShortcutsOverlay"; +import { SplitPane } from "./components/SplitPane"; import { TabStrip } from "./components/TabStrip"; import { TopBarSearch } from "./components/TopBarSearch"; import { UsageBar } from "./components/UsageBar"; -import { ViewHost } from "./components/ViewHost"; import { useStore } from "./context"; import type { CommandId } from "./keyboard/commands"; import { detectPlatform, installKeymap } from "./keyboard/dispatch"; import { shortcutForAria } from "./keyboard/keymap"; import type { LiveClients } from "./live/client"; import { routeTitle } from "./route-title"; -import { focusViewPanel, viewPanelId, viewTabId } from "./view-panel"; -import { focusedViewOf, shownViewIds, tabViews } from "./window-layout"; +import { focusViewPanel } from "./view-panel"; +import { focusedPane, focusedViewOf, shownViewIds } from "./window-layout"; // Compass shell: routed center view with persistent navigation and usage chrome. @@ -68,72 +68,110 @@ const App: Component< ); // Panels toggle `hidden` imperatively so a switch can move focus into the // shown view before hiding the old one; a hidden element drops focus. - const panels = new Map(); + // SplitPane renders them, so they are found under main by view id. + let main: HTMLElement | undefined; + const panels = (): Map => + new Map( + [ + ...(main?.querySelectorAll(".view-panel[data-view-id]") ?? + []), + ].map((el) => [el.dataset.viewId ?? "", el]), + ); const lastFocus = new Map(); // The view focus was last in, kept even after a close removes its panel. let focusedPanelView: string | undefined; + // The splitter focus sat on: removing it drops focus to the body even + // though its tab's panes survive. + let focusedSplitter: HTMLElement | undefined; + // The splitter sits outside both panels; focus on it counts as its tab's + // focused view, so removing or hiding it still lands focus in a pane. + const viewIdOf = (target: HTMLElement): string | undefined => { + const panelView = target.closest(".view-panel[data-view-id]") + ?.dataset.viewId; + if (panelView !== undefined) return panelView; + const tabId = target.closest(".cx-split-pane[data-tab-id]") + ?.dataset.tabId; + const tab = store.layout().tabs.find((item) => item.id === tabId); + return tab ? focusedPane(tab.layout).id : undefined; + }; const rememberFocus = (event: FocusEvent): void => { const target = event.target; if (!(target instanceof HTMLElement)) return; - const panel = target.closest('[role="tabpanel"]'); - const viewId = [...panels].find(([, el]) => el === panel)?.[0]; + const viewId = viewIdOf(target); if (viewId === undefined) return; - lastFocus.set(viewId, target); focusedPanelView = viewId; + focusedSplitter = target.closest(".view-panel") ? undefined : target; + if (focusedSplitter) return; + lastFocus.set(viewId, target); store.focusViewId(viewId); }; - // Focus moving to another real target outside every panel ends the claim; - // a null target is a removal, which the close path below still handles. + // Focus moving to another real target outside main ends the claim; a null + // target is a removal, which the close path below handles. const forgetFocus = (event: FocusEvent): void => { const next = event.relatedTarget; if (!(next instanceof Node)) return; - if (![...panels.values()].some((el) => el.contains(next))) + if (!main?.contains(next)) { focusedPanelView = undefined; + focusedSplitter = undefined; + } }; - const tabIdOf = (viewId: string): string | undefined => { - const tab = store - .layout() - .tabs.find((t) => tabViews(t.layout).some((v) => v.id === viewId)); - return tab ? viewTabId(tab.id) : undefined; - }; - // Focus is leaving if it sits in a panel about to hide, or it was in a panel - // a close just removed (the browser has already dropped it to the body). - const focusLeaving = (shownIds: readonly string[]): boolean => { + // Focus is leaving if it sits in a box or panel about to hide, or it was in + // a panel a close just removed (the browser has already dropped it to body). + const focusLeaving = ( + shown: Map, + toggles: readonly [HTMLElement, boolean][], + ): boolean => { const active = document.activeElement; if (active === null || active === document.body) - return focusedPanelView !== undefined && !panels.has(focusedPanelView); - return [...panels].some( - ([id, el]) => !shownIds.includes(id) && el.contains(active), - ); + return ( + focusedPanelView !== undefined && + (focusedSplitter?.isConnected === false || !shown.has(focusedPanelView)) + ); + return toggles.some(([el, show]) => !show && el.contains(active)); }; - const prunePanels = (ids: readonly string[]): void => { - for (const id of [...panels.keys()]) { - if (ids.includes(id)) continue; - panels.delete(id); - lastFocus.delete(id); + const pruneFocus = (ids: readonly string[]): void => { + for (const id of [...lastFocus.keys()]) { + if (!ids.includes(id)) lastFocus.delete(id); } }; + // Each tab's box and every panel, paired with whether it should show. + const visibility = ( + current: Map, + shownIds: readonly string[], + activeTabId: string, + ): [HTMLElement, boolean][] => [ + ...[...(main?.querySelectorAll(".cx-split-pane") ?? [])].map( + (box): [HTMLElement, boolean] => [box, box.dataset.tabId === activeTabId], + ), + ...[...current].map(([id, el]): [HTMLElement, boolean] => [ + el, + shownIds.includes(id), + ]), + ]; createEffect( () => ({ shownIds: shownViewIds(store.layout()), focusedId: focusedViewOf(store.layout()).id, + activeTabId: store.layout().activeTabId, ids: store.viewScopes().map((scope) => scope.id), }), - ({ shownIds, focusedId, ids }) => { - prunePanels(ids); - const focused = panels.get(focusedId); + ({ shownIds, focusedId, activeTabId, ids }) => { + pruneFocus(ids); + const current = panels(); + const focused = current.get(focusedId); if (!focused) return; - for (const id of shownIds) panels.get(id)?.removeAttribute("hidden"); - const activePanelId = [...panels].find(([, panel]) => + const toggles = visibility(current, shownIds, activeTabId); + for (const [el, show] of toggles) if (show) el.hidden = false; + const activePanelId = [...current].find(([, panel]) => panel.contains(document.activeElement), )?.[0]; if ( - focusLeaving(shownIds) || + focusLeaving(current, toggles) || (activePanelId !== undefined && activePanelId !== focusedId) ) { focusInto(focusedId, focused); } - for (const [id, el] of panels) el.hidden = !shownIds.includes(id); + for (const [el, show] of toggles) el.hidden = !show; }, ); const focusInto = (viewId: string, panel: HTMLElement): void => { @@ -223,21 +261,16 @@ const App: Component< -
- scope.id}> - {(scope) => ( - - )} +
{ + main = el; + }} + onFocusIn={rememberFocus} + onFocusOut={forgetFocus} + > + tab.id}> + {(tab) => }
diff --git a/apps/ui/src/components/SplitPane.test.tsx b/apps/ui/src/components/SplitPane.test.tsx new file mode 100644 index 000000000..82c93e61e --- /dev/null +++ b/apps/ui/src/components/SplitPane.test.tsx @@ -0,0 +1,260 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { cleanup, fireEvent } from "@solidjs/testing-library"; +import type { CommandId } from "../keyboard/commands"; +import type { AppStore } from "../store"; +import { flush, mountApp } from "../test-router"; +import { + loadLayout, + type TabLayout, + type WindowLayout, +} from "../window-layout"; + +// A split tab renders both views in one row or column, sized by the ratio, +// with a keyboard- and pointer-driven splitter whose ratio persists. + +const TOPIC_PATH = "/channel/ch-svc-compass/topic/top-compass-acp"; + +type Split = Extract; + +function memoryStorage(): Storage { + const items = new Map(); + return { + get length() { + return items.size; + }, + clear: () => items.clear(), + getItem: (key) => items.get(key) ?? null, + key: (index) => [...items.keys()][index] ?? null, + removeItem: (key) => { + items.delete(key); + }, + setItem: (key, value) => { + items.set(key, value); + }, + }; +} + +const activeSplit = (layout: WindowLayout): Split => { + const tab = layout.tabs.find((item) => item.id === layout.activeTabId); + if (tab?.layout.kind !== "split") throw new Error("active tab is not split"); + return tab.layout; +}; +const splitter = (c: HTMLElement): HTMLElement => { + const el = c.querySelector('.cx-split-pane [role="separator"]'); + if (!el) throw new Error("no splitter"); + return el; +}; +const panel = (c: HTMLElement, viewId: string): HTMLElement => { + const el = c.querySelector(`#view-panel-${viewId}`); + if (!el) throw new Error(`no panel for ${viewId}`); + return el; +}; +const runCommand = (store: AppStore, id: string): void => { + const command = store.keyboard.registry.get(id as CommandId); + if (!command) throw new Error(`missing command ${id}`); + command.run(); +}; + +async function mountSplit(direction: "row" | "column") { + const storage = memoryStorage(); + const mounted = mountApp(TOPIC_PATH, storage); + await flush(); + mounted.store.dispatchLayout({ kind: "split", direction }); + await flush(); + return { ...mounted, storage }; +} + +afterEach(() => cleanup()); + +describe("SplitPane", () => { + test("a row split shows both views side by side, sized by the ratio", async () => { + const { store, container } = await mountSplit("row"); + const split = activeSplit(store.layout()); + const first = panel(container, split.first.id); + const second = panel(container, split.second.id); + const root = first.parentElement; + expect(root?.classList.contains("cx-split-pane")).toBe(true); + expect(root?.dataset.direction).toBe("row"); + expect(second.parentElement).toBe(root); + expect([first.hidden, second.hidden]).toEqual([false, false]); + expect([first.style.flexGrow, second.style.flexGrow]).toEqual([ + "0.5", + "0.5", + ]); + const sep = splitter(container); + expect(sep.getAttribute("aria-orientation")).toBe("vertical"); + expect(sep.getAttribute("aria-controls")).toBe(first.id); + // The splitter sits between the two panes. + expect(first.nextElementSibling).toBe(sep); + expect(sep.nextElementSibling).toBe(second); + }); + + test("a column split stacks the views with a horizontal splitter", async () => { + const { store, container } = await mountSplit("column"); + const split = activeSplit(store.layout()); + expect( + panel(container, split.first.id).parentElement?.dataset.direction, + ).toBe("column"); + expect(splitter(container).getAttribute("aria-orientation")).toBe( + "horizontal", + ); + }); + + test("a single-view tab has no splitter", async () => { + const { container } = mountApp(TOPIC_PATH); + await flush(); + expect(container.querySelector('[role="separator"]')).toBeNull(); + }); + + test("arrow keys step the ratio along the split axis, clamped and persisted", async () => { + const { store, container, storage } = await mountSplit("row"); + const sep = splitter(container); + expect(sep.tabIndex).toBe(0); + expect( + ["aria-valuemin", "aria-valuemax", "aria-valuenow"].map((name) => + sep.getAttribute(name), + ), + ).toEqual(["20", "80", "50"]); + + fireEvent.keyDown(sep, { key: "ArrowRight" }); + await flush(); + expect(activeSplit(store.layout()).ratio).toBe(0.55); + expect(sep.getAttribute("aria-valuenow")).toBe("55"); + + // Each real keydown is its own task, so the layout settles between them. + for (let i = 0; i < 10; i++) { + fireEvent.keyDown(sep, { key: "ArrowRight" }); + await flush(); + } + expect(activeSplit(store.layout()).ratio).toBe(0.8); + expect(sep.getAttribute("aria-valuenow")).toBe("80"); + + fireEvent.keyDown(sep, { key: "ArrowLeft" }); + await flush(); + // The cross axis does nothing on a row split. + fireEvent.keyDown(sep, { key: "ArrowUp" }); + await flush(); + expect(activeSplit(store.layout()).ratio).toBe(0.75); + expect(activeSplit(loadLayout(storage, TOPIC_PATH)).ratio).toBe(0.75); + }); + + test("up and down step a column split", async () => { + const { store, container } = await mountSplit("column"); + const sep = splitter(container); + fireEvent.keyDown(sep, { key: "ArrowUp" }); + fireEvent.keyDown(sep, { key: "ArrowRight" }); + await flush(); + expect(activeSplit(store.layout()).ratio).toBe(0.45); + }); + + test("dragging the splitter resizes, clamped to the bounds, and persists", async () => { + const { store, container, storage } = await mountSplit("row"); + const sep = splitter(container); + const root = sep.parentElement; + if (!root) throw new Error("splitter has no root"); + root.getBoundingClientRect = () => new DOMRect(100, 0, 1000, 600); + + fireEvent.pointerDown(sep, { pointerId: 1, button: 0, clientX: 600 }); + fireEvent.pointerMove(sep, { pointerId: 1, buttons: 1, clientX: 400 }); + await flush(); + expect(activeSplit(store.layout()).ratio).toBe(0.3); + const split = activeSplit(store.layout()); + expect(panel(container, split.first.id).style.flexGrow).toBe("0.3"); + expect(panel(container, split.second.id).style.flexGrow).toBe("0.7"); + + fireEvent.pointerMove(sep, { pointerId: 1, buttons: 1, clientX: 120 }); + await flush(); + expect(activeSplit(store.layout()).ratio).toBe(0.2); + + fireEvent.pointerUp(sep, { pointerId: 1, clientX: 120 }); + fireEvent.pointerMove(sep, { pointerId: 1, buttons: 1, clientX: 900 }); + await flush(); + expect(activeSplit(store.layout()).ratio).toBe(0.2); + expect(activeSplit(loadLayout(storage, TOPIC_PATH)).ratio).toBe(0.2); + }); + + test("a drag ends when capture is lost or the button is no longer held", async () => { + const { store, container } = await mountSplit("row"); + const sep = splitter(container); + const root = sep.parentElement; + if (!root) throw new Error("splitter has no root"); + root.getBoundingClientRect = () => new DOMRect(100, 0, 1000, 600); + + fireEvent.pointerDown(sep, { pointerId: 1, button: 0, clientX: 600 }); + fireEvent.pointerMove(sep, { pointerId: 1, buttons: 0, clientX: 400 }); + await flush(); + expect(activeSplit(store.layout()).ratio).toBe(0.5); + + // Capture lost with no pointerup reaching the splitter. + fireEvent.lostPointerCapture(sep, { pointerId: 1 }); + fireEvent.pointerMove(sep, { pointerId: 1, buttons: 1, clientX: 400 }); + await flush(); + expect(activeSplit(store.layout()).ratio).toBe(0.5); + }); + + test("removing a focused splitter keeps focus in a pane", async () => { + for (const command of ["pane.closeOther", "tab.close", "tab.next"]) { + const { store, container } = await mountSplit("row"); + if (command !== "pane.closeOther") { + store.dispatchLayout({ + kind: "open", + path: "/settings", + background: true, + }); + await flush(); + } + splitter(container).focus(); + runCommand(store, command); + await flush(); + const active = document.activeElement; + expect(active === document.body || active === null).toBe(false); + expect(active?.closest(".view-panel:not([hidden])")).not.toBeNull(); + cleanup(); + } + }); + + test("the focused pane carries the focus marker, and only in a split", async () => { + const { store, container } = await mountSplit("row"); + const split = activeSplit(store.layout()); + const first = panel(container, split.first.id); + const second = panel(container, split.second.id); + expect([first.dataset.focused, second.dataset.focused]).toEqual([ + undefined, + "", + ]); + runCommand(store, "pane.focusFirst"); + await flush(); + expect([first.dataset.focused, second.dataset.focused]).toEqual([ + "", + undefined, + ]); + runCommand(store, "pane.closeOther"); + await flush(); + expect(first.dataset.focused).toBeUndefined(); + }); + + test("splitting and closing the other pane keep the first view mounted", async () => { + const { store, container } = mountApp(TOPIC_PATH); + await flush(); + const input = container.querySelector( + ".conv-composer input.field", + ); + if (!input) throw new Error("no composer"); + fireEvent.input(input, { target: { value: "draft" } }); + store.dispatchLayout({ kind: "split", direction: "row" }); + await flush(); + runCommand(store, "pane.focusFirst"); + runCommand(store, "pane.closeOther"); + await flush(); + expect(input.isConnected).toBe(true); + expect(input.value).toBe("draft"); + }); + + test("an inactive split tab hides its splitter with its panes", async () => { + const { store, container } = await mountSplit("row"); + const sep = splitter(container); + store.dispatchLayout({ kind: "open", path: "/settings" }); + await flush(); + expect(sep.closest("[hidden]")).not.toBeNull(); + }); +}); diff --git a/apps/ui/src/components/SplitPane.tsx b/apps/ui/src/components/SplitPane.tsx new file mode 100644 index 000000000..323ed9387 --- /dev/null +++ b/apps/ui/src/components/SplitPane.tsx @@ -0,0 +1,147 @@ +import { type Component, createMemo, For, Show } from "solid-js"; +import "../design/components/split-pane.css"; +import { useStore } from "../context"; +import { viewPanelId, viewTabId } from "../view-panel"; +import { + MAX_RATIO, + MIN_RATIO, + type TabLayout, + tabViews, +} from "../window-layout"; +import { ViewHost } from "./ViewHost"; + +type Split = Extract; + +const KEY_STEP = 0.05; + +/** One view tab's box: its single view, or both panes of a split with a + * splitter between them. App toggles `hidden` on the box and its panels. */ +export const SplitPane: Component<{ tabId: string }> = (props) => { + const store = useStore(); + const layout = createMemo( + () => store.layout().tabs.find((tab) => tab.id === props.tabId)?.layout, + ); + const split = createMemo((): Split | undefined => { + const tab = layout(); + return tab?.kind === "split" ? tab : undefined; + }); + const views = createMemo(() => { + const tab = layout(); + return tab ? tabViews(tab) : []; + }); + let root: HTMLDivElement | undefined; + let dragging: number | undefined; + + const onKeyDown = (event: KeyboardEvent, current: Split): void => { + const [back, forward] = + current.direction === "row" + ? ["ArrowLeft", "ArrowRight"] + : ["ArrowUp", "ArrowDown"]; + const step = + event.key === forward ? KEY_STEP : event.key === back ? -KEY_STEP : 0; + if (step === 0) return; + event.preventDefault(); + // Rounded so repeated steps land on whole percents, not float drift. + const ratio = Math.round((current.ratio + step) * 100) / 100; + store.dispatchLayout({ kind: "resize", ratio }); + }; + const onPointerMove = (event: PointerEvent, current: Split): void => { + // A release that never reached us leaves `dragging` set; `buttons` and + // the active tab say whether this is still a live drag of this tab. + if ( + dragging !== event.pointerId || + (event.buttons & 1) === 0 || + store.layout().activeTabId !== props.tabId || + !root + ) + return; + const box = root.getBoundingClientRect(); + const ratio = + current.direction === "row" + ? (event.clientX - box.left) / box.width + : (event.clientY - box.top) / box.height; + store.dispatchLayout({ kind: "resize", ratio }); + }; + + return ( +