diff --git a/tests/e2e_ui/sessions/test_inline_panel_resize.py b/tests/e2e_ui/sessions/test_inline_panel_resize.py new file mode 100644 index 0000000000..94c2078708 --- /dev/null +++ b/tests/e2e_ui/sessions/test_inline_panel_resize.py @@ -0,0 +1,137 @@ +"""Touch resizing for the inline Workspace panel.""" + +from __future__ import annotations + +from playwright.sync_api import Page, expect + +from tests.e2e_ui.conftest import open_right_rail, seed_committed_turn + +_VIEWPORT = {"width": 1280, "height": 700} +_GUTTER = "[data-workspace-panel-resize-gutter]" +_STORAGE_KEY = "omnigent:session-workspace-state" + + +def _touch_drag(page: Page, *, start: tuple[float, float], end: tuple[float, float]) -> None: + """Drive trusted touch input so Chromium exercises pointer capture.""" + client = page.context.new_cdp_session(page) + try: + client.send( + "Input.dispatchTouchEvent", + {"type": "touchStart", "touchPoints": [{"x": start[0], "y": start[1]}]}, + ) + client.send( + "Input.dispatchTouchEvent", + {"type": "touchMove", "touchPoints": [{"x": end[0], "y": end[1]}]}, + ) + client.send("Input.dispatchTouchEvent", {"type": "touchEnd", "touchPoints": []}) + finally: + client.detach() + + +def _panel_width(page: Page) -> float: + box = page.get_by_role("complementary", name="Workspace").bounding_box() + assert box is not None + return box["width"] + + +def _stored_width(page: Page, session_id: str) -> float | None: + return page.evaluate( + """([key, id]) => { + const entries = JSON.parse(localStorage.getItem(key) || "[]"); + return entries.find((entry) => entry.id === id)?.state?.widthPx ?? null; + }""", + [_STORAGE_KEY, session_id], + ) + + +def test_touch_resize_persists_without_stealing_transcript_scroll( + page: Page, + seeded_session: tuple[str, str], +) -> None: + base_url, session_id = seeded_session + for index in range(6): + seed_committed_turn( + session_id, + prompt=f"Question {index}?", + reply=f"Paragraph {index}. " + ("filler sentence for height. " * 12), + response_id=f"resp_resize_{index}", + ) + + page.set_viewport_size(_VIEWPORT) + page.goto(f"{base_url}/c/{session_id}") + open_right_rail(page) + + gutter = page.locator(_GUTTER) + expect(gutter).to_be_visible() + gutter_box = gutter.bounding_box() + assert gutter_box is not None + initial_width = _panel_width(page) + + _touch_drag( + page, + start=(gutter_box["x"] + gutter_box["width"] / 2, gutter_box["y"] + 200), + end=(gutter_box["x"] + 120, gutter_box["y"] + 200), + ) + + resized_width = _panel_width(page) + assert resized_width <= initial_width - 100 + page.wait_for_function( + """([key, id, width]) => { + const entries = JSON.parse(localStorage.getItem(key) || "[]"); + const stored = entries.find((entry) => entry.id === id)?.state?.widthPx; + return Math.abs(stored - width) <= 1; + }""", + arg=[_STORAGE_KEY, session_id, resized_width], + ) + + page.reload() + open_right_rail(page) + expect(page.get_by_role("log")).to_be_visible(timeout=30_000) + page.wait_for_function( + """(width) => { + const panel = document.querySelector('[aria-label="Workspace"]'); + return Math.abs(panel.getBoundingClientRect().width - width) <= 1; + }""", + arg=resized_width, + ) + + transcript_box = page.get_by_role("log").bounding_box() + assert transcript_box is not None + # The 8px hit test catches a widened gutter without dragging the scrollbar + # thumb; the 16px gesture separately proves transcript scrolling stays owned. + hit = page.evaluate( + """([x, y]) => { + const target = document.elementFromPoint(x, y); + return { + isGutter: target?.closest('[data-workspace-panel-resize-gutter]') !== null, + touchAction: target ? getComputedStyle(target).touchAction : null, + }; + }""", + arg=[transcript_box["x"] + transcript_box["width"] - 8, transcript_box["y"] + 400], + ) + assert hit["isGutter"] is False + assert hit["touchAction"] != "none" + + scroll_top = page.evaluate( + """() => { + const log = document.querySelector('[role="log"]'); + const scroller = [...log.querySelectorAll('*')].find( + (element) => element.scrollHeight > element.clientHeight + 4, + ); + if (!scroller) return null; + scroller.dataset.inlineResizeScroller = "true"; + scroller.scrollTop = 0; + return scroller.scrollTop; + }""" + ) + assert scroll_top == 0 + width_before_scroll = _panel_width(page) + _touch_drag( + page, + start=(transcript_box["x"] + transcript_box["width"] - 16, transcript_box["y"] + 400), + end=(transcript_box["x"] + transcript_box["width"] - 16, transcript_box["y"] + 280), + ) + + page.wait_for_function("document.querySelector('[data-inline-resize-scroller]').scrollTop > 0") + assert _panel_width(page) == width_before_scroll + assert _stored_width(page, session_id) == resized_width diff --git a/tests/e2e_ui/visual/snapshots/test_chat_snapshot/test_chat_conversation_matches_baseline/test_chat_conversation_matches_baseline[chromium][linux].png b/tests/e2e_ui/visual/snapshots/test_chat_snapshot/test_chat_conversation_matches_baseline/test_chat_conversation_matches_baseline[chromium][linux].png index 24a334d471..47183e736f 100644 Binary files a/tests/e2e_ui/visual/snapshots/test_chat_snapshot/test_chat_conversation_matches_baseline/test_chat_conversation_matches_baseline[chromium][linux].png and b/tests/e2e_ui/visual/snapshots/test_chat_snapshot/test_chat_conversation_matches_baseline/test_chat_conversation_matches_baseline[chromium][linux].png differ diff --git a/tests/e2e_ui/visual/snapshots/test_chat_turn_rail_snapshot/test_chat_turn_rail_matches_baseline/test_chat_turn_rail_matches_baseline[chromium][linux].png b/tests/e2e_ui/visual/snapshots/test_chat_turn_rail_snapshot/test_chat_turn_rail_matches_baseline/test_chat_turn_rail_matches_baseline[chromium][linux].png index a0273638a0..21ea9989ab 100644 Binary files a/tests/e2e_ui/visual/snapshots/test_chat_turn_rail_snapshot/test_chat_turn_rail_matches_baseline/test_chat_turn_rail_matches_baseline[chromium][linux].png and b/tests/e2e_ui/visual/snapshots/test_chat_turn_rail_snapshot/test_chat_turn_rail_matches_baseline/test_chat_turn_rail_matches_baseline[chromium][linux].png differ diff --git a/web/src/hooks/useResizableInlinePanel.test.tsx b/web/src/hooks/useResizableInlinePanel.test.tsx index 926be6eb91..d6a24805ce 100644 --- a/web/src/hooks/useResizableInlinePanel.test.tsx +++ b/web/src/hooks/useResizableInlinePanel.test.tsx @@ -1,5 +1,5 @@ import { act, renderHook } from "@testing-library/react"; -import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { readSessionWorkspaceState } from "@/lib/sessionWorkspaceState"; import { resetWidthStoreForTesting, useResizableInlinePanel } from "./useResizableInlinePanel"; @@ -28,6 +28,49 @@ function nudgeWiderOnce(result: { current: ReturnType(); + const setPointerCapture = vi.fn((pointerId: number) => capturedPointers.add(pointerId)); + const releasePointerCapture = vi.fn((pointerId: number) => capturedPointers.delete(pointerId)); + const hasPointerCapture = vi.fn((pointerId: number) => capturedPointers.has(pointerId)); + Object.assign(element, { setPointerCapture, releasePointerCapture, hasPointerCapture }); + return { element, setPointerCapture, releasePointerCapture }; +} + +function pointerEvent( + element: HTMLElement, + overrides: Partial<{ + pointerId: number; + pointerType: string; + button: number; + clientX: number; + preventDefault: () => void; + }> = {}, +): React.PointerEvent { + return { + currentTarget: element, + pointerId: 1, + pointerType: "touch", + button: 0, + clientX: 0, + preventDefault: () => {}, + ...overrides, + } as React.PointerEvent; +} + +function dispatchDocumentPointer(type: "pointerup" | "pointercancel", pointerId: number): void { + const event = new Event(type, { bubbles: true }); + Object.defineProperty(event, "pointerId", { value: pointerId }); + document.dispatchEvent(event); +} + +const overlaySelector = () => + [...document.body.children].find( + (c): c is HTMLElement => + c instanceof HTMLElement && c.style.position === "fixed" && c.style.zIndex === "2147483647", + ) ?? null; + beforeEach(() => { setInnerWidth(2000); }); @@ -104,14 +147,14 @@ describe("useResizableInlinePanel reserved width (sidebar)", () => { const collapsed = renderHook(() => useResizableInlinePanel(SESSION, undefined, /* reservedPx */ 0), ); - act(() => window.dispatchEvent(new MouseEvent("mousemove", { clientX: 0 }))); - act(() => - collapsed.result.current.handleProps.onMouseDown({ - preventDefault: () => {}, - } as React.MouseEvent), - ); - act(() => window.dispatchEvent(new MouseEvent("mousemove", { clientX: 100 }))); - act(() => window.dispatchEvent(new MouseEvent("mouseup"))); + const handle = createPointerHandle(); + act(() => { + collapsed.result.current.handleProps.onPointerDown(pointerEvent(handle.element)); + collapsed.result.current.handleProps.onPointerMove( + pointerEvent(handle.element, { clientX: 100 }), + ); + collapsed.result.current.handleProps.onPointerUp(pointerEvent(handle.element)); + }); expect(collapsed.result.current.panelWidth).toBe(912); expect(readSessionWorkspaceState(SESSION).widthPx).toBe(912); collapsed.unmount(); @@ -159,11 +202,12 @@ describe("useResizableInlinePanel reserved width (sidebar)", () => { { initialProps: { reserved: reservedPx } }, ); // Drag the rail out to its widest at this viewport. - act(() => - result.current.handleProps.onMouseDown({ preventDefault: () => {} } as React.MouseEvent), - ); - act(() => window.dispatchEvent(new MouseEvent("mousemove", { clientX: 0 }))); - act(() => window.dispatchEvent(new MouseEvent("mouseup"))); + const handle = createPointerHandle(); + act(() => { + result.current.handleProps.onPointerDown(pointerEvent(handle.element)); + result.current.handleProps.onPointerMove(pointerEvent(handle.element)); + result.current.handleProps.onPointerUp(pointerEvent(handle.element)); + }); // Now shrink the viewport hard. Even though the stored (no-reserve) width may // still fit its own ceiling, the render-time reserve clamp must re-run. @@ -175,37 +219,336 @@ describe("useResizableInlinePanel reserved width (sidebar)", () => { }); }); -describe("useResizableInlinePanel drag overlay", () => { - const overlaySelector = () => - [...document.body.children].find( - (c): c is HTMLElement => - c instanceof HTMLElement && c.style.position === "fixed" && c.style.zIndex === "2147483647", - ) ?? null; - - it("mounts a full-window overlay during a drag so mouseup isn't lost to an iframe", () => { - // The panel sits beside the sandboxed HTML-preview iframe. Without an - // overlay, dragging over the frame routes mousemove/mouseup into it and the - // parent never sees the release, so the drag sticks to the cursor. - const { result, unmount } = renderHook(() => useResizableInlinePanel(SESSION)); +describe("useResizableInlinePanel pointer drag", () => { + it("captures the pointer and persists the final width on release", () => { + // Without setPointerCapture, a drag that leaves the 1px handle (or crosses + // the HTML-preview iframe) loses the pointer stream and the rail sticks. + const { result } = renderHook(() => useResizableInlinePanel(SESSION)); + const handle = createPointerHandle(); + + act(() => + result.current.handleProps.onPointerDown(pointerEvent(handle.element, { pointerId: 7 })), + ); + expect(handle.setPointerCapture).toHaveBeenCalledWith(7); + + // 2000px viewport, cursor at 1200 → width = innerWidth - clientX = 800. + act(() => + result.current.handleProps.onPointerMove( + pointerEvent(handle.element, { pointerId: 7, clientX: 1200 }), + ), + ); + + // Live width tracks the drag, but nothing is written to storage mid-drag — + // persisting per pointermove would fire a synchronous write on every frame. + expect(result.current.panelWidth).toBe(800); + expect(readSessionWorkspaceState(SESSION).widthPx).toBeUndefined(); + + act(() => + result.current.handleProps.onPointerUp(pointerEvent(handle.element, { pointerId: 7 })), + ); + + expect(readSessionWorkspaceState(SESSION).widthPx).toBe(800); + expect(handle.releasePointerCapture).toHaveBeenCalledWith(7); + }); + + it("stays idle when pointer capture throws", () => { + const { result } = renderHook(() => useResizableInlinePanel(SESSION)); + const handle = createPointerHandle(); + const preventDefault = vi.fn(); + handle.setPointerCapture.mockImplementationOnce(() => { + throw new DOMException("capture unavailable"); + }); + + act(() => + result.current.handleProps.onPointerDown( + pointerEvent(handle.element, { pointerId: 7, preventDefault }), + ), + ); + + expect(preventDefault).not.toHaveBeenCalled(); + expect(overlaySelector()).toBeNull(); + expect(document.body.style.cursor).toBe(""); + expect(document.body.style.userSelect).toBe(""); + + act(() => + result.current.handleProps.onPointerMove( + pointerEvent(handle.element, { pointerId: 7, clientX: 1200 }), + ), + ); + expect(result.current.panelWidth).toBe(600); + }); + + it.each(["onPointerCancel", "onLostPointerCapture"] as const)( + "aborts cleanly without persisting through %s", + (abortHandler) => { + // Browser cancellation or capture loss keeps the last applied width, + // ends the drag, and never persists a half-finished resize. + const { result } = renderHook(() => useResizableInlinePanel(SESSION)); + const handle = createPointerHandle(); + + act(() => { + result.current.handleProps.onPointerDown(pointerEvent(handle.element, { pointerId: 11 })); + result.current.handleProps.onPointerMove( + pointerEvent(handle.element, { pointerId: 11, clientX: 1200 }), + ); + }); + expect(result.current.panelWidth).toBe(800); + + act(() => { + result.current.handleProps[abortHandler](pointerEvent(handle.element, { pointerId: 11 })); + result.current.handleProps.onPointerMove( + pointerEvent(handle.element, { pointerId: 11, clientX: 1400 }), + ); + }); + + expect(result.current.panelWidth).toBe(800); + expect(readSessionWorkspaceState(SESSION).widthPx).toBeUndefined(); + expect(document.body.style.cursor).toBe(""); + expect(document.body.style.userSelect).toBe(""); + }, + ); + + it("does not start a drag from a secondary pen button", () => { + const { result } = renderHook(() => useResizableInlinePanel(SESSION)); + const handle = createPointerHandle(); + + act(() => + result.current.handleProps.onPointerDown( + pointerEvent(handle.element, { pointerType: "pen", button: 2 }), + ), + ); + expect(handle.setPointerCapture).not.toHaveBeenCalled(); + + act(() => + result.current.handleProps.onPointerMove(pointerEvent(handle.element, { clientX: 1200 })), + ); + expect(result.current.panelWidth).toBe(600); + }); + + it("finishes through the document fallback if the handle unmounts mid-drag", () => { + const { result } = renderHook(() => useResizableInlinePanel(SESSION)); + const firstHandle = createPointerHandle(); + + act(() => { + result.current.handleProps.onPointerDown(pointerEvent(firstHandle.element, { pointerId: 5 })); + result.current.handleProps.onPointerMove( + pointerEvent(firstHandle.element, { pointerId: 5, clientX: 1200 }), + ); + firstHandle.element.remove(); + dispatchDocumentPointer("pointerup", 5); + }); + + expect(result.current.panelWidth).toBe(800); + expect(readSessionWorkspaceState(SESSION).widthPx).toBe(800); + expect(overlaySelector()).toBeNull(); + expect(document.body.style.cursor).toBe(""); + expect(document.body.style.userSelect).toBe(""); + + const nextHandle = createPointerHandle(); + act(() => { + result.current.handleProps.onPointerDown(pointerEvent(nextHandle.element, { pointerId: 6 })); + result.current.handleProps.onPointerMove( + pointerEvent(nextHandle.element, { pointerId: 6, clientX: 1100 }), + ); + }); + expect(nextHandle.setPointerCapture).toHaveBeenCalledWith(6); + expect(result.current.panelWidth).toBe(900); + }); + + it("aborts without persisting when the panel-enabled gate flips false", () => { + const { result, rerender } = renderHook( + ({ enabled }) => useResizableInlinePanel(SESSION, undefined, 0, enabled), + { initialProps: { enabled: true } }, + ); + const handle = createPointerHandle(); + + act(() => { + result.current.handleProps.onPointerDown(pointerEvent(handle.element)); + result.current.handleProps.onPointerMove(pointerEvent(handle.element, { clientX: 1200 })); + }); + expect(result.current.panelWidth).toBe(800); + + rerender({ enabled: false }); + + expect(result.current.panelWidth).toBe(800); + expect(readSessionWorkspaceState(SESSION).widthPx).toBeUndefined(); + expect(overlaySelector()).toBeNull(); + expect(document.body.style.cursor).toBe(""); + expect(document.body.style.userSelect).toBe(""); + }); + + it("does not start pointer or keyboard resize while disabled", () => { + const { result } = renderHook(() => useResizableInlinePanel(SESSION, undefined, 0, false)); + const handle = createPointerHandle(); + + act(() => { + result.current.handleProps.onPointerDown(pointerEvent(handle.element)); + result.current.handleProps.onPointerMove(pointerEvent(handle.element, { clientX: 1200 })); + result.current.handleProps.onPointerUp(pointerEvent(handle.element)); + result.current.handleProps.onKeyDown({ + key: "ArrowLeft", + preventDefault: () => {}, + } as React.KeyboardEvent); + }); + + expect(handle.setPointerCapture).not.toHaveBeenCalled(); + expect(result.current.handleProps["aria-disabled"]).toBe(true); + expect(result.current.panelWidth).toBe(600); + expect(readSessionWorkspaceState(SESSION).widthPx).toBeUndefined(); + expect(overlaySelector()).toBeNull(); + }); + + it("does not start pointer or keyboard resize at a zero-width clamp", () => { + setInnerWidth(0); + const { result } = renderHook(() => useResizableInlinePanel(SESSION)); + const handle = createPointerHandle(); + + act(() => { + result.current.handleProps.onPointerDown(pointerEvent(handle.element)); + result.current.handleProps.onKeyDown({ + key: "ArrowLeft", + preventDefault: () => {}, + } as React.KeyboardEvent); + }); + + expect(result.current.panelWidth).toBe(0); + expect(result.current.handleProps["aria-disabled"]).toBe(true); + expect(handle.setPointerCapture).not.toHaveBeenCalled(); + expect(readSessionWorkspaceState(SESSION).widthPx).toBeUndefined(); + expect(overlaySelector()).toBeNull(); + }); + + it("aborts the old drag before loading a new session", () => { + const { result, rerender } = renderHook(({ sessionId }) => useResizableInlinePanel(sessionId), { + initialProps: { sessionId: "conv_old" }, + }); + const handle = createPointerHandle(); + + act(() => { + result.current.handleProps.onPointerDown(pointerEvent(handle.element)); + result.current.handleProps.onPointerMove(pointerEvent(handle.element, { clientX: 1200 })); + }); + expect(result.current.panelWidth).toBe(800); + + rerender({ sessionId: "conv_new" }); + expect(overlaySelector()).toBeNull(); + expect(document.body.style.cursor).toBe(""); + expect(document.body.style.userSelect).toBe(""); + act(() => dispatchDocumentPointer("pointerup", 1)); + + expect(readSessionWorkspaceState("conv_old").widthPx).toBeUndefined(); + expect(readSessionWorkspaceState("conv_new").widthPx).toBeUndefined(); + }); + + it("ignores additional pointers until the active drag ends", () => { + // A second finger joining a live resize must not steal the stream — + // first pointer wins until that drag ends. + const { result } = renderHook(() => useResizableInlinePanel(SESSION)); + const firstHandle = createPointerHandle(); + const secondHandle = createPointerHandle(); + + act(() => { + result.current.handleProps.onPointerDown(pointerEvent(firstHandle.element)); + result.current.handleProps.onPointerDown( + pointerEvent(secondHandle.element, { pointerId: 2 }), + ); + result.current.handleProps.onPointerMove( + pointerEvent(secondHandle.element, { pointerId: 2, clientX: 1400 }), + ); + }); + + expect(firstHandle.setPointerCapture).toHaveBeenCalledWith(1); + expect(secondHandle.setPointerCapture).not.toHaveBeenCalled(); + expect(result.current.panelWidth).toBe(600); act(() => - result.current.handleProps.onMouseDown({ preventDefault: () => {} } as React.MouseEvent), + result.current.handleProps.onPointerMove( + pointerEvent(firstHandle.element, { clientX: 1200 }), + ), ); + expect(result.current.panelWidth).toBe(800); + }); + + it("returns a 24px fine-pointer target with a 10px gutter footprint", () => { + const { result } = renderHook(() => useResizableInlinePanel(SESSION)); + + expect(result.current.handleProps.style).toMatchObject({ + touchAction: "none", + boxSizing: "content-box", + paddingLeft: 9, + paddingRight: 11, + marginLeft: -6, + marginRight: -8, + backgroundClip: "content-box", + }); + }); + + it("reacts to coarse-pointer changes with a tightly bounded 26px target", () => { + const originalMatchMedia = window.matchMedia; + let coarse = false; + let onChange: ((event: MediaQueryListEvent) => void) | undefined; + window.matchMedia = ((query: string) => ({ + matches: query === "(pointer: coarse)" ? coarse : false, + media: query, + onchange: null, + addListener: () => {}, + removeListener: () => {}, + addEventListener: (_type: string, listener: (event: MediaQueryListEvent) => void) => { + if (query === "(pointer: coarse)") onChange = listener; + }, + removeEventListener: () => {}, + dispatchEvent: () => false, + })) as typeof window.matchMedia; + + try { + const { result } = renderHook(() => useResizableInlinePanel(SESSION)); + coarse = true; + act(() => onChange?.({ matches: true } as MediaQueryListEvent)); + + expect(result.current.handleProps.style).toMatchObject({ + paddingLeft: 10, + paddingRight: 12, + marginLeft: -6, + marginRight: -8, + }); + } finally { + window.matchMedia = originalMatchMedia; + } + }); + + it("caps the chat-side sliver before the transcript scrollbar thumb", () => { + const { result } = renderHook(() => useResizableInlinePanel(SESSION)); + + // TranscriptScrollbar's resting thumb occupies the 6–12px band from the + // chat edge, so the resize target must stop at or before 6px. + expect(Math.abs(Number(result.current.handleProps.style?.marginLeft))).toBeLessThanOrEqual(6); + }); +}); + +describe("useResizableInlinePanel drag overlay", () => { + it("mounts a full-window overlay during a drag so moves aren't lost to an iframe", () => { + // The panel sits beside the sandboxed HTML-preview iframe. Capture plus + // a shielding overlay keeps the parent receiving the pointer stream when + // the drag crosses the frame. + const { result, unmount } = renderHook(() => useResizableInlinePanel(SESSION)); + expect(overlaySelector()).toBeNull(); + + const handle = createPointerHandle(); + act(() => result.current.handleProps.onPointerDown(pointerEvent(handle.element))); const overlay = overlaySelector(); expect(overlay).not.toBeNull(); expect(overlay?.style.cursor).toBe("col-resize"); - act(() => window.dispatchEvent(new MouseEvent("mouseup"))); + act(() => result.current.handleProps.onPointerUp(pointerEvent(handle.element))); expect(overlaySelector()).toBeNull(); unmount(); }); it("removes the overlay if unmounted mid-drag", () => { const { result, unmount } = renderHook(() => useResizableInlinePanel(SESSION)); - act(() => - result.current.handleProps.onMouseDown({ preventDefault: () => {} } as React.MouseEvent), - ); + const handle = createPointerHandle(); + act(() => result.current.handleProps.onPointerDown(pointerEvent(handle.element))); expect(overlaySelector()).not.toBeNull(); // Panel closes (e.g. tab switch) while still dragging — cleanup must not diff --git a/web/src/hooks/useResizableInlinePanel.ts b/web/src/hooks/useResizableInlinePanel.ts index a09e82b846..1f251a2003 100644 --- a/web/src/hooks/useResizableInlinePanel.ts +++ b/web/src/hooks/useResizableInlinePanel.ts @@ -14,6 +14,28 @@ const MAX_WIDTH_RATIO = 0.99; const CHAT_MIN_WIDTH_PX = 480; /** Visual gap between the chat column and the rail. */ const GAP_PX = 8; +// The handle is a dedicated flex gutter between chat and panel, outside both +// scroll containers. The painted `w-1` strip is centered in a small layout +// gutter, with tightly bounded overhangs that avoid owning either surface. +const PAINTED_STRIP_PX = 4; +const COARSE_GUTTER_PX = 12; +const FINE_GUTTER_PX = 10; +const CHAT_SLIVER_PX = 6; +const PANEL_SLIVER_PX = 8; + +function gutterStyle(isCoarse: boolean): React.CSSProperties { + const gutter = isCoarse ? COARSE_GUTTER_PX : FINE_GUTTER_PX; + const inset = (gutter - PAINTED_STRIP_PX) / 2; + return { + touchAction: "none", + boxSizing: "content-box", + paddingLeft: CHAT_SLIVER_PX + inset, + paddingRight: PANEL_SLIVER_PX + inset, + marginLeft: -CHAT_SLIVER_PX, + marginRight: -PANEL_SLIVER_PX, + backgroundClip: "content-box", + }; +} // ~36 % of viewport, clamped [420, 600] — ~30 % wider than the prior default so // the first manual open lands at a comfortable working width. @@ -131,8 +153,8 @@ function getServerSnapshot(): number | null { * inline panel doesn't disturb the push-panel widths (TerminalsPanel etc.). * * Returns the current pixel width and handle props to spread onto the resize - * handle element. Intended for desktop-only use — callers should not render - * the handle on mobile. + * handle element. Drag uses pointer events with capture so touch/stylus work + * the same as mouse. Callers should not render the handle on mobile. * * `sessionId` scopes the persisted width: each conversation remembers its own * rail width. Pass `null` when there is no active conversation (the panel then @@ -147,8 +169,12 @@ export function useResizableInlinePanel( sessionId: string | null, minWidthPx = MIN_WIDTH_PX, reservedPx = 0, + enabled = true, ) { const raw = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot); + const [isCoarsePointer, setIsCoarsePointer] = useState( + () => typeof window !== "undefined" && !!window.matchMedia?.("(pointer: coarse)").matches, + ); // On a session switch the module store still holds the previous session's // width until the effect below re-seeds it after commit. Derive this render's // width straight from the incoming session's saved value so the panel doesn't @@ -163,16 +189,13 @@ export function useResizableInlinePanel( // Clamped at render time only — the store keeps the user's preferred width, so // a temporary squeeze (sidebar opening) is undone when the space returns. const resolvedWidth = clamp(effectiveRaw ?? defaultWidthPx(), minWidthPx, reservedPx); - // Drives the drag listeners' lifecycle: they mount only while a drag is - // live, so there's no idle window-level mousemove handler firing during - // ordinary page use. - const [isDragging, setIsDragging] = useState(false); // `resolvedWidth` reads `window.innerWidth` at render, but a viewport resize // that leaves the stored (no-reserve) width unchanged wouldn't otherwise // re-render — so the render-time reserve clamp would go stale and the chat // could dip below its minimum on a shrink. This tick forces a recompute on // every resize regardless of whether the stored width moved. const [, bumpViewport] = useReducer((n: number) => n + 1, 0); + const activePointerIdRef = useRef(null); const overlayRef = useRef(null); const minWidthRef = useRef(minWidthPx); minWidthRef.current = minWidthPx; @@ -180,9 +203,9 @@ export function useResizableInlinePanel( reservedRef.current = reservedPx; // While dragging, a transparent full-window overlay sits above the panel so - // the pointer stream keeps reaching the parent document. Without it, dragging - // over a cross-origin/sandboxed iframe (e.g. the HTML preview) routes mousemove - // /mouseup into the frame, the parent never sees mouseup, and the drag sticks. + // the pointer stream keeps reaching the parent document. Capture continues + // moves off the handle, but without the overlay a drag over a cross-origin + // iframe (e.g. the HTML preview) can still lose the stream on some engines. const addDragOverlay = useCallback(() => { if (overlayRef.current || typeof document === "undefined") return; const el = document.createElement("div"); @@ -197,12 +220,13 @@ export function useResizableInlinePanel( overlayRef.current = null; }, []); - // Load the active session's saved width into the module store (and re-load - // when it changes) so the live store and the drag handlers operate on the - // right session. useEffect(() => { - loadSession(sessionId); - }, [sessionId]); + const media = window.matchMedia?.("(pointer: coarse)"); + if (!media) return; + const onChange = (event: MediaQueryListEvent) => setIsCoarsePointer(event.matches); + media.addEventListener("change", onChange); + return () => media.removeEventListener("change", onChange); + }, []); // Re-clamp on viewport resize so the panel can't overflow a shrunken window. // Re-derive the effective width from the persisted preference so widening the @@ -227,19 +251,93 @@ export function useResizableInlinePanel( // The resolvedWidth formula already enforces the visual minimum. No effect // needed — this lets the panel shrink back when minWidthPx drops. - const onMouseDown = useCallback( - (e: React.MouseEvent) => { + const endDrag = useCallback( + (persist: boolean) => { + if (activePointerIdRef.current === null) return; + activePointerIdRef.current = null; + if (persist) persistStoredWidth(); + removeDragOverlay(); + document.body.style.cursor = ""; + document.body.style.userSelect = ""; + }, + [removeDragOverlay], + ); + + // A session switch removes the old panel identity even when the next + // session also renders a rail. Abort before re-seeding so a late pointerup + // cannot persist the old drag into the new conversation. + useEffect(() => { + endDrag(false); + loadSession(sessionId); + }, [endDrag, sessionId]); + + const onPointerDown = useCallback( + (e: React.PointerEvent) => { + // First pointer wins; secondary buttons do not start a resize. + if (!enabled || resolvedWidth === 0) return; + if (activePointerIdRef.current !== null) return; + if (e.button !== 0) return; + try { + e.currentTarget.setPointerCapture(e.pointerId); + } catch { + return; + } e.preventDefault(); - setIsDragging(true); + activePointerIdRef.current = e.pointerId; addDragOverlay(); document.body.style.cursor = "col-resize"; document.body.style.userSelect = "none"; }, - [addDragOverlay], + [addDragOverlay, enabled, resolvedWidth], + ); + + const onPointerMove = useCallback((e: React.PointerEvent) => { + if (e.pointerId !== activePointerIdRef.current) return; + // Live width only; persist once on release to avoid a storage write per move. + setStoredWidth(clamp(window.innerWidth - e.clientX, minWidthRef.current, reservedRef.current)); + }, []); + + const onPointerUp = useCallback( + (e: React.PointerEvent) => { + if (e.pointerId !== activePointerIdRef.current) return; + endDrag(true); + if (e.currentTarget.hasPointerCapture(e.pointerId)) { + e.currentTarget.releasePointerCapture(e.pointerId); + } + }, + [endDrag], ); + const onPointerCancel = useCallback( + (e: React.PointerEvent) => { + if (e.pointerId !== activePointerIdRef.current) return; + endDrag(false); + }, + [endDrag], + ); + + useEffect(() => { + const onDocumentPointerUp = (e: PointerEvent) => { + if (e.pointerId === activePointerIdRef.current) endDrag(true); + }; + const onDocumentPointerCancel = (e: PointerEvent) => { + if (e.pointerId === activePointerIdRef.current) endDrag(false); + }; + document.addEventListener("pointerup", onDocumentPointerUp); + document.addEventListener("pointercancel", onDocumentPointerCancel); + return () => { + document.removeEventListener("pointerup", onDocumentPointerUp); + document.removeEventListener("pointercancel", onDocumentPointerCancel); + }; + }, [endDrag]); + + useEffect(() => { + if (!enabled || resolvedWidth === 0) endDrag(false); + }, [enabled, endDrag, resolvedWidth]); + const onKeyDown = useCallback( (e: React.KeyboardEvent) => { + if (!enabled || resolvedWidth === 0) return; const step = 20; if (e.key === "ArrowLeft") { e.preventDefault(); @@ -255,62 +353,25 @@ export function useResizableInlinePanel( ); } }, - [resolvedWidth], + [enabled, resolvedWidth], ); - // Drag listeners live only while a drag is active — no idle window-level - // mousemove handler during ordinary use. Moves are coalesced through a - // single rAF so a burst of mousemove events yields at most one width update - // per frame (setStoredWidth already dedupes equal values). - useEffect(() => { - if (!isDragging) return; - let frame = 0; - let pending: number | null = null; - - function flush() { - frame = 0; - if (pending === null) return; - setStoredWidth(clamp(pending, minWidthRef.current, reservedRef.current)); - pending = null; - } - - function onMouseMove(e: MouseEvent) { - pending = window.innerWidth - e.clientX; - if (frame === 0) frame = requestAnimationFrame(flush); - } - - function stop() { - if (frame !== 0) cancelAnimationFrame(frame); - flush(); - setIsDragging(false); - removeDragOverlay(); - persistStoredWidth(); - document.body.style.cursor = ""; - document.body.style.userSelect = ""; - } - - window.addEventListener("mousemove", onMouseMove); - window.addEventListener("mouseup", stop); - return () => { - if (frame !== 0) cancelAnimationFrame(frame); - window.removeEventListener("mousemove", onMouseMove); - window.removeEventListener("mouseup", stop); - // Unmounted mid-drag (panel closed via tab switch): reset the cursor and - // drop the overlay so it can't swallow later clicks. - document.body.style.cursor = ""; - document.body.style.userSelect = ""; - removeDragOverlay(); - }; - }, [isDragging, removeDragOverlay]); + useEffect(() => () => endDrag(false), [endDrag]); return { panelWidth: resolvedWidth, handleProps: { - onMouseDown, + onPointerDown, + onPointerMove, + onPointerUp, + onPointerCancel, + onLostPointerCapture: onPointerCancel, onKeyDown, + style: gutterStyle(isCoarsePointer), role: "separator" as const, "aria-orientation": "vertical" as const, "aria-label": "Resize panel", + "aria-disabled": !enabled || resolvedWidth === 0, tabIndex: 0, }, }; diff --git a/web/src/shell/AppShell.test.tsx b/web/src/shell/AppShell.test.tsx index ccff4fa9ff..491d6d6340 100644 --- a/web/src/shell/AppShell.test.tsx +++ b/web/src/shell/AppShell.test.tsx @@ -16,7 +16,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { TooltipProvider } from "@/components/ui/tooltip"; import type { ServerInfo } from "@/lib/capabilities"; import { CapabilitiesProvider } from "@/lib/CapabilitiesContext"; -import { writeSessionWorkspaceState } from "@/lib/sessionWorkspaceState"; +import { readSessionWorkspaceState, writeSessionWorkspaceState } from "@/lib/sessionWorkspaceState"; import { writeWorkspacePanelDefault } from "@/lib/workspacePanelPreferences"; vi.mock("@/hooks/useConversations", () => ({ @@ -523,7 +523,10 @@ beforeEach(() => { }); }); -afterEach(cleanup); +afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); +}); describe("AppShell header", () => { it("renders the sidebar toggle on all pages", () => { @@ -2064,6 +2067,47 @@ describe("FilesPanel visibility", () => { }); describe("Right workspace card visibility", () => { + it("aborts an active resize when the workspace panel closes", () => { + vi.stubGlobal("innerWidth", 1024); + useEnvironmentMock.mockReturnValue({ + data: { available: false, root: null, home: null }, + isLoading: false, + } as unknown as ReturnType); + mockConversations([{ id: "conv_drag_close", permission_level: null }]); + + renderShell("/c/conv_drag_close"); + + const separator = screen.getByRole("separator", { name: "Resize panel" }); + Object.assign(separator, { + setPointerCapture: vi.fn(), + hasPointerCapture: () => true, + releasePointerCapture: vi.fn(), + }); + + fireEvent.pointerDown(separator, { pointerId: 9, pointerType: "touch", button: 0 }); + fireEvent.pointerMove(separator, { pointerId: 9, pointerType: "touch", clientX: 1200 }); + + expect(document.body.style.cursor).toBe("col-resize"); + expect(document.body.style.userSelect).toBe("none"); + expect( + [...document.body.children].some( + (child) => child instanceof HTMLElement && child.style.zIndex === "2147483647", + ), + ).toBe(true); + + fireEvent.click(screen.getByRole("button", { name: "Collapse right panel" })); + + expect(screen.queryByRole("separator", { name: "Resize panel" })).toBeNull(); + expect(document.body.style.cursor).toBe(""); + expect(document.body.style.userSelect).toBe(""); + expect( + [...document.body.children].some( + (child) => child instanceof HTMLElement && child.style.zIndex === "2147483647", + ), + ).toBe(false); + expect(readSessionWorkspaceState("conv_drag_close").widthPx).toBeUndefined(); + }); + it("reserves the visible pane width plus its two desktop margins from the header", () => { useEnvironmentMock.mockReturnValue({ data: { available: false, root: null, home: null }, diff --git a/web/src/shell/AppShell.tsx b/web/src/shell/AppShell.tsx index e78d1ea1f0..c193beabea 100644 --- a/web/src/shell/AppShell.tsx +++ b/web/src/shell/AppShell.tsx @@ -73,6 +73,7 @@ import { } from "@/hooks/useSessionLiveness"; import { useResizableInlinePanel } from "@/hooks/useResizableInlinePanel"; import { useResizableSidebar } from "@/hooks/useResizableSidebar"; +import { useIsMobileViewport } from "@/hooks/useIsMobileViewport"; import { ChatHeader } from "./ChatHeader"; import { ExecutionLogsPanel } from "./ExecutionLogsPanel"; import { FileViewer } from "./FileViewer"; @@ -210,12 +211,7 @@ export function AppShell() { // Reads the same module-level store Sidebar drives, so the rail's ceiling // tracks the live sidebar width (including a drag) rather than a guess. const { width: sidebarWidth } = useResizableSidebar(); - const { panelWidth: inlinePanelWidth, handleProps: inlinePanelHandleProps } = - useResizableInlinePanel( - conversationId ?? null, - inlinePanelMinWidth, - sidebarOpen ? sidebarWidth : 0, - ); + const mobileViewport = useIsMobileViewport(); // ?sidebar=open surfaces the session list on phone-width shells where the // sidebar is closed by default — the destination for a "N sessions need // your attention" notification tap, which would otherwise land on a bare @@ -1497,6 +1493,13 @@ export function AppShell() { !executionLogsOpen && !filesPanelOpen, ); + const { panelWidth: inlinePanelWidth, handleProps: inlinePanelHandleProps } = + useResizableInlinePanel( + conversationId ?? null, + inlinePanelMinWidth, + sidebarOpen ? sidebarWidth : 0, + workspacePanelVisible && !mobileViewport, + ); return ( diff --git a/web/src/shell/WorkspacePanel.test.tsx b/web/src/shell/WorkspacePanel.test.tsx index 22110bb5d2..5563c22d3c 100644 --- a/web/src/shell/WorkspacePanel.test.tsx +++ b/web/src/shell/WorkspacePanel.test.tsx @@ -92,6 +92,7 @@ function renderWorkspace( selectedTerminalKey?: string | null; maximized?: boolean; liveness?: SessionLiveness; + handleProps?: React.HTMLAttributes & { tabIndex: number }; } = {}, ) { const openFileViewer = vi.fn(); @@ -105,7 +106,7 @@ function renderWorkspace( { expect(screen.queryByTestId("files-panel-stub")).toBeNull(); }); }); + +// ── Resize handle geometry ──────────────────────────────────────────────────── + +describe("WorkspacePanel resize handle geometry", () => { + const handleStyle = { + touchAction: "none", + boxSizing: "content-box", + paddingLeft: 10, + paddingRight: 12, + marginLeft: -6, + marginRight: -8, + backgroundClip: "content-box", + } as React.CSSProperties; + + it("renders the resize target as a dedicated flex gutter beside the clipped panel", () => { + renderWorkspace({ + handleProps: { + tabIndex: 0, + role: "separator", + "aria-label": "Resize panel", + style: handleStyle, + }, + }); + + const panel = screen.getByRole("complementary", { name: "Workspace" }); + const separator = screen.getByRole("separator", { name: "Resize panel" }); + + expect(separator).toHaveAttribute("data-workspace-panel-resize-gutter"); + expect(separator.nextElementSibling).toBe(panel); + expect(panel).toHaveClass("md:overflow-hidden"); + expect(panel).not.toContainElement(separator); + expect(separator).toHaveClass("shrink-0"); + }); + + it("keeps adjacent scroll surfaces outside the gutter ownership", () => { + const onPointerDown = vi.fn(); + renderWorkspace({ + handleProps: { + tabIndex: 0, + role: "separator", + "aria-label": "Resize panel", + style: handleStyle, + onPointerDown, + }, + }); + + const separator = screen.getByRole("separator", { name: "Resize panel" }); + expect(Math.abs(parseFloat(separator.style.marginLeft))).toBeLessThanOrEqual(6); + expect(Math.abs(parseFloat(separator.style.marginRight))).toBeLessThanOrEqual(8); + + const filesTab = screen.getByRole("tab", { name: "Files" }); + fireEvent.pointerDown(filesTab, { pointerId: 1, button: 0 }); + fireEvent.pointerDown(screen.getByTestId("files-panel-stub"), { pointerId: 2, button: 0 }); + expect(onPointerDown).not.toHaveBeenCalled(); + }); + + it("keeps the visible resize strip byte-identical", () => { + renderWorkspace({ + handleProps: { + tabIndex: 0, + role: "separator", + "aria-label": "Resize panel", + style: handleStyle, + }, + }); + + const separator = screen.getByRole("separator", { name: "Resize panel" }); + expect(separator.className).toBe( + "relative z-10 hidden w-1 shrink-0 cursor-col-resize transition-colors hover:bg-primary/30 active:bg-primary/50 md:block", + ); + expect(separator.style.boxSizing).toBe("content-box"); + expect(separator.style.backgroundClip).toBe("content-box"); + }); + + it("does not render the gutter when the hook marks it disabled", () => { + renderWorkspace({ + handleProps: { + tabIndex: 0, + role: "separator", + "aria-label": "Resize panel", + "aria-disabled": true, + style: handleStyle, + }, + }); + + expect(screen.queryByRole("separator", { name: "Resize panel" })).toBeNull(); + }); +}); diff --git a/web/src/shell/WorkspacePanel.tsx b/web/src/shell/WorkspacePanel.tsx index 03f2059cac..2b3e692eaf 100644 --- a/web/src/shell/WorkspacePanel.tsx +++ b/web/src/shell/WorkspacePanel.tsx @@ -668,40 +668,44 @@ export function WorkspacePanel({ [terminals], ); return ( - + ); }