From beaa23562275125153208cb9c232d57ede6f3e27 Mon Sep 17 00:00:00 2001 From: Bryan Li Date: Sat, 15 Aug 2026 07:45:51 -0700 Subject: [PATCH 1/6] feat(web): unify touch resize interactions Consolidate pointer, touch, stylus, and keyboard resize behavior across sidebar, inline rail, terminal column, push panels, and comments. Centralize responsive input capability and breakpoint handling for web and Android. Signed-off-by: Bryan Li Co-authored-by: omnigent --- .../comments/test_comments_panel_resize.py | 245 +++++++++ .../files/test_workspace_panel_resize.py | 85 ++++ .../sessions/test_inline_panel_resize.py | 137 +++++ tests/e2e_ui/sessions/test_sidebar_resize.py | 62 +++ .../shells/test_terminals_column_resize.py | 222 +++++++++ .../ai/omnigent/android/NativeBridgeScript.kt | 7 +- .../android/OmnigentWebViewClientTest.kt | 14 + web/src/hooks/useInputCapabilities.test.tsx | 113 +++++ web/src/hooks/useInputCapabilities.ts | 83 ++++ web/src/hooks/useIsMobileViewport.ts | 16 +- web/src/hooks/useResizableColumn.test.tsx | 269 ++++++++++ web/src/hooks/useResizableColumn.ts | 171 +++++-- .../hooks/useResizableCommentsPanel.test.tsx | 429 +++++++++++++++- web/src/hooks/useResizableCommentsPanel.ts | 210 ++++++-- .../hooks/useResizableInlinePanel.test.tsx | 405 +++++++++++++-- web/src/hooks/useResizableInlinePanel.ts | 189 ++++--- web/src/hooks/useResizablePanel.test.tsx | 297 ++++++++++- web/src/hooks/useResizablePanel.ts | 159 ++++-- web/src/hooks/useResizableSidebar.test.tsx | 268 +++++++++- web/src/hooks/useResizableSidebar.ts | 170 +++++-- web/src/lib/breakpoints.test.ts | 71 +++ web/src/lib/breakpoints.ts | 62 +++ web/src/pages/ChatPage.composer.test.tsx | 38 ++ web/src/pages/ChatPage.mention.test.tsx | 28 ++ web/src/pages/ChatPage.tsx | 13 +- web/src/shell/AppShell.test.tsx | 85 +++- web/src/shell/AppShell.tsx | 27 +- web/src/shell/CommentsPanel.test.tsx | 74 ++- web/src/shell/CommentsPanel.tsx | 273 +++++----- web/src/shell/ExecutionLogsPanel.test.tsx | 24 +- web/src/shell/ExecutionLogsPanel.tsx | 172 +++---- web/src/shell/FileViewer.test.tsx | 14 + web/src/shell/FileViewer.tsx | 43 +- web/src/shell/FilesPanelDrawer.test.tsx | 43 ++ web/src/shell/FilesPanelDrawer.tsx | 62 +-- web/src/shell/Sidebar.test.tsx | 107 +++- web/src/shell/Sidebar.tsx | 16 +- web/src/shell/TerminalsPanel.test.tsx | 76 +++ web/src/shell/TerminalsPanel.tsx | 197 ++++---- web/src/shell/WorkspacePanel.test.tsx | 91 +++- web/src/shell/WorkspacePanel.tsx | 467 +++++++++--------- 41 files changed, 4615 insertions(+), 919 deletions(-) create mode 100644 tests/e2e_ui/comments/test_comments_panel_resize.py create mode 100644 tests/e2e_ui/files/test_workspace_panel_resize.py create mode 100644 tests/e2e_ui/sessions/test_inline_panel_resize.py create mode 100644 tests/e2e_ui/sessions/test_sidebar_resize.py create mode 100644 tests/e2e_ui/shells/test_terminals_column_resize.py create mode 100644 web/src/hooks/useInputCapabilities.test.tsx create mode 100644 web/src/hooks/useInputCapabilities.ts create mode 100644 web/src/hooks/useResizableColumn.test.tsx create mode 100644 web/src/lib/breakpoints.test.ts create mode 100644 web/src/lib/breakpoints.ts create mode 100644 web/src/shell/FilesPanelDrawer.test.tsx diff --git a/tests/e2e_ui/comments/test_comments_panel_resize.py b/tests/e2e_ui/comments/test_comments_panel_resize.py new file mode 100644 index 0000000000..72f0ee78a7 --- /dev/null +++ b/tests/e2e_ui/comments/test_comments_panel_resize.py @@ -0,0 +1,245 @@ +"""E2E: resizing the CommentsPanel via its divider gutter. + +The comments panel is resized by dragging the slim divider gutter that sits +between the code/diff viewer and the panel (``role=separator`` labelled +"Resize comments panel"). The interaction is pointer-event driven (touch, +pen, and mouse share the same handlers), the chosen width persists across a +reload, and the gutter's invisible hit slivers are capped so pointer input +on the viewer beside the gutter must never start a resize. + +Covered here: + + 1. Dragging the gutter leftward widens the panel to track the pointer + (width = panel right edge − pointer x). + 2. The dragged width survives a full page reload (persisted preference). + 3. NEGATIVE: a press-and-drag over the viewer, left of the gutter's capped + hit sliver, does not resize the panel. + +If this goes red, the likely regression is in ``useResizableCommentsPanel`` +(pointer capture / clamp / persistence) or in CommentsPanel's divider-gutter +markup (the gutter must stay a flex sibling between viewer and panel). +""" + +from __future__ import annotations + +from collections.abc import Iterator + +import httpx +import pytest +from playwright.sync_api import Locator, Page, expect + +# --------------------------------------------------------------------------- +# Test constants +# --------------------------------------------------------------------------- + +_FILE_PATH = "resize_target.md" + +# Anchor paragraph for the seeded comment; appears exactly once so the stored +# offsets unambiguously match the file content. +_ANCHOR_TEXT = "Resize gutter anchor paragraph." + +_FILE_CONTENT = f"""\ +# Comments Resize Test + +{_ANCHOR_TEXT} + +Closing paragraph with filler text. +""" + +_COMMENT_BODY = "Comment pinning the panel open for the resize test." + +# Wide desktop viewport: the FileViewer rail must have enough row space that +# a leftward drag can widen the panel without hitting the dynamic clamp +# (which reserves 240px for the viewer beside the gutter). +_DESKTOP_VIEWPORT = {"width": 1728, "height": 1080} + +# Preferred leftward drag distance; shrunk at runtime if the viewer has less +# spare room than this above its 240px minimum. +_DRAG_PX = 100 + +# The viewer's clamp-protected minimum width (MIN_VIEWER_PX in +# useResizableCommentsPanel.ts) plus slack, so the capped drag never lands in +# clamp territory and the tracking assertion stays exact. +_VIEWER_MIN_PX = 240 +_CLAMP_SLACK_PX = 20 + +# Pixel tolerance for width assertions (sub-pixel layout rounding). +_TOLERANCE = 3.0 + +# The gutter's invisible hit sliver may overhang the viewer by at most this +# many px (VIEWER_SLIVER_PX in useResizableCommentsPanel.ts). The negative +# probe presses just left of this budget, measured from the VIEWER's right +# edge — never from the gutter's own box, which a hit-region regression +# would move (dragging the probe along with it and masking the leak). +_VIEWER_SLIVER_BUDGET_PX = 10 + +# Safety margin between the budget line and the probe press point, absorbing +# sub-pixel layout rounding while staying close enough to catch a sliver +# that grows even a few px past its cap. +_PROBE_MARGIN_PX = 4 + + +# --------------------------------------------------------------------------- +# Fixture +# --------------------------------------------------------------------------- + + +@pytest.fixture +def commented_session( + seeded_session: tuple[str, str], +) -> Iterator[tuple[str, str, str]]: + """Seed a markdown file plus one open comment via REST. + + The ``?comment={id}`` deep link then opens the file with the comments + panel already visible — no in-browser selection dance needed. + + :param seeded_session: Base fixture providing a runner-bound + ``(base_url, session_id)`` pair. + :returns: ``(base_url, session_id, comment_id)``. + """ + base_url, session_id = seeded_session + file_url = ( + f"{base_url}/v1/sessions/{session_id}" + f"/resources/environments/default/filesystem/{_FILE_PATH}" + ) + httpx.put( + file_url, + json={"content": _FILE_CONTENT, "encoding": "utf-8"}, + timeout=10.0, + ).raise_for_status() + + start = _FILE_CONTENT.find(_ANCHOR_TEXT) + assert start != -1, "fixture bug: anchor text missing from file content" + resp = httpx.post( + f"{base_url}/v1/sessions/{session_id}/comments", + json={ + "path": _FILE_PATH, + "body": _COMMENT_BODY, + "start_index": start, + "end_index": start + len(_ANCHOR_TEXT), + "anchor_content": _ANCHOR_TEXT, + }, + timeout=10.0, + ) + resp.raise_for_status() + yield (base_url, session_id, resp.json()["id"]) + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _open_panel(page: Page, base_url: str, session_id: str, comment_id: str) -> Locator: + """Deep-link into the file with the comments panel open; return the gutter. + + :returns: The divider-gutter separator locator, ready to drag. + """ + page.goto(f"{base_url}/c/{session_id}?file={_FILE_PATH}&comment={comment_id}") + file_viewer = page.locator('[data-testid="file-viewer"]:visible') + expect(file_viewer).to_be_visible(timeout=30_000) + expect(file_viewer).to_contain_text(_COMMENT_BODY, timeout=15_000) + + separator = file_viewer.get_by_role("separator", name="Resize comments panel") + expect(separator).to_be_visible() + return separator + + +def _panel_width(separator: Locator) -> float: + """Measure the panel root (the gutter's next sibling) rendered width.""" + return separator.evaluate("el => el.nextElementSibling.getBoundingClientRect().width") + + +def _panel_right(separator: Locator) -> float: + """Measure the panel root's right edge (fixed during a drag).""" + return separator.evaluate("el => el.nextElementSibling.getBoundingClientRect().right") + + +def _viewer_width(separator: Locator) -> float: + """Measure the code/diff viewer's rendered width (the gutter's preceding sibling).""" + return separator.evaluate("el => el.previousElementSibling.getBoundingClientRect().width") + + +def _viewer_right(separator: Locator) -> float: + """Measure the code/diff viewer's right edge (the gutter's preceding sibling). + + The negative probe anchors here: the viewer's own box marks the seam the + layout owns, independent of how far the gutter's hit sliver actually + reaches — so a hit-region regression cannot move the probe with it. + """ + return separator.evaluate("el => el.previousElementSibling.getBoundingClientRect().right") + + +# --------------------------------------------------------------------------- +# Test +# --------------------------------------------------------------------------- + + +def test_gutter_drag_resizes_panel_and_persists( + page: Page, + commented_session: tuple[str, str, str], +) -> None: + """Drag the divider gutter, verify the width tracks, persists, and is scoped.""" + base_url, session_id, comment_id = commented_session + page.set_viewport_size(_DESKTOP_VIEWPORT) + separator = _open_panel(page, base_url, session_id, comment_id) + + box = separator.bounding_box() + assert box is not None, "separator has no layout box" + start_x = box["x"] + box["width"] / 2 + y = box["y"] + box["height"] / 2 + panel_right = _panel_right(separator) + initial_width = _panel_width(separator) + + # Keep the drag inside the un-clamped window: the hook stops widening the + # panel once the viewer would drop below its 240px minimum, so cap the + # distance by the viewer's spare room to keep the tracking check exact. + spare = _viewer_width(separator) - _VIEWER_MIN_PX - _CLAMP_SLACK_PX + drag_px = min(_DRAG_PX, spare) + assert drag_px >= 40, ( + f"viewer too narrow ({_viewer_width(separator)}px) to exercise an " + f"un-clamped drag at {_DESKTOP_VIEWPORT['width']}px viewport" + ) + + # 1. Drag leftward: the hook derives width from the panel's right edge and + # the pointer position, so the final width must track the release point. + end_x = start_x - drag_px + page.mouse.move(start_x, y) + page.mouse.down() + page.mouse.move(end_x, y, steps=8) + page.mouse.up() + + dragged_width = _panel_width(separator) + expected = panel_right - end_x + assert abs(dragged_width - expected) <= _TOLERANCE, ( + f"panel width {dragged_width} does not track the pointer release at " + f"{end_x} (expected ~{expected}, started at {initial_width})" + ) + + # 2. The released width is a persisted preference: it must survive a full + # reload (fresh React tree, width restored from storage). + page.reload() + separator = _open_panel(page, base_url, session_id, comment_id) + reloaded_width = _panel_width(separator) + assert abs(reloaded_width - dragged_width) <= _TOLERANCE, ( + f"panel width {reloaded_width} after reload lost the dragged width {dragged_width}" + ) + + # 3. NEGATIVE: press-and-drag over the viewer, just left of the gutter's + # sliver budget, must not resize — the viewer keeps its own pointer + # stream (text selection / scrollbar). The probe is anchored to the + # VIEWER's right edge plus the fixed budget, NOT to the separator's own + # box: a regression that widens the hit sliver would shift that box and + # carry a box-relative probe out of harm's way, hiding the leak. + outside_x = _viewer_right(separator) - _VIEWER_SLIVER_BUDGET_PX - _PROBE_MARGIN_PX + page.mouse.move(outside_x, y) + page.mouse.down() + page.mouse.move(outside_x - 80, y, steps=5) + page.mouse.up() + + final_width = _panel_width(separator) + assert abs(final_width - reloaded_width) <= _TOLERANCE, ( + f"a drag starting {_PROBE_MARGIN_PX}px outside the {_VIEWER_SLIVER_BUDGET_PX}px " + f"viewer-side sliver budget resized the panel " + f"({reloaded_width} -> {final_width}); the hit sliver leaked over the viewer" + ) diff --git a/tests/e2e_ui/files/test_workspace_panel_resize.py b/tests/e2e_ui/files/test_workspace_panel_resize.py new file mode 100644 index 0000000000..ee6317bece --- /dev/null +++ b/tests/e2e_ui/files/test_workspace_panel_resize.py @@ -0,0 +1,85 @@ +"""E2E coverage for the workspace push-panel resize seam.""" + +from __future__ import annotations + +from playwright.sync_api import Page, expect + +_FINE_GUTTER_PX = 10 + + +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 _open_execution_logs_panel(page: Page) -> None: + expect(page.get_by_test_id("execution-logs-card")).to_be_visible(timeout=30_000) + page.get_by_test_id("execution-log-row-main").click() + expect(page.get_by_test_id("execution-logs-panel")).to_be_visible() + + +def test_workspace_panel_pointer_resize_persists_without_annexing_chat( + page: Page, + seeded_session: tuple[str, str], +) -> None: + """Resize the workspace panel while adjacent chat input stays inert.""" + base_url, session_id = seeded_session + page.set_viewport_size({"width": 1440, "height": 900}) + page.goto(f"{base_url}/c/{session_id}?debug=1") + _open_execution_logs_panel(page) + + panel = page.get_by_test_id("execution-logs-panel") + handle = page.get_by_role("separator", name="Resize panel") + initial_width = panel.bounding_box()["width"] + handle_box = handle.bounding_box() + + _touch_drag( + page, + start=(handle_box["x"] + handle_box["width"] / 2, handle_box["y"] + 100), + end=(handle_box["x"] - 80, handle_box["y"] + 100), + ) + + resized_width = panel.bounding_box()["width"] + assert resized_width >= initial_width + 70 + + panel_box = panel.bounding_box() + probe_x = panel_box["x"] - _FINE_GUTTER_PX - 8 + probe_y = panel_box["y"] + panel_box["height"] / 2 + assert panel.evaluate( + """(panel, point) => { + const target = document.elementFromPoint(point.x, point.y); + if (!target || panel.contains(target) || target.closest('[role="separator"]')) { + return false; + } + target.addEventListener( + 'pointerdown', + () => document.documentElement.dataset.resizeProbeReceived = 'true', + { once: true }, + ); + return true; + }""", + {"x": probe_x, "y": probe_y}, + ), "chat-side probe landed on the panel resize handle" + page.mouse.move(probe_x, probe_y) + page.mouse.down() + page.mouse.move(probe_x - 12, probe_y, steps=2) + page.mouse.up() + expect(page.locator("html")).to_have_attribute("data-resize-probe-received", "true") + assert abs(panel.bounding_box()["width"] - resized_width) <= 1 + + page.reload() + _open_execution_logs_panel(page) + persisted_width = page.get_by_test_id("execution-logs-panel").bounding_box()["width"] + assert abs(persisted_width - resized_width) <= 1 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/sessions/test_sidebar_resize.py b/tests/e2e_ui/sessions/test_sidebar_resize.py new file mode 100644 index 0000000000..a244e75354 --- /dev/null +++ b/tests/e2e_ui/sessions/test_sidebar_resize.py @@ -0,0 +1,62 @@ +"""E2E coverage for resizing the desktop Conversations sidebar.""" + +from __future__ import annotations + +from playwright.sync_api import Page, expect + +_CONVERSATIONS = 'aside[aria-label="Conversations"]' +_RESIZE_HANDLE = '[data-testid="sidebar-resize-handle"]' + + +def _width(page: Page) -> float: + box = page.locator(_CONVERSATIONS).bounding_box() + assert box is not None + return box["width"] + + +def test_sidebar_resize_persists_without_annexing_chat_scroll( + page: Page, + seeded_session: tuple[str, str], +) -> None: + base_url, session_id = seeded_session + page.set_viewport_size({"width": 1400, "height": 800}) + page.goto(f"{base_url}/c/{session_id}") + + sidebar = page.locator(_CONVERSATIONS) + handle = page.locator(_RESIZE_HANDLE) + expect(sidebar).to_be_visible(timeout=30_000) + expect(handle).to_be_visible() + + initial_width = _width(page) + handle_box = handle.bounding_box() + assert handle_box is not None + handle_x = handle_box["x"] + handle_box["width"] / 2 + handle_y = handle_box["y"] + handle_box["height"] / 2 + target_x = initial_width + 80 + + # Playwright's mouse input emits the pointer events used by the resize hook. + page.mouse.move(handle_x, handle_y) + page.mouse.down() + page.mouse.move(target_x, handle_y, steps=5) + page.mouse.up() + + resized_width = _width(page) + assert abs(resized_width - target_x) < 2, (resized_width, target_x) + + page.reload() + expect(handle).to_be_visible(timeout=30_000) + persisted_width = _width(page) + assert abs(persisted_width - resized_width) < 2, (persisted_width, resized_width) + + # A scroll start beside the handle belongs to chat and must not resize. + sidebar_box = sidebar.bounding_box() + handle_box = handle.bounding_box() + assert sidebar_box is not None and handle_box is not None + chat_x = sidebar_box["x"] + sidebar_box["width"] + 10 + scroll_y = handle_box["y"] + handle_box["height"] / 2 + page.mouse.move(chat_x, scroll_y) + page.mouse.down() + page.mouse.move(chat_x, scroll_y + 80, steps=5) + page.mouse.up() + + assert abs(_width(page) - persisted_width) < 1, (_width(page), persisted_width) diff --git a/tests/e2e_ui/shells/test_terminals_column_resize.py b/tests/e2e_ui/shells/test_terminals_column_resize.py new file mode 100644 index 0000000000..d0026a5278 --- /dev/null +++ b/tests/e2e_ui/shells/test_terminals_column_resize.py @@ -0,0 +1,222 @@ +"""E2E: resizing the terminals-panel list column by pointer and keyboard. + +The full-screen Shells panel (``TerminalsPanel``) splits into a terminal +list column and an xterm pane on desktop, divided by a draggable handle +(``useResizableColumn``). The handle is a pointer-events separator: it +captures the pointer on pointerdown (so drags keep tracking off the thin +strip), carries ``touch-action: none`` plus an invisible widened hit +target for touch, and is a focusable ARIA separator resizable with +ArrowLeft/ArrowRight under the same 100–480px clamps as dragging. + +The panel's only UI entry point is the mobile Shells drawer (desktop +opens shells as rail tabs instead), while the column split needs a +desktop viewport — so the test opens the panel at a phone viewport and +then widens the window, which is also a real tablet-rotation scenario +the handle must survive. + +Terminals are launched over REST (the same runner path as +``sys_terminal_launch``) so no LLM turn is involved and the flow is +deterministic. +""" + +from __future__ import annotations + +import re +import time + +import httpx +from playwright.sync_api import Page, ViewportSize, expect + +# Below Tailwind's ``md`` (768px) so the FAB/mobile drawer renders. +_MOBILE_VIEWPORT: ViewportSize = {"width": 390, "height": 844} +# Comfortably above ``md`` so the panel splits into list + xterm columns. +_DESKTOP_VIEWPORT: ViewportSize = {"width": 1400, "height": 900} + +# useResizableColumn defaults: initial 176, clamps [100, 480], 20px/keypress. +_DEFAULT_WIDTH = 176 +_KEY_STEP = 20 +_MAX_WIDTH = 480 +_DRAG_TARGET_WIDTH = 256 + + +def _launch_terminal(base_url: str, session_id: str, session_key: str) -> None: + """Launch an agent-declared ``zsh`` terminal via REST (no LLM turn).""" + resp = httpx.post( + f"{base_url}/v1/sessions/{session_id}/resources/terminals", + json={"terminal": "zsh", "session_key": session_key}, + timeout=60.0, + ) + resp.raise_for_status() + + +def _force_chat_first(base_url: str, session_id: str, timeout_s: float = 15.0) -> None: + """Pin the session to the chat-first presentation before the page loads. + + When the runner starts hosting an SDK session it auto-creates an + embedded Omnigent REPL terminal and stamps ``omnigent.ui: terminal`` + on the session. A session carrying that label renders terminal-first: + tapping a shell row opens the shell in the MAIN view and the + full-screen ``TerminalsPanel`` (the surface with the resizable column + split under test) never mounts — so whether this test's entry path + works depends on a race between the runner's stamp and the page load. + Wait briefly for the one-time stamp (the terminal launches already + forced the runner's session init, which runs the REPL ensure + stamp + inline, so it lands within seconds when it lands at all), then delete + the label (empty value clears it; the runner never re-stamps — the + REPL-terminal ensure is guarded) so the entry path is + deterministically chat-first. + """ + deadline = time.monotonic() + timeout_s + while time.monotonic() < deadline: + resp = httpx.get(f"{base_url}/v1/sessions/{session_id}/labels", timeout=10.0) + resp.raise_for_status() + if resp.json().get("labels", {}).get("omnigent.ui") == "terminal": + break + time.sleep(0.5) + # Delete regardless: if the stamp never landed (REPL creation failed, + # a logged warning path that also never retries), the label is simply + # absent and the delete is a no-op — either way chat-first from here. + resp = httpx.patch( + f"{base_url}/v1/sessions/{session_id}", + json={"labels": {"omnigent.ui": ""}}, + timeout=10.0, + ) + resp.raise_for_status() + resp = httpx.get(f"{base_url}/v1/sessions/{session_id}/labels", timeout=10.0) + resp.raise_for_status() + assert resp.json().get("labels", {}).get("omnigent.ui") != "terminal" + + +def _open_terminals_panel_on_desktop(page: Page, base_url: str, session_id: str) -> None: + """Open the full-screen Shells panel, then widen to a desktop viewport. + + Mobile flow (FAB → Shells drawer → tap the ``main`` shell row) is the + panel's entry point; the subsequent viewport widening flips the panel + into its desktop list/xterm split where the column handle renders. + """ + page.set_viewport_size(_MOBILE_VIEWPORT) + page.goto(f"{base_url}/c/{session_id}") + + fab = page.get_by_role("button", name="Open session menu") + expect(fab).to_be_visible(timeout=15_000) + fab.click() + # Accessible name includes the shell-count badge (e.g. "Shells 2"). + shells_entry = page.get_by_role("menuitem", name=re.compile(r"^Shells\b")) + expect(shells_entry).to_be_visible(timeout=10_000) + shells_entry.click() + + drawer = page.get_by_test_id("shells-panel-drawer") + expect(drawer).to_have_attribute("data-state", "open") + row = drawer.get_by_role("button").filter(has_text="main").filter(has_text="zsh") + expect(row.first).to_be_visible(timeout=30_000) + row.first.click() + + panel = page.get_by_test_id("terminals-panel") + expect(panel).to_have_attribute("data-state", "open") + + page.set_viewport_size(_DESKTOP_VIEWPORT) + + +def _separator(page: Page): + """The column-resize handle, scoped to the terminals panel.""" + return page.get_by_test_id("terminals-panel").get_by_role( + "separator", name="Resize terminal list" + ) + + +def _list_panel_width(page: Page) -> float: + """Measured width of the terminal-list column (the handle's next sibling).""" + return _separator(page).evaluate("el => el.nextElementSibling.getBoundingClientRect().width") + + +def test_terminals_column_resizes_by_pointer_and_keyboard( + page: Page, + terminal_session: tuple[str, str], +) -> None: + """Drag, keyboard-resize, and row-tap isolation of the list column. + + One flow (panel setup is the expensive part): drag the handle to a new + width and verify it sticks after release, step it with arrow keys on + the focused separator, and confirm interactions with adjacent list + rows — tapping a row, wheel-scrolling over the list — never resize. + """ + base_url, session_id = terminal_session + # Two shells so the negative row-tap check can select a non-active row + # (tapping the active row toggles the xterm closed, hiding the handle). + _launch_terminal(base_url, session_id, "main") + _launch_terminal(base_url, session_id, "aux") + _force_chat_first(base_url, session_id) + + _open_terminals_panel_on_desktop(page, base_url, session_id) + + handle = _separator(page) + expect(handle).to_be_visible(timeout=30_000) + + # Touch affordance: competing gestures must not pan/zoom during a drag. + assert handle.evaluate("el => getComputedStyle(el).touchAction") == "none" + expect(handle).to_have_attribute("aria-valuenow", str(_DEFAULT_WIDTH)) + assert abs(_list_panel_width(page) - _DEFAULT_WIDTH) < 2 + + # --- Pointer drag: press on the handle, pull right, release. The width + # follows the pointer (measured from the split row's left edge) and the + # new width persists after the pointer lifts. hover() first: the panel + # slides in (translate transition), and hover's actionability check waits + # for the handle's position to stabilize before we read its box. + handle.hover() + box = handle.bounding_box() + assert box is not None, "resize handle should have a bounding box" + container_left = handle.evaluate("el => el.parentElement.getBoundingClientRect().left") + start_y = box["y"] + box["height"] / 2 + page.mouse.move(box["x"] + box["width"] / 2, start_y) + page.mouse.down() + page.mouse.move(container_left + _DRAG_TARGET_WIDTH, start_y, steps=8) + page.mouse.up() + + expect(handle).to_have_attribute("aria-valuenow", str(_DRAG_TARGET_WIDTH)) + assert abs(_list_panel_width(page) - _DRAG_TARGET_WIDTH) < 2 + + # Persists after release: pointer movement without a pressed button + # must not keep resizing (the drag really ended on pointerup). + page.mouse.move(container_left + 400, start_y) + expect(handle).to_have_attribute("aria-valuenow", str(_DRAG_TARGET_WIDTH)) + + # --- Keyboard: the handle is a focusable separator; arrow keys step the + # width by 20px under the same clamps as dragging. + handle.focus() + page.keyboard.press("ArrowLeft") + expect(handle).to_have_attribute("aria-valuenow", str(_DRAG_TARGET_WIDTH - _KEY_STEP)) + page.keyboard.press("ArrowRight") + page.keyboard.press("ArrowRight") + keyboard_width = _DRAG_TARGET_WIDTH + _KEY_STEP + expect(handle).to_have_attribute("aria-valuenow", str(keyboard_width)) + assert abs(_list_panel_width(page) - keyboard_width) < 2 + + # Clamp: enough presses to overshoot maxWidth (480) pin the width there; + # one more press must not push past it. + for _ in range((_MAX_WIDTH - keyboard_width) // _KEY_STEP + 2): + page.keyboard.press("ArrowRight") + expect(handle).to_have_attribute("aria-valuenow", str(_MAX_WIDTH)) + page.keyboard.press("ArrowRight") + expect(handle).to_have_attribute("aria-valuenow", str(_MAX_WIDTH)) + assert abs(_list_panel_width(page) - _MAX_WIDTH) < 2 + keyboard_width = _MAX_WIDTH + + # --- Negative: interacting with the list next to the handle must not + # resize. Tapping the ``aux`` row (its center, well clear of the handle's + # invisible hit pad at the column boundary) selects that shell... + panel = page.get_by_test_id("terminals-panel") + aux_row = panel.get_by_role("button").filter(has_text="aux").filter(has_text="zsh") + expect(aux_row.first).to_be_visible() + aux_row.first.click() + # Anchored: the inactive row carries hover:bg-accent/60, which a bare + # "bg-accent" substring would also match. + expect(aux_row.first).to_have_class(re.compile(r"(?:^|\s)bg-accent(?:\s|$)")) + + # ...and a wheel scroll over the list is plain scrolling, not a resize. + row_box = aux_row.first.bounding_box() + assert row_box is not None + page.mouse.move(row_box["x"] + row_box["width"] / 2, row_box["y"] + 5) + page.mouse.wheel(0, 120) + + expect(handle).to_have_attribute("aria-valuenow", str(keyboard_width)) + assert abs(_list_panel_width(page) - keyboard_width) < 2 diff --git a/web/android/app/src/main/java/ai/omnigent/android/NativeBridgeScript.kt b/web/android/app/src/main/java/ai/omnigent/android/NativeBridgeScript.kt index e0d0ee242a..dcfe0c6c9c 100644 --- a/web/android/app/src/main/java/ai/omnigent/android/NativeBridgeScript.kt +++ b/web/android/app/src/main/java/ai/omnigent/android/NativeBridgeScript.kt @@ -158,7 +158,12 @@ object NativeBridgeScript { // (overlays) Back should dismiss; at md+ they dock as persistent // rails (md:relative md:translate-x-0, still data-state="open") // that Back must NOT close. Modal dialogs dismiss at any width. - const narrow = window.innerWidth < 768; + // The web layer owns the breakpoint (web/src/lib/breakpoints.ts) + // and publishes it as __omnigentIsMobileViewport; the inline + // check is a fallback for older web builds without the signal. + const narrow = typeof window.__omnigentIsMobileViewport === "function" + ? !!window.__omnigentIsMobileViewport() + : window.innerWidth < 768; // 1. Conversations sidebar (a drawer only when narrow; closed = // data-collapsed). if (narrow) { diff --git a/web/android/app/src/test/java/ai/omnigent/android/OmnigentWebViewClientTest.kt b/web/android/app/src/test/java/ai/omnigent/android/OmnigentWebViewClientTest.kt index 329f0d5a63..c9fcf7f720 100644 --- a/web/android/app/src/test/java/ai/omnigent/android/OmnigentWebViewClientTest.kt +++ b/web/android/app/src/test/java/ai/omnigent/android/OmnigentWebViewClientTest.kt @@ -78,6 +78,20 @@ class OmnigentWebViewClientTest { ) } + @Test + fun `back handler consumes the web layer's breakpoint signal`() { + // The web layer owns the md breakpoint (web/src/lib/breakpoints.ts) and + // publishes __omnigentIsMobileViewport; the back handler must consult it + // rather than re-derive the drawer/rail boundary from its own literal. + val backHandler = + NativeBridgeScript.source + .substringAfter("__omnigentNativeHandleBack") + assertTrue(backHandler.contains("window.__omnigentIsMobileViewport === \"function\"")) + assertTrue(backHandler.contains("window.__omnigentIsMobileViewport()")) + // The inline width check survives only as the older-web-build fallback. + assertTrue(backHandler.contains(": window.innerWidth < 768")) + } + @Test fun `off-origin page finish injects nothing`() { val webView = RecordingWebView(ApplicationProvider.getApplicationContext()) diff --git a/web/src/hooks/useInputCapabilities.test.tsx b/web/src/hooks/useInputCapabilities.test.tsx new file mode 100644 index 0000000000..7c12aeb06d --- /dev/null +++ b/web/src/hooks/useInputCapabilities.test.tsx @@ -0,0 +1,113 @@ +import { act, renderHook } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { useInputCapabilities } from "./useInputCapabilities"; + +// Controllable matchMedia: per-query matches plus manually fired change +// events, so the hook's reactivity can be exercised (a convertible flipping +// modes, a mouse attaching). +function installMatchMedia(state: Record) { + const listeners = new Map void>>(); + Object.defineProperty(window, "matchMedia", { + writable: true, + configurable: true, + value: vi.fn((query: string) => ({ + get matches() { + return state[query] ?? false; + }, + media: query, + addEventListener: (_: string, cb: () => void) => { + if (!listeners.has(query)) listeners.set(query, new Set()); + listeners.get(query)!.add(cb); + }, + removeEventListener: (_: string, cb: () => void) => { + listeners.get(query)?.delete(cb); + }, + })), + }); + return { + set(query: string, matches: boolean) { + state[query] = matches; + for (const cb of listeners.get(query) ?? []) cb(); + }, + }; +} + +function setMaxTouchPoints(value: number) { + Object.defineProperty(navigator, "maxTouchPoints", { + configurable: true, + value, + }); +} + +afterEach(() => { + setMaxTouchPoints(0); +}); + +describe("useInputCapabilities", () => { + it("reports a mouse desktop when no query matches", () => { + installMatchMedia({}); + const { result } = renderHook(() => useInputCapabilities()); + expect(result.current).toEqual({ + coarsePrimary: false, + anyCoarse: false, + hoverPrimary: false, + hasTouch: false, + }); + }); + + it("reads each capability from its media query and maxTouchPoints", () => { + installMatchMedia({ + "(pointer: coarse)": true, + "(any-pointer: coarse)": true, + "(hover: hover)": false, + }); + setMaxTouchPoints(5); + const { result } = renderHook(() => useInputCapabilities()); + expect(result.current).toEqual({ + coarsePrimary: true, + anyCoarse: true, + hoverPrimary: false, + hasTouch: true, + }); + }); + + it("keeps viewport-independent axes independent: a fine-primary touch laptop", () => { + // any-pointer coarse (touchscreen present) with a fine hovering primary + // (trackpad) — TR-2's touch-laptop shape must be representable. + installMatchMedia({ + "(any-pointer: coarse)": true, + "(hover: hover)": true, + }); + setMaxTouchPoints(10); + const { result } = renderHook(() => useInputCapabilities()); + expect(result.current).toEqual({ + coarsePrimary: false, + anyCoarse: true, + hoverPrimary: true, + hasTouch: true, + }); + }); + + it("updates live when a media query flips (convertible mode change)", () => { + const media = installMatchMedia({ "(hover: hover)": true }); + const { result } = renderHook(() => useInputCapabilities()); + expect(result.current.coarsePrimary).toBe(false); + expect(result.current.hoverPrimary).toBe(true); + + act(() => { + media.set("(pointer: coarse)", true); + media.set("(hover: hover)", false); + }); + expect(result.current.coarsePrimary).toBe(true); + expect(result.current.hoverPrimary).toBe(false); + }); + + it("returns a referentially stable snapshot while values are unchanged", () => { + installMatchMedia({ "(hover: hover)": true }); + const { result, rerender } = renderHook(() => useInputCapabilities()); + const first = result.current; + rerender(); + expect(result.current).toBe(first); + }); +}); diff --git a/web/src/hooks/useInputCapabilities.ts b/web/src/hooks/useInputCapabilities.ts new file mode 100644 index 0000000000..dad04b9559 --- /dev/null +++ b/web/src/hooks/useInputCapabilities.ts @@ -0,0 +1,83 @@ +// Single source of truth for pointer/input capability. +// +// Capability gates AFFORDANCES and defaults only (hit-target sizing, +// persistent vs hover-revealed controls, swipe hints) — never per-event +// handling. Gesture recognition must instead branch on the active sequence's +// `PointerEvent.pointerType`, so a touch on a fine-primary laptop still gets +// gesture semantics regardless of what these queries report. Viewport width +// is an independent LAYOUT axis — see `@/lib/breakpoints`. + +import { useSyncExternalStore } from "react"; + +import { subscribeMatchMedia } from "@/lib/breakpoints"; + +export interface InputCapabilities { + /** Primary pointer is coarse — `(pointer: coarse)`. */ + coarsePrimary: boolean; + /** ANY attached pointer is coarse — `(any-pointer: coarse)`. */ + anyCoarse: boolean; + /** Primary pointer can hover — `(hover: hover)`. */ + hoverPrimary: boolean; + /** + * A touch digitizer is present — `navigator.maxTouchPoints > 0`. Re-read + * only when a capability media query fires; a digitizer change that flips + * no query is not observed (accepted point-in-time limitation). + */ + hasTouch: boolean; +} + +const CAPABILITY_QUERIES = [ + "(pointer: coarse)", + "(any-pointer: coarse)", + "(hover: hover)", +] as const; + +// SSR / no-matchMedia fallback: assume a hovering fine pointer (mouse +// desktop), matching the shell's historical desktop-first defaults. +const SERVER_SNAPSHOT: InputCapabilities = { + coarsePrimary: false, + anyCoarse: false, + hoverPrimary: true, + hasTouch: false, +}; + +function read(): InputCapabilities { + if (typeof window === "undefined" || !window.matchMedia) return SERVER_SNAPSHOT; + const [coarse, anyCoarse, hover] = CAPABILITY_QUERIES.map((q) => window.matchMedia(q).matches); + return { + coarsePrimary: coarse, + anyCoarse, + hoverPrimary: hover, + hasTouch: navigator.maxTouchPoints > 0, + }; +} + +// useSyncExternalStore requires a referentially stable snapshot while values +// are unchanged; re-reading is cheap, so compare and keep the old object. +let cached: InputCapabilities = SERVER_SNAPSHOT; + +function getSnapshot(): InputCapabilities { + const next = read(); + if ( + next.coarsePrimary !== cached.coarsePrimary || + next.anyCoarse !== cached.anyCoarse || + next.hoverPrimary !== cached.hoverPrimary || + next.hasTouch !== cached.hasTouch + ) { + cached = next; + } + return cached; +} + +function subscribe(callback: () => void): () => void { + return subscribeMatchMedia(CAPABILITY_QUERIES, callback); +} + +/** + * Reactive input-capability snapshot. Updates live when a convertible flips + * modes or a mouse attaches/detaches (matchMedia change events). SSR-safe: + * the server snapshot assumes a mouse desktop. + */ +export function useInputCapabilities(): InputCapabilities { + return useSyncExternalStore(subscribe, getSnapshot, () => SERVER_SNAPSHOT); +} diff --git a/web/src/hooks/useIsMobileViewport.ts b/web/src/hooks/useIsMobileViewport.ts index 70dcbd3166..f3965cbc9a 100644 --- a/web/src/hooks/useIsMobileViewport.ts +++ b/web/src/hooks/useIsMobileViewport.ts @@ -3,25 +3,21 @@ // The shell's responsive layout pivots on Tailwind's `md` breakpoint // (`min-width: 768px`), used both as CSS classes (`md:` / `max-md:`) and as // the JS threshold in AppShell's `initialSidebarOpen`. This hook exposes the -// `max-md` side of that line to component logic that can't be expressed in +// mobile side of that line to component logic that can't be expressed in // CSS alone (e.g. swapping a hover flyout for an in-place page on touch). import { useSyncExternalStore } from "react"; -// Mirror Tailwind's `max-md` variant exactly so this hook stays in lockstep -// with the `max-md:` / `md:` classes already used across the shell. -const MOBILE_QUERY = "(max-width: 767.98px)"; +import { MD_MIN_WIDTH_QUERY, isMobileViewport, subscribeMatchMedia } from "@/lib/breakpoints"; function subscribe(callback: () => void): () => void { - if (typeof window === "undefined" || !window.matchMedia) return () => {}; - const mql = window.matchMedia(MOBILE_QUERY); - mql.addEventListener("change", callback); - return () => mql.removeEventListener("change", callback); + return subscribeMatchMedia([MD_MIN_WIDTH_QUERY], callback); } +// One canonical predicate: the snapshot IS the imperative helper, so the +// reactive and point-in-time answers can never diverge. function getSnapshot(): boolean { - if (typeof window === "undefined" || !window.matchMedia) return false; - return window.matchMedia(MOBILE_QUERY).matches; + return isMobileViewport(); } /** diff --git a/web/src/hooks/useResizableColumn.test.tsx b/web/src/hooks/useResizableColumn.test.tsx new file mode 100644 index 0000000000..3ff0d1b7d8 --- /dev/null +++ b/web/src/hooks/useResizableColumn.test.tsx @@ -0,0 +1,269 @@ +import { act, renderHook } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { useResizableColumn } from "./useResizableColumn"; + +function pointerEvent( + pointerId: number, + clientX = 0, + setPointerCapture = vi.fn(), + { button = 0, pointerType = "touch" } = {}, +): React.PointerEvent & { setPointerCapture: ReturnType } { + return { + pointerId, + clientX, + button, + pointerType, + preventDefault: vi.fn(), + currentTarget: { setPointerCapture }, + setPointerCapture, + } as unknown as React.PointerEvent & { setPointerCapture: ReturnType }; +} + +function keyEvent(key: string) { + return { key, preventDefault: vi.fn() } as unknown as React.KeyboardEvent & { + preventDefault: ReturnType; + }; +} + +/** Render the hook with a container anchored at the given viewport left edge. */ +function renderColumn(containerLeft = 0) { + const rendered = renderHook(() => useResizableColumn()); + rendered.result.current.containerRef.current = { + getBoundingClientRect: () => ({ left: containerLeft }), + } as HTMLElement; + return rendered; +} + +afterEach(() => { + document.body.style.cursor = ""; + document.body.style.userSelect = ""; +}); + +describe("useResizableColumn pointer dragging", () => { + it("captures the pointer on pointerdown and tracks moves on the captured element", () => { + const { result } = renderColumn(100); + const down = pointerEvent(7); + + act(() => result.current.handleProps.onPointerDown(down)); + expect(down.setPointerCapture).toHaveBeenCalledWith(7); + expect(document.body.style.cursor).toBe("col-resize"); + expect(document.body.style.userSelect).toBe("none"); + + // Width follows the pointer, measured from the container's left edge. + act(() => result.current.handleProps.onPointerMove(pointerEvent(7, 400))); + expect(result.current.width).toBe(300); + + // Drag clamps to [minWidth, maxWidth] (defaults 100..480). + act(() => result.current.handleProps.onPointerMove(pointerEvent(7, 100))); + expect(result.current.width).toBe(100); + act(() => result.current.handleProps.onPointerMove(pointerEvent(7, 5000))); + expect(result.current.width).toBe(480); + + act(() => result.current.handleProps.onPointerUp(pointerEvent(7))); + expect(document.body.style.cursor).toBe(""); + expect(document.body.style.userSelect).toBe(""); + + // Moves after release no longer resize. + act(() => result.current.handleProps.onPointerMove(pointerEvent(7, 350))); + expect(result.current.width).toBe(480); + }); + + it("ignores a second concurrent pointer (first pointer wins)", () => { + const { result } = renderColumn(0); + + act(() => result.current.handleProps.onPointerDown(pointerEvent(1))); + + // A second finger going down mid-drag must not capture or steal the drag. + const second = pointerEvent(2); + act(() => result.current.handleProps.onPointerDown(second)); + expect(second.setPointerCapture).not.toHaveBeenCalled(); + + act(() => result.current.handleProps.onPointerMove(pointerEvent(2, 999))); + expect(result.current.width).toBe(176); + + // The second pointer lifting must not end the first pointer's drag. + act(() => result.current.handleProps.onPointerUp(pointerEvent(2))); + act(() => result.current.handleProps.onPointerMove(pointerEvent(1, 300))); + expect(result.current.width).toBe(300); + }); + + it.each(["onPointerCancel", "onLostPointerCapture"] as const)( + "aborts cleanly on %s, keeping the last applied width", + (name) => { + const { result } = renderColumn(0); + + act(() => result.current.handleProps.onPointerDown(pointerEvent(3))); + act(() => result.current.handleProps.onPointerMove(pointerEvent(3, 250))); + expect(result.current.width).toBe(250); + + act(() => result.current.handleProps[name](pointerEvent(3))); + expect(document.body.style.cursor).toBe(""); + expect(document.body.style.userSelect).toBe(""); + + // The aborted pointer is dead: further moves must not resize. + act(() => result.current.handleProps.onPointerMove(pointerEvent(3, 400))); + expect(result.current.width).toBe(250); + }, + ); + + it("resets body cursor/selection when unmounted mid-drag", () => { + const { result, unmount } = renderColumn(0); + + act(() => result.current.handleProps.onPointerDown(pointerEvent(4))); + expect(document.body.style.cursor).toBe("col-resize"); + + unmount(); + expect(document.body.style.cursor).toBe(""); + expect(document.body.style.userSelect).toBe(""); + }); + + it("ends the drag via the document fallback when the handle element is gone", () => { + const { result } = renderColumn(0); + + act(() => result.current.handleProps.onPointerDown(pointerEvent(5))); + expect(document.body.style.cursor).toBe("col-resize"); + + // If the handle unmounts mid-drag (breakpoint flip, terminal exit) its + // React handlers never fire — the document-level pointerup fallback must + // still end the drag instead of wedging body styles + activePointerId. + act(() => { + const up = new Event("pointerup"); + Object.defineProperty(up, "pointerId", { value: 5 }); + document.dispatchEvent(up); + }); + expect(document.body.style.cursor).toBe(""); + expect(document.body.style.userSelect).toBe(""); + + // The drag state is fully released: a fresh pointer can start a new drag. + act(() => result.current.handleProps.onPointerDown(pointerEvent(6))); + act(() => result.current.handleProps.onPointerMove(pointerEvent(6, 200))); + expect(result.current.width).toBe(200); + act(() => result.current.handleProps.onPointerUp(pointerEvent(6))); + }); + + it.each(["mouse", "pen"] as const)( + "ignores a secondary-button (%s) pointerdown entirely", + (pointerType) => { + const { result } = renderColumn(0); + + // Right-click / pen barrel button (button 2) must not arm a drag, + // capture the pointer, or flip body styles. + const down = pointerEvent(11, 0, vi.fn(), { button: 2, pointerType }); + act(() => result.current.handleProps.onPointerDown(down)); + expect(down.setPointerCapture).not.toHaveBeenCalled(); + expect(down.preventDefault).not.toHaveBeenCalled(); + expect(document.body.style.cursor).toBe(""); + expect(document.body.style.userSelect).toBe(""); + + // No activePointerId was published: moves from that pointer are inert. + act(() => result.current.handleProps.onPointerMove(pointerEvent(11, 300))); + expect(result.current.width).toBe(176); + }, + ); + + it("stays idle when pointer capture fails", () => { + const { result } = renderColumn(0); + const failing = pointerEvent( + 9, + 0, + vi.fn(() => { + throw new DOMException("InvalidPointerId"); + }), + ); + + act(() => result.current.handleProps.onPointerDown(failing)); + expect(document.body.style.cursor).toBe(""); + expect(document.body.style.userSelect).toBe(""); + + // No drag was armed by the failed capture... + act(() => result.current.handleProps.onPointerMove(pointerEvent(9, 300))); + expect(result.current.width).toBe(176); + + // ...and a later pointer can still start one. + act(() => result.current.handleProps.onPointerDown(pointerEvent(10))); + act(() => result.current.handleProps.onPointerMove(pointerEvent(10, 300))); + expect(result.current.width).toBe(300); + act(() => result.current.handleProps.onPointerUp(pointerEvent(10))); + }); +}); + +describe("useResizableColumn keyboard resizing", () => { + it("resizes with arrow keys using the same clamps as dragging", () => { + const { result } = renderColumn(0); + + const right = keyEvent("ArrowRight"); + act(() => result.current.handleProps.onKeyDown(right)); + expect(result.current.width).toBe(196); + expect(right.preventDefault).toHaveBeenCalled(); + + act(() => result.current.handleProps.onKeyDown(keyEvent("ArrowLeft"))); + expect(result.current.width).toBe(176); + + // Repeated ArrowLeft stops at minWidth (100). + for (let i = 0; i < 10; i++) { + act(() => result.current.handleProps.onKeyDown(keyEvent("ArrowLeft"))); + } + expect(result.current.width).toBe(100); + + // Repeated ArrowRight stops at maxWidth (480). + for (let i = 0; i < 30; i++) { + act(() => result.current.handleProps.onKeyDown(keyEvent("ArrowRight"))); + } + expect(result.current.width).toBe(480); + + // Unrelated keys neither resize nor swallow the event. + const other = keyEvent("Enter"); + act(() => result.current.handleProps.onKeyDown(other)); + expect(result.current.width).toBe(480); + expect(other.preventDefault).not.toHaveBeenCalled(); + }); + + it("exposes a focusable separator with value semantics that track the width", () => { + const { result } = renderColumn(0); + const props = result.current.handleProps; + + expect(props.role).toBe("separator"); + expect(props.tabIndex).toBe(0); + expect(props["aria-orientation"]).toBe("vertical"); + expect(props["aria-valuenow"]).toBe(176); + expect(props["aria-valuemin"]).toBe(100); + expect(props["aria-valuemax"]).toBe(480); + + act(() => result.current.handleProps.onKeyDown(keyEvent("ArrowRight"))); + expect(result.current.handleProps["aria-valuenow"]).toBe(196); + }); +}); + +describe("useResizableColumn touch affordances", () => { + // The painted strip the consumer renders is 4px wide (`w-1`); the hit box + // is that strip plus the invisible padding on each side. + const PAINTED = 4; + + it("disables touch-action and keeps a >=24px hit target on fine pointers", () => { + // jsdom's matchMedia never matches "(pointer: coarse)" → fine-pointer pad. + const { result } = renderColumn(0); + const style = result.current.handleProps.style; + + expect(style.touchAction).toBe("none"); + expect(style.paddingLeft + PAINTED + style.paddingRight).toBeGreaterThanOrEqual(24); + + // The negative margin anchors the painted strip's right edge to the + // consumer-provided `left` boundary; background-clip keeps the pad + // unpainted so the visual weight stays a 4px line. + expect(style.marginLeft).toBe(-(style.paddingLeft + PAINTED)); + expect(style.backgroundClip).toBe("content-box"); + expect(style.boxSizing).toBe("content-box"); + }); + + it("widens the hit target to >=44px on coarse pointers", () => { + const spy = vi.spyOn(window, "matchMedia").mockReturnValue({ matches: true } as MediaQueryList); + const { result } = renderColumn(0); + const style = result.current.handleProps.style; + spy.mockRestore(); + + expect(style.paddingLeft + PAINTED + style.paddingRight).toBeGreaterThanOrEqual(44); + // Pad biased toward the terminal pane so the list rows' trailing status + // badges keep more of their tappable area. + expect(style.paddingRight).toBeGreaterThan(style.paddingLeft); + }); +}); diff --git a/web/src/hooks/useResizableColumn.ts b/web/src/hooks/useResizableColumn.ts index 6dfbff2571..722b3bc7ae 100644 --- a/web/src/hooks/useResizableColumn.ts +++ b/web/src/hooks/useResizableColumn.ts @@ -1,57 +1,158 @@ import { useCallback, useEffect, useRef, useState } from "react"; +const KEYBOARD_STEP_PX = 20; +// Width of the painted separator strip (the consumer's `w-1` element). +const PAINTED_WIDTH_PX = 4; + +// Invisible hit-target padding around the painted strip. The handle sits over +// the boundary between the terminal list and the xterm pane, so the pad is +// biased toward the terminal side: list rows end with a status badge flush +// against the boundary (taps there must select the row), while the xterm's +// leftmost pixels are rarely interactive. Coarse pointers get a 44px total +// target; fine pointers get the 24px minimum so the pad steals less hover +// area from the row badges. +const COARSE_PAD = { left: 12, right: 28 }; // 12 + 4 + 28 = 44px +const FINE_PAD = { left: 6, right: 14 }; // 6 + 4 + 14 = 24px + +function hitTargetPad() { + const coarse = + typeof window !== "undefined" && + typeof window.matchMedia === "function" && + window.matchMedia("(pointer: coarse)").matches; + return coarse ? COARSE_PAD : FINE_PAD; +} + export function useResizableColumn(defaultWidth = 176, minWidth = 100, maxWidth = 480) { const [width, setWidth] = useState(defaultWidth); - const dragging = useRef(false); + // Pointer id of the active drag; null when idle. First pointer wins — a + // second concurrent pointer is ignored until the first drag ends. + const activePointerId = useRef(null); const containerRef = useRef(null); - const minRef = useRef(minWidth); - const maxRef = useRef(maxWidth); - minRef.current = minWidth; - maxRef.current = maxWidth; - - const onMouseDown = useCallback((e: React.MouseEvent) => { - e.preventDefault(); - dragging.current = true; - document.body.style.cursor = "col-resize"; - document.body.style.userSelect = "none"; + // Removes the document-level fallback listeners for the active drag. + const removeDocListeners = useRef<(() => void) | null>(null); + const pad = useRef(hitTargetPad()).current; + + const clamp = useCallback( + (w: number) => Math.max(minWidth, Math.min(maxWidth, w)), + [minWidth, maxWidth], + ); + + const endDrag = useCallback(() => { + if (activePointerId.current === null) return; + activePointerId.current = null; + removeDocListeners.current?.(); + removeDocListeners.current = null; + document.body.style.cursor = ""; + document.body.style.userSelect = ""; }, []); - useEffect(() => { - function onMouseMove(e: MouseEvent) { - if (!dragging.current || !containerRef.current) return; + const onPointerDown = useCallback( + (e: React.PointerEvent) => { + // Only the primary button starts a drag — right-click / pen barrel + // button must not capture the pointer or flip body styles. + if (e.button !== 0) return; + if (activePointerId.current !== null) return; + e.preventDefault(); + // Capture so moves keep arriving when the pointer leaves the handle (or + // crosses an iframe), and so no other gesture consumer sees the stream. + // Capture first: if it throws (pointer already gone), stay idle rather + // than publishing a drag that can never receive its end events. + try { + e.currentTarget.setPointerCapture(e.pointerId); + } catch { + return; + } + activePointerId.current = e.pointerId; + document.body.style.cursor = "col-resize"; + document.body.style.userSelect = "none"; + // Document-level fallback: if the handle element unmounts mid-drag + // (breakpoint flip, active terminal exits) its React handlers never + // fire, which would leave the drag armed and body styles stuck. + const onDocEnd = (ev: PointerEvent) => { + if (ev.pointerId === activePointerId.current) endDrag(); + }; + document.addEventListener("pointerup", onDocEnd); + document.addEventListener("pointercancel", onDocEnd); + removeDocListeners.current = () => { + document.removeEventListener("pointerup", onDocEnd); + document.removeEventListener("pointercancel", onDocEnd); + }; + }, + [endDrag], + ); + + const onPointerMove = useCallback( + (e: React.PointerEvent) => { + if (e.pointerId !== activePointerId.current || !containerRef.current) return; const left = containerRef.current.getBoundingClientRect().left; - setWidth(Math.max(minRef.current, Math.min(maxRef.current, e.clientX - left))); - } - function onMouseUp() { - if (!dragging.current) return; - dragging.current = false; - document.body.style.cursor = ""; - document.body.style.userSelect = ""; - } - window.addEventListener("mousemove", onMouseMove); - window.addEventListener("mouseup", onMouseUp); - return () => { - window.removeEventListener("mousemove", onMouseMove); - window.removeEventListener("mouseup", onMouseUp); - if (dragging.current) { - dragging.current = false; - document.body.style.cursor = ""; - document.body.style.userSelect = ""; + setWidth(clamp(e.clientX - left)); + }, + [clamp], + ); + + // pointerup ends the drag; pointercancel and capture loss abort it cleanly, + // keeping the last applied width (never a half-state). + const onPointerEnd = useCallback( + (e: React.PointerEvent) => { + if (e.pointerId !== activePointerId.current) return; + endDrag(); + }, + [endDrag], + ); + + const onKeyDown = useCallback( + (e: React.KeyboardEvent) => { + // Vertical separator between columns: ArrowRight widens the left + // column, ArrowLeft narrows it, with the same clamps as dragging. + if (e.key === "ArrowRight") { + e.preventDefault(); + setWidth((w) => clamp(w + KEYBOARD_STEP_PX)); + } else if (e.key === "ArrowLeft") { + e.preventDefault(); + setWidth((w) => clamp(w - KEYBOARD_STEP_PX)); } - }; - }, []); + }, + [clamp], + ); + + // Reset body cursor/selection if the hook itself unmounts mid-drag. + useEffect(() => endDrag, [endDrag]); return { /** Pixel width for the left column (apply as inline style). */ width, /** Attach to the flex-row container to anchor drag calculations. */ containerRef, - /** Spread onto the resize handle element at the right edge of the left column. */ + /** + * Spread onto the resize handle. Render the handle as a direct child of + * the (overflow-hidden, relative) split row — NOT inside the scrollable + * list panel, where the invisible pad would be clipped and add horizontal + * overflow — absolutely positioned with `left: width`. The negative + * margin then aligns the painted strip's right edge to the boundary, + * with the invisible pad straddling it. + */ handleProps: { - onMouseDown, + onPointerDown, + onPointerMove, + onPointerUp: onPointerEnd, + onPointerCancel: onPointerEnd, + onLostPointerCapture: onPointerEnd, + onKeyDown, role: "separator" as const, + tabIndex: 0, "aria-orientation": "vertical" as const, "aria-label": "Resize terminal list", + "aria-valuenow": width, + "aria-valuemin": minWidth, + "aria-valuemax": maxWidth, + style: { + touchAction: "none", + boxSizing: "content-box", + paddingLeft: pad.left, + paddingRight: pad.right, + marginLeft: -(pad.left + PAINTED_WIDTH_PX), + backgroundClip: "content-box", + } as const, }, }; } diff --git a/web/src/hooks/useResizableCommentsPanel.test.tsx b/web/src/hooks/useResizableCommentsPanel.test.tsx index 28df8c2fe8..7abeaec2b4 100644 --- a/web/src/hooks/useResizableCommentsPanel.test.tsx +++ b/web/src/hooks/useResizableCommentsPanel.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 { readPanelSizePreference } from "@/lib/panelSizePreferences"; import { resetCommentsWidthStoreForTesting, @@ -12,6 +12,88 @@ function setInnerWidth(px: number): void { Object.defineProperty(window, "innerWidth", { configurable: true, writable: true, value: px }); } +// jsdom has no pointer capture, so tests drive the returned handlers directly +// with a stub handle element that tracks capture state. +function makeHandleTarget() { + const captured = new Set(); + return { + setPointerCapture: vi.fn((id: number) => captured.add(id)), + releasePointerCapture: vi.fn((id: number) => captured.delete(id)), + hasPointerCapture: (id: number) => captured.has(id), + }; +} + +type HandleTarget = ReturnType; + +function pointerEvent( + target: HandleTarget, + overrides: Partial<{ pointerId: number; pointerType: string; button: number; clientX: number }>, +): React.PointerEvent { + return { + pointerId: 1, + pointerType: "touch", + button: 0, + clientX: 0, + preventDefault: () => {}, + currentTarget: target, + ...overrides, + } as unknown as React.PointerEvent; +} + +const originalMatchMedia = window.matchMedia; + +type MediaListener = (e: MediaQueryListEvent) => void; + +/** Controllable matchMedia mock: per-query matches plus a change-event firer. */ +function mockMatchMedia(matches: Record = {}) { + const listeners = new Map>(); + window.matchMedia = ((query: string) => ({ + matches: matches[query] ?? false, + media: query, + onchange: null, + addEventListener: (_: string, cb: MediaListener) => { + if (!listeners.has(query)) listeners.set(query, new Set()); + listeners.get(query)?.add(cb); + }, + removeEventListener: (_: string, cb: MediaListener) => listeners.get(query)?.delete(cb), + addListener: () => {}, + removeListener: () => {}, + dispatchEvent: () => false, + })) as typeof window.matchMedia; + return { + fire(query: string, value: boolean) { + for (const cb of listeners.get(query) ?? new Set()) { + cb({ matches: value } as MediaQueryListEvent); + } + }, + }; +} + +/** jsdom has no PointerEvent constructor; a plain Event with pointerId works + * for the hook's document-level fallback listeners. */ +function docPointerEvent(type: string, pointerId: number): Event { + return Object.assign(new Event(type), { pointerId }); +} + +const overlaySelector = () => + [...document.body.children].find( + (c): c is HTMLElement => + c instanceof HTMLElement && c.style.position === "fixed" && c.style.zIndex === "2147483647", + ) ?? null; + +/** Panel root inside the split row; defaults to right edge x=1000, row 2000px. */ +function attachContainer( + ref: React.MutableRefObject, + { parentWidth = 2000, panelRight = 1000 } = {}, +): void { + const parent = document.createElement("div"); + const panel = document.createElement("div"); + parent.appendChild(panel); + vi.spyOn(parent, "getBoundingClientRect").mockReturnValue({ width: parentWidth } as DOMRect); + vi.spyOn(panel, "getBoundingClientRect").mockReturnValue({ right: panelRight } as DOMRect); + ref.current = panel; +} + beforeEach(() => { setInnerWidth(2000); }); @@ -20,6 +102,7 @@ afterEach(() => { localStorage.clear(); resetCommentsWidthStoreForTesting(); setInnerWidth(originalInnerWidth); + window.matchMedia = originalMatchMedia; }); describe("useResizableCommentsPanel persistence", () => { @@ -48,40 +131,352 @@ describe("useResizableCommentsPanel persistence", () => { }); }); -describe("useResizableCommentsPanel drag overlay", () => { - const overlaySelector = () => - [...document.body.children].find( - (c): c is HTMLElement => - c instanceof HTMLElement && c.style.position === "fixed" && c.style.zIndex === "2147483647", - ) ?? null; +describe("useResizableCommentsPanel pointer drag", () => { + it("captures the pointer on pointerdown and resizes from pointermove", () => { + const { result, unmount } = renderHook(() => useResizableCommentsPanel()); + attachContainer(result.current.containerRef); + const target = makeHandleTarget(); + + act(() => result.current.handleProps.onPointerDown(pointerEvent(target, { pointerId: 7 }))); + // Capture keeps the drag alive when the pointer leaves the 1px handle. + expect(target.setPointerCapture).toHaveBeenCalledWith(7); + + // Panel right edge is at 1000, so a move to x=700 means a 300px width. + act(() => + result.current.handleProps.onPointerMove( + pointerEvent(target, { pointerId: 7, clientX: 700 }), + ), + ); + expect(result.current.width).toBe(300); + + // Live moves must not persist; only the release does. + expect(readPanelSizePreference("commentsPanelWidthPx")).toBeNull(); + act(() => + result.current.handleProps.onPointerUp(pointerEvent(target, { pointerId: 7, clientX: 700 })), + ); + expect(target.releasePointerCapture).toHaveBeenCalledWith(7); + expect(readPanelSizePreference("commentsPanelWidthPx")).toBe(300); + unmount(); + }); + + it("ignores a second concurrent pointer — first pointer wins", () => { + const { result, unmount } = renderHook(() => useResizableCommentsPanel()); + attachContainer(result.current.containerRef); + const target = makeHandleTarget(); + + act(() => result.current.handleProps.onPointerDown(pointerEvent(target, { pointerId: 1 }))); + act(() => result.current.handleProps.onPointerDown(pointerEvent(target, { pointerId: 2 }))); + // A second finger neither captures nor steals the drag. + expect(target.setPointerCapture).toHaveBeenCalledTimes(1); + + act(() => + result.current.handleProps.onPointerMove( + pointerEvent(target, { pointerId: 2, clientX: 500 }), + ), + ); + expect(result.current.width).toBe(240); + + act(() => + result.current.handleProps.onPointerMove( + pointerEvent(target, { pointerId: 1, clientX: 700 }), + ), + ); + expect(result.current.width).toBe(300); + unmount(); + }); + + it("leaves at least 240px for the viewer at maximum width with the gutter present", () => { + const { result, unmount } = renderHook(() => useResizableCommentsPanel()); + // Full row is [viewer, gutter, panel]: a 500px row with the panel's right + // edge flush at x=500, so every pixel the panel takes comes out of the + // viewer+gutter budget. + attachContainer(result.current.containerRef, { parentWidth: 500, panelRight: 500 }); + const target = makeHandleTarget(); + + act(() => result.current.handleProps.onPointerDown(pointerEvent(target, { pointerId: 6 }))); + // Drag all the way left — the clamp, not the pointer, decides the max. + act(() => + result.current.handleProps.onPointerMove(pointerEvent(target, { pointerId: 6, clientX: 0 })), + ); + + // The dynamic max must budget the gutter's footprint (always the coarse + // 8px) on top of the viewer's 240px minimum: 500 − 240 − 8 = 252. + const width = result.current.width as number; + expect(width).toBe(252); + expect(500 - width - 8).toBeGreaterThanOrEqual(240); + + act(() => + result.current.handleProps.onPointerUp(pointerEvent(target, { pointerId: 6, clientX: 0 })), + ); + unmount(); + }); + + it("ends the drag from the document fallback when capture delivery fails", () => { + // A browser can drop capture without firing the handle's own pointerup + // (tab switch, node detach). The document-level fallback still ends the + // drag — a release, so it persists — and the max-z overlay comes down. + const { result, unmount } = renderHook(() => useResizableCommentsPanel()); + attachContainer(result.current.containerRef); + const target = makeHandleTarget(); + + act(() => result.current.handleProps.onPointerDown(pointerEvent(target, { pointerId: 5 }))); + act(() => + result.current.handleProps.onPointerMove( + pointerEvent(target, { pointerId: 5, clientX: 700 }), + ), + ); + act(() => void document.dispatchEvent(docPointerEvent("pointerup", 5))); + + expect(overlaySelector()).toBeNull(); + expect(document.body.style.cursor).toBe(""); + expect(readPanelSizePreference("commentsPanelWidthPx")).toBe(300); + unmount(); + }); + + it("aborts without persisting from the document pointercancel fallback", () => { + const { result, unmount } = renderHook(() => useResizableCommentsPanel()); + attachContainer(result.current.containerRef); + const target = makeHandleTarget(); + + act(() => result.current.handleProps.onPointerDown(pointerEvent(target, { pointerId: 5 }))); + act(() => + result.current.handleProps.onPointerMove( + pointerEvent(target, { pointerId: 5, clientX: 700 }), + ), + ); + act(() => void document.dispatchEvent(docPointerEvent("pointercancel", 5))); + + expect(overlaySelector()).toBeNull(); + expect(result.current.width).toBe(300); + expect(readPanelSizePreference("commentsPanelWidthPx")).toBeNull(); + unmount(); + }); + + it("aborts the drag when the layout flips below the md breakpoint", () => { + // Flipping to mobile unmounts the handle, so its up/cancel can never + // arrive; the drag must end (unpersisted) or the overlay would wedge. + const mm = mockMatchMedia(); + const { result, unmount } = renderHook(() => useResizableCommentsPanel()); + attachContainer(result.current.containerRef); + const target = makeHandleTarget(); + + act(() => result.current.handleProps.onPointerDown(pointerEvent(target, { pointerId: 4 }))); + expect(overlaySelector()).not.toBeNull(); + + act(() => mm.fire("(min-width: 768px)", false)); + expect(result.current.isDesktop).toBe(false); + expect(overlaySelector()).toBeNull(); + expect(document.body.style.cursor).toBe(""); + expect(readPanelSizePreference("commentsPanelWidthPx")).toBeNull(); + unmount(); + }); + + it("stays fully idle when pointer capture throws", () => { + // If capture fails, publishing drag state anyway would leave a stale + // activePointerId that a later reused pointerId could match — ending + // (and persisting) a drag that never started. + const { result, unmount } = renderHook(() => useResizableCommentsPanel()); + attachContainer(result.current.containerRef); + const target = makeHandleTarget(); + target.setPointerCapture.mockImplementation(() => { + throw new Error("InvalidPointerId"); + }); + + act(() => result.current.handleProps.onPointerDown(pointerEvent(target, { pointerId: 9 }))); + expect(overlaySelector()).toBeNull(); + expect(document.body.style.cursor).toBe(""); + + act(() => + result.current.handleProps.onPointerMove( + pointerEvent(target, { pointerId: 9, clientX: 700 }), + ), + ); + expect(result.current.width).toBe(240); - it("mounts a full-window overlay during a drag so mouseup isn't lost to the preview iframe", () => { - // The divider sits between the HTML-preview iframe and this panel. 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. + act(() => void document.dispatchEvent(docPointerEvent("pointerup", 9))); + expect(readPanelSizePreference("commentsPanelWidthPx")).toBeNull(); + unmount(); + }); + + it("does not start a drag from a pen barrel button", () => { + // A pen barrel press dispatches pointerType "pen" with button 2; only + // the primary button/tip (button 0) may start a drag. const { result, unmount } = renderHook(() => useResizableCommentsPanel()); + attachContainer(result.current.containerRef); + const target = makeHandleTarget(); + + act(() => + result.current.handleProps.onPointerDown( + pointerEvent(target, { pointerType: "pen", button: 2 }), + ), + ); + expect(target.setPointerCapture).not.toHaveBeenCalled(); expect(overlaySelector()).toBeNull(); + unmount(); + }); + it("does not start a drag from a secondary mouse button", () => { + const { result, unmount } = renderHook(() => useResizableCommentsPanel()); + attachContainer(result.current.containerRef); + const target = makeHandleTarget(); + + act(() => + result.current.handleProps.onPointerDown( + pointerEvent(target, { pointerType: "mouse", button: 2 }), + ), + ); + expect(target.setPointerCapture).not.toHaveBeenCalled(); + + act(() => result.current.handleProps.onPointerMove(pointerEvent(target, { clientX: 700 }))); + expect(result.current.width).toBe(240); + unmount(); + }); + + it.each([ + ["pointercancel", "onPointerCancel"], + ["lostpointercapture", "onLostPointerCapture"], + ] as const)("aborts cleanly at the last applied width on %s", (_name, handler) => { + const { result, unmount } = renderHook(() => useResizableCommentsPanel()); + attachContainer(result.current.containerRef); + const target = makeHandleTarget(); + + act(() => result.current.handleProps.onPointerDown(pointerEvent(target, { pointerId: 3 }))); act(() => - result.current.handleProps.onMouseDown({ preventDefault: () => {} } as React.MouseEvent), + result.current.handleProps.onPointerMove( + pointerEvent(target, { pointerId: 3, clientX: 700 }), + ), ); + act(() => result.current.handleProps[handler](pointerEvent(target, { pointerId: 3 }))); + + // Never a half-state: width settles, body styles restore, drag is over so + // later moves from the same pointer are inert. An abort is not a choice — + // the last applied width stays on screen but is never persisted. + expect(result.current.width).toBe(300); + expect(readPanelSizePreference("commentsPanelWidthPx")).toBeNull(); + expect(document.body.style.cursor).toBe(""); + expect(document.body.style.userSelect).toBe(""); + act(() => + result.current.handleProps.onPointerMove( + pointerEvent(target, { pointerId: 3, clientX: 500 }), + ), + ); + expect(result.current.width).toBe(300); + unmount(); + }); +}); + +describe("useResizableCommentsPanel touch affordances", () => { + // The handle is a divider gutter: a real flex child whose layout footprint + // is the gutter width (4px painted strip + padding + the cancelling + // negative margins) and whose hit box overhangs each neighbor by only a + // capped sliver. Derived from the returned style: + const gutterGeometry = (style: React.CSSProperties) => { + const padLeft = Number(style.paddingLeft); + const padRight = Number(style.paddingRight); + const marginLeft = Number(style.marginLeft); + const marginRight = Number(style.marginRight); + return { + hitTotal: 4 + padLeft + padRight, + footprint: 4 + padLeft + padRight + marginLeft + marginRight, + viewerOverhang: -marginLeft, + inwardOverhang: -marginRight, + }; + }; + + it("declares touch-action none and a capped-sliver gutter hit target", () => { + const { result, unmount } = renderHook(() => useResizableCommentsPanel()); + const { style } = result.current.handleProps; + + // No scroll/swipe may start from the handle during a potential drag. + expect(style.touchAction).toBe("none"); + + // Fine pointer (the matchMedia stub reports no coarse pointer): >=24px + // hit total in a 6px-wide gutter. The slivers are capped so a classic + // viewer scrollbar and the panel's content keep their pointer streams. + const g = gutterGeometry(style); + expect(g.hitTotal).toBeGreaterThanOrEqual(24); + expect(g.footprint).toBe(6); + expect(g.viewerOverhang).toBeLessThanOrEqual(10); + expect(g.inwardOverhang).toBeLessThanOrEqual(8); + + // Content-box keeps hover/active backgrounds on the 4px painted strip. + expect(style.boxSizing).toBe("content-box"); + expect(style.backgroundClip).toBe("content-box"); + + // The affordance is pure style — nothing is rendered into the handle. + expect("children" in result.current.handleProps).toBe(false); + unmount(); + }); + + it("widens the gutter and hit target on coarse-pointer devices", () => { + mockMatchMedia({ "(pointer: coarse)": true }); + const { result, unmount } = renderHook(() => useResizableCommentsPanel()); + + // Coarse: 8px gutter, 26px hit total — TR-7's 24px floor with the same + // sliver caps (the preferred 44px would need a visually wide gutter). + const g = gutterGeometry(result.current.handleProps.style); + expect(g.hitTotal).toBe(26); + expect(g.footprint).toBe(8); + expect(g.viewerOverhang).toBeLessThanOrEqual(10); + expect(g.inwardOverhang).toBeLessThanOrEqual(8); + unmount(); + }); + + it("exposes the width to assistive tech via aria value attributes", () => { + const { result, unmount } = renderHook(() => useResizableCommentsPanel()); + expect(result.current.handleProps["aria-valuenow"]).toBe(240); + expect(result.current.handleProps["aria-valuemin"]).toBe(200); + expect(result.current.handleProps["aria-valuemax"]).toBe(640); + + // The value tracks live resizes. + act(() => { + result.current.handleProps.onKeyDown({ + key: "ArrowLeft", + preventDefault: () => {}, + } as React.KeyboardEvent); + }); + expect(result.current.handleProps["aria-valuenow"]).toBe(260); + unmount(); + }); + + it("keeps the separator contract the consumer's markup relies on", () => { + const { result, unmount } = renderHook(() => useResizableCommentsPanel()); + expect(result.current.handleProps.role).toBe("separator"); + expect(result.current.handleProps["aria-label"]).toBe("Resize comments panel"); + expect(result.current.handleProps.tabIndex).toBe(0); + unmount(); + }); +}); + +describe("useResizableCommentsPanel drag overlay", () => { + it("shields iframes with a full-window overlay for the duration of the drag", () => { + // The divider sits beside the HTML-preview iframe. If capture is lost, + // the overlay keeps the pointer stream in the parent document so the + // release is never swallowed by the frame. + const { result, unmount } = renderHook(() => useResizableCommentsPanel()); + const target = makeHandleTarget(); + expect(overlaySelector()).toBeNull(); + + act(() => result.current.handleProps.onPointerDown(pointerEvent(target, {}))); 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(target, {}))); expect(overlaySelector()).toBeNull(); unmount(); }); - it("removes the overlay if unmounted mid-drag", () => { + it("removes the overlay and restores body styles if unmounted mid-drag", () => { const { result, unmount } = renderHook(() => useResizableCommentsPanel()); - act(() => - result.current.handleProps.onMouseDown({ preventDefault: () => {} } as React.MouseEvent), - ); + const target = makeHandleTarget(); + act(() => result.current.handleProps.onPointerDown(pointerEvent(target, {}))); expect(overlaySelector()).not.toBeNull(); + expect(document.body.style.cursor).toBe("col-resize"); unmount(); expect(overlaySelector()).toBeNull(); + expect(document.body.style.cursor).toBe(""); + expect(document.body.style.userSelect).toBe(""); }); }); diff --git a/web/src/hooks/useResizableCommentsPanel.ts b/web/src/hooks/useResizableCommentsPanel.ts index b541a6f740..07b8589b64 100644 --- a/web/src/hooks/useResizableCommentsPanel.ts +++ b/web/src/hooks/useResizableCommentsPanel.ts @@ -24,6 +24,35 @@ const MAX_WIDTH_PX = 640; const MIN_VIEWER_PX = 240; /** Tailwind `md` breakpoint — must track the value in tailwind.config. */ const MD_BREAKPOINT = 768; +// The handle is a dedicated divider gutter between the viewer and the panel — +// a real flex child outside both scroll containers, so its hit area overlays +// almost no content. The painted strip (`w-1`) sits centered in the gutter; +// invisible padding fills the rest and overhangs each side by a small sliver +// via negative margins. The slivers are capped so the viewer's scrollbar and +// the panel's header/tabs/cards keep their taps and scroll starts — which +// also caps the hit total at 26px coarse / 24px fine (TR-7's 24px floor; the +// preferred 44px would need a visually wide gutter the layout doesn't permit). +const PAINTED_STRIP_PX = 4; // must match the handle's `w-1` class +const GUTTER_COARSE_PX = 8; +const GUTTER_FINE_PX = 6; +const VIEWER_SLIVER_PX = 10; // ≤10: a 14px viewer scrollbar keeps 4px + its own gutter +const INWARD_SLIVER_PX = 8; // ≤8: stays within the panel's 12px content gutter + +/** Inline style for the divider-gutter handle: layout footprint = gutter + * width, hit box = gutter + both slivers, paint = the centered `w-1` strip. */ +function gutterStyle(isCoarse: boolean): React.CSSProperties { + const gutter = isCoarse ? GUTTER_COARSE_PX : GUTTER_FINE_PX; + const inset = (gutter - PAINTED_STRIP_PX) / 2; + return { + touchAction: "none", + boxSizing: "content-box", + paddingLeft: VIEWER_SLIVER_PX + inset, + paddingRight: INWARD_SLIVER_PX + inset, + marginLeft: -VIEWER_SLIVER_PX, + marginRight: -INWARD_SLIVER_PX, + backgroundClip: "content-box", + }; +} // --------------------------------------------------------------------------- // Module-level width store (shared across panel remounts within a session) @@ -97,14 +126,19 @@ export function resetCommentsWidthStoreForTesting(): void { export function useResizableCommentsPanel() { const raw = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot); const width = Math.max(MIN_WIDTH_PX, Math.min(raw ?? DEFAULT_WIDTH_PX, MAX_WIDTH_PX)); - const dragging = useRef(false); + // Pointer id of the active drag; null when idle. A second concurrent + // pointer (e.g. another finger) is ignored — first pointer wins. + const activePointerId = useRef(null); const containerRef = useRef(null); const overlayRef = useRef(null); + // Removes the document-level pointerup/pointercancel fallbacks installed + // for the active drag; null when idle. + const removeDocFallbacks = useRef<(() => void) | null>(null); // 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 even if capture is + // lost. Without it, dragging over a cross-origin/sandboxed iframe (e.g. the + // HTML preview) routes moves into the frame and the drag sticks. const addDragOverlay = useCallback(() => { if (overlayRef.current || typeof document === "undefined") return; const el = document.createElement("div"); @@ -130,24 +164,124 @@ export function useResizableCommentsPanel() { return () => mql.removeEventListener("change", handler); }, []); + // Coarse pointers (fingers) get the full 44px hit box; fine pointers + // (mouse, trackpad, pen tip) can acquire a 24px one. + const [isCoarse, setIsCoarse] = useState( + () => typeof window !== "undefined" && !!window.matchMedia?.("(pointer: coarse)").matches, + ); + + useEffect(() => { + const mql = window.matchMedia?.("(pointer: coarse)"); + if (!mql) return; + const handler = (e: MediaQueryListEvent) => setIsCoarse(e.matches); + mql.addEventListener("change", handler); + return () => mql.removeEventListener("change", handler); + }, []); + // Clamp a candidate width to [MIN, dynamic max], leaving MIN_VIEWER_PX for // the sibling code/diff viewer so the panel can't swallow the whole row. + // The divider gutter is a third flex child in the row, so its footprint + // comes out of the budget too — always the coarse 8px, so a pointer-type + // flip mid-session can never shrink the viewer below its minimum. const clampWidth = useCallback((candidate: number): number => { const parent = containerRef.current?.parentElement; const parentWidth = parent?.getBoundingClientRect().width ?? window.innerWidth; - const max = Math.max(MIN_WIDTH_PX, Math.min(MAX_WIDTH_PX, parentWidth - MIN_VIEWER_PX)); + const max = Math.max( + MIN_WIDTH_PX, + Math.min(MAX_WIDTH_PX, parentWidth - MIN_VIEWER_PX - GUTTER_COARSE_PX), + ); return Math.max(MIN_WIDTH_PX, Math.min(candidate, max)); }, []); - const onMouseDown = useCallback( - (e: React.MouseEvent) => { + // Ends the drag at the last applied width (never a half-state): clears the + // active pointer, drops the overlay, and restores the body cursor/selection. + // Only a deliberate release persists; aborts (cancel, capture loss, unmount) + // keep the width on screen but don't write storage. Idempotent so pointerup + // + the lostpointercapture it triggers don't double-run. + const endDrag = useCallback( + (persist: boolean) => { + if (activePointerId.current === null) return; + activePointerId.current = null; + removeDocFallbacks.current?.(); + removeDocFallbacks.current = null; + removeDragOverlay(); + if (persist) persistStoredWidth(); + document.body.style.cursor = ""; + document.body.style.userSelect = ""; + }, + [removeDragOverlay], + ); + + const onPointerDown = useCallback( + (e: React.PointerEvent) => { + // First pointer wins; only the primary button/tip starts a drag. (A pen + // barrel button reports pointerType "pen" with button 2, so the guard + // must not be mouse-only; touch and pen tip are always button 0.) + if (activePointerId.current !== null) return; + if (e.button !== 0) return; + // Capture BEFORE publishing any drag state: if capture throws (pointer + // already gone, detached node), staying fully idle avoids a stale + // activePointerId that a later reused pointerId could match — which + // would spuriously end (and persist) a drag that never started. + try { + e.currentTarget.setPointerCapture?.(e.pointerId); // jsdom lacks capture + } catch { + return; + } e.preventDefault(); - dragging.current = true; + activePointerId.current = e.pointerId; + // Document-level fallbacks: if the browser drops capture without + // delivering the handle's up/cancel, the drag still ends here so the + // max-z overlay can never outlive it. + const onDocPointerUp = (ev: PointerEvent) => { + if (ev.pointerId === activePointerId.current) endDrag(true); + }; + const onDocPointerCancel = (ev: PointerEvent) => { + if (ev.pointerId === activePointerId.current) endDrag(false); + }; + document.addEventListener("pointerup", onDocPointerUp); + document.addEventListener("pointercancel", onDocPointerCancel); + removeDocFallbacks.current = () => { + document.removeEventListener("pointerup", onDocPointerUp); + document.removeEventListener("pointercancel", onDocPointerCancel); + }; addDragOverlay(); document.body.style.cursor = "col-resize"; document.body.style.userSelect = "none"; }, - [addDragOverlay], + [addDragOverlay, endDrag], + ); + + const onPointerMove = useCallback( + (e: React.PointerEvent) => { + if (e.pointerId !== activePointerId.current || !containerRef.current) return; + const right = containerRef.current.getBoundingClientRect().right; + // Update the live width only; persist once on release to avoid a + // synchronous localStorage write per move. + setStoredWidth(clampWidth(right - e.clientX)); + }, + [clampWidth], + ); + + const onPointerUp = useCallback( + (e: React.PointerEvent) => { + if (e.pointerId !== activePointerId.current) return; + if (e.currentTarget.hasPointerCapture?.(e.pointerId)) { + e.currentTarget.releasePointerCapture(e.pointerId); + } + endDrag(true); + }, + [endDrag], + ); + + // pointercancel (e.g. the browser reclaims the touch) and capture loss + // both abort cleanly to the last applied width, without persisting it. + const onPointerCancel = useCallback( + (e: React.PointerEvent) => { + if (e.pointerId !== activePointerId.current) return; + endDrag(false); + }, + [endDrag], ); // Keyboard resize: left/right arrows widen/narrow by 20px. @@ -165,35 +299,14 @@ export function useResizableCommentsPanel() { [clampWidth], ); + // Unmount mid-drag: abort (no persist) and clean up body/overlay state. + useEffect(() => () => endDrag(false), [endDrag]); + + // The layout flipping to mobile mid-drag unmounts the handle, so its + // up/cancel can never arrive — abort so the overlay doesn't outlive the drag. useEffect(() => { - function onMouseMove(e: MouseEvent) { - if (!dragging.current || !containerRef.current) return; - const right = containerRef.current.getBoundingClientRect().right; - // Update the live width only; persist once on release to avoid a - // synchronous localStorage write per mousemove. - setStoredWidth(clampWidth(right - e.clientX)); - } - function onMouseUp() { - if (!dragging.current) return; - dragging.current = false; - removeDragOverlay(); - persistStoredWidth(); - document.body.style.cursor = ""; - document.body.style.userSelect = ""; - } - window.addEventListener("mousemove", onMouseMove); - window.addEventListener("mouseup", onMouseUp); - return () => { - window.removeEventListener("mousemove", onMouseMove); - window.removeEventListener("mouseup", onMouseUp); - if (dragging.current) { - dragging.current = false; - document.body.style.cursor = ""; - document.body.style.userSelect = ""; - } - removeDragOverlay(); - }; - }, [clampWidth, removeDragOverlay]); + if (!isDesktop) endDrag(false); + }, [isDesktop, endDrag]); // Re-clamp the stored width when the viewport resizes so a width chosen on // a wider layout doesn't crowd out the viewer after the window shrinks. @@ -217,14 +330,33 @@ export function useResizableCommentsPanel() { containerRef, /** Whether the resize handle should render (desktop only). */ isDesktop, - /** Props to spread onto the resize handle element. */ + /** + * Props to spread onto the divider-gutter handle. Render it as the + * panel's PRECEDING SIBLING in the split row (a `w-1 shrink-0` flex + * child), never inside either scroll container — the pads would be + * clipped and would steal the neighbors' pointer streams. + */ handleProps: { - onMouseDown, + onPointerDown, + onPointerMove, + onPointerUp, + onPointerCancel, + onLostPointerCapture: onPointerCancel, onKeyDown, role: "separator" as const, "aria-orientation": "vertical" as const, "aria-label": "Resize comments panel", + "aria-valuenow": width, + "aria-valuemin": MIN_WIDTH_PX, + "aria-valuemax": MAX_WIDTH_PX, tabIndex: 0, + // The gutter owns its touches outright (no scroll/selection may start + // from it). With content-box sizing the `w-1` class is the painted + // strip; padding centers it in the gutter and adds the overhang + // slivers, whose footprint the negative margins cancel — so the + // element occupies exactly the gutter width and the hover/active + // background (content-box clipped) never widens visually. + style: gutterStyle(isCoarse), }, }; } 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/hooks/useResizablePanel.test.tsx b/web/src/hooks/useResizablePanel.test.tsx index a22c49d50c..c43db5ba50 100644 --- a/web/src/hooks/useResizablePanel.test.tsx +++ b/web/src/hooks/useResizablePanel.test.tsx @@ -1,22 +1,109 @@ 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 { readPanelSizePreference } from "@/lib/panelSizePreferences"; -import { resetSharedWidthStoreForTesting, useResizablePanel } from "./useResizablePanel"; +import { + HANDLE_COARSE_GUTTER_PX, + HANDLE_FINE_GUTTER_PX, + HANDLE_INWARD_SLIVER_PX, + HANDLE_OUTWARD_SLIVER_PX, + resetSharedWidthStoreForTesting, + useResizablePanel, +} from "./useResizablePanel"; const originalInnerWidth = window.innerWidth; +const originalMatchMedia = window.matchMedia; +let desktopMatches = true; +let coarsePointer = false; +const desktopChangeListeners = new Set<(event: MediaQueryListEvent) => void>(); +const coarseChangeListeners = new Set<(event: MediaQueryListEvent) => void>(); function setInnerWidth(px: number): void { Object.defineProperty(window, "innerWidth", { configurable: true, writable: true, value: px }); } +function installMatchMedia(): void { + window.matchMedia = vi.fn((query: string) => ({ + matches: query === "(pointer: coarse)" ? coarsePointer : desktopMatches, + media: query, + onchange: null, + addListener: () => {}, + removeListener: () => {}, + addEventListener: (_type: string, listener: (event: MediaQueryListEvent) => void) => { + if (query === "(pointer: coarse)") coarseChangeListeners.add(listener); + else if (query.includes("min-width")) desktopChangeListeners.add(listener); + }, + removeEventListener: (_type: string, listener: (event: MediaQueryListEvent) => void) => { + if (query === "(pointer: coarse)") coarseChangeListeners.delete(listener); + else desktopChangeListeners.delete(listener); + }, + dispatchEvent: () => false, + })) as typeof window.matchMedia; +} + +function setCoarseMatch(matches: boolean): void { + coarsePointer = matches; + const event = { matches } as MediaQueryListEvent; + for (const listener of coarseChangeListeners) listener(event); +} + +function setDesktopMatch(matches: boolean): void { + desktopMatches = matches; + const event = { matches } as MediaQueryListEvent; + for (const listener of desktopChangeListeners) listener(event); +} + +function createPointerHandle() { + const element = document.createElement("div"); + const capturedPointers = new Set(); + 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, + { + pointerId, + clientX = 0, + pointerType = "touch", + button = 0, + preventDefault = () => {}, + }: { + pointerId: number; + clientX?: number; + pointerType?: string; + button?: number; + preventDefault?: () => void; + }, +): React.PointerEvent { + return { + currentTarget: element, + pointerId, + clientX, + pointerType, + button, + preventDefault, + } as React.PointerEvent; +} + beforeEach(() => { setInnerWidth(2000); + desktopMatches = true; + coarsePointer = false; + desktopChangeListeners.clear(); + coarseChangeListeners.clear(); + installMatchMedia(); }); afterEach(() => { localStorage.clear(); resetSharedWidthStoreForTesting(); setInnerWidth(originalInnerWidth); + window.matchMedia = originalMatchMedia; + desktopChangeListeners.clear(); + coarseChangeListeners.clear(); }); describe("useResizablePanel persistence", () => { @@ -74,30 +161,222 @@ describe("useResizablePanel persistence", () => { expect(result.current.panelWidth).toBe(980); }); - it("updates live width during a drag but only persists on release", () => { + it("captures the pointer and persists the final width on release", () => { const { result } = renderHook(() => useResizablePanel(true)); + const handle = createPointerHandle(); act(() => { - result.current.handleProps.onMouseDown({ - preventDefault: () => {}, - } as React.MouseEvent); + result.current.handleProps.onPointerDown(pointerEvent(handle.element, { pointerId: 7 })); }); + expect(handle.setPointerCapture).toHaveBeenCalledWith(7); + act(() => { // 2000px viewport, cursor at 1200 → width = innerWidth - clientX = 800. - window.dispatchEvent(new MouseEvent("mousemove", { clientX: 1200 })); + 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 mousemove would fire a synchronous setItem on every frame. + // persisting per pointermove would fire a synchronous setItem on every frame. expect(result.current.panelWidth).toBe(800); expect(readPanelSizePreference("pushPanelWidthPx")).toBeNull(); act(() => { - window.dispatchEvent(new MouseEvent("mouseup")); + result.current.handleProps.onPointerUp(pointerEvent(handle.element, { pointerId: 7 })); }); // Release snapshots the final width exactly once. expect(readPanelSizePreference("pushPanelWidthPx")).toBe(800); + expect(handle.releasePointerCapture).toHaveBeenCalledWith(7); + }); + + it("stays idle when pointer capture throws", () => { + const { result } = renderHook(() => useResizablePanel(true)); + const failedHandle = createPointerHandle(); + const nextHandle = createPointerHandle(); + failedHandle.setPointerCapture.mockImplementation(() => { + throw new Error("capture failed"); + }); + const preventDefault = vi.fn(); + + act(() => { + result.current.handleProps.onPointerDown( + pointerEvent(failedHandle.element, { pointerId: 8, preventDefault }), + ); + result.current.handleProps.onPointerMove( + pointerEvent(failedHandle.element, { pointerId: 8, clientX: 1200 }), + ); + }); + + expect(preventDefault).not.toHaveBeenCalled(); + expect(result.current.panelWidth).toBe(1000); + expect(document.body.style.cursor).toBe(""); + expect(document.body.style.userSelect).toBe(""); + + act(() => { + result.current.handleProps.onPointerDown(pointerEvent(nextHandle.element, { pointerId: 9 })); + }); + expect(nextHandle.setPointerCapture).toHaveBeenCalledWith(9); + }); + + it.each(["onPointerCancel", "onLostPointerCapture"] as const)( + "aborts cleanly via %s without persisting", + (abortHandler) => { + const { result } = renderHook(() => useResizablePanel(true)); + 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(readPanelSizePreference("pushPanelWidthPx")).toBeNull(); + expect(document.body.style.cursor).toBe(""); + expect(document.body.style.userSelect).toBe(""); + }, + ); + + it("ignores additional pointers until the active drag ends", () => { + const { result } = renderHook(() => useResizablePanel(true)); + const firstHandle = createPointerHandle(); + const secondHandle = createPointerHandle(); + + act(() => { + result.current.handleProps.onPointerDown(pointerEvent(firstHandle.element, { pointerId: 1 })); + 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(1000); + + act(() => { + result.current.handleProps.onPointerMove( + pointerEvent(firstHandle.element, { pointerId: 1, clientX: 1200 }), + ); + }); + expect(result.current.panelWidth).toBe(800); + }); + + it("aborts without persisting when the hook unmounts mid-drag", () => { + const { result, unmount } = renderHook(() => useResizablePanel(true)); + const handle = createPointerHandle(); + + act(() => { + result.current.handleProps.onPointerDown(pointerEvent(handle.element, { pointerId: 4 })); + result.current.handleProps.onPointerMove( + pointerEvent(handle.element, { pointerId: 4, clientX: 1200 }), + ); + unmount(); + }); + + expect(readPanelSizePreference("pushPanelWidthPx")).toBeNull(); + expect(document.body.style.cursor).toBe(""); + expect(document.body.style.userSelect).toBe(""); + }); + + it("aborts when the desktop media query flips during a drag", () => { + const { result } = renderHook(() => useResizablePanel(true)); + const handle = createPointerHandle(); + + act(() => { + result.current.handleProps.onPointerDown(pointerEvent(handle.element, { pointerId: 6 })); + result.current.handleProps.onPointerMove( + pointerEvent(handle.element, { pointerId: 6, clientX: 1200 }), + ); + }); + act(() => setDesktopMatch(false)); + + expect(result.current.isDesktop).toBe(false); + expect(readPanelSizePreference("pushPanelWidthPx")).toBeNull(); + expect(document.body.style.cursor).toBe(""); + expect(document.body.style.userSelect).toBe(""); + }); + + it("ignores non-primary pen buttons", () => { + const { result } = renderHook(() => useResizablePanel(true)); + const handle = createPointerHandle(); + const preventDefault = vi.fn(); + + act(() => { + result.current.handleProps.onPointerDown( + pointerEvent(handle.element, { + pointerId: 3, + pointerType: "pen", + button: 2, + preventDefault, + }), + ); + result.current.handleProps.onPointerMove( + pointerEvent(handle.element, { pointerId: 3, clientX: 1200 }), + ); + }); + + expect(handle.setPointerCapture).not.toHaveBeenCalled(); + expect(preventDefault).not.toHaveBeenCalled(); + expect(result.current.panelWidth).toBe(1000); + }); + + it.each([ + ["fine", false, HANDLE_FINE_GUTTER_PX, 24], + ["coarse", true, HANDLE_COARSE_GUTTER_PX, 26], + ] as const)("returns the budgeted %s seam gutter", (_, coarse, gutter, target) => { + coarsePointer = coarse; + const { result } = renderHook(() => useResizablePanel(true)); + const style = result.current.handleProps.style; + const inset = (gutter - 4) / 2; + + expect(style).toMatchObject({ + touchAction: "none", + boxSizing: "content-box", + paddingInlineStart: HANDLE_OUTWARD_SLIVER_PX + inset, + paddingInlineEnd: HANDLE_INWARD_SLIVER_PX + inset, + marginInlineStart: -HANDLE_OUTWARD_SLIVER_PX, + marginInlineEnd: -HANDLE_INWARD_SLIVER_PX, + backgroundClip: "content-box", + }); + expect(4 + Number(style.paddingInlineStart) + Number(style.paddingInlineEnd)).toBe(target); + // The transcript scrollbar thumb occupies the 6–12px band from this seam; + // keep the handle out of that band so touch-scroll starts remain available. + expect(-Number(style.marginInlineStart)).toBeLessThanOrEqual(6); + // FilesPanel's toolbar uses px-2 (8px); do not annex beyond that gutter + // into its search control or other panel-side interactive content. + expect(-Number(style.marginInlineEnd)).toBeLessThanOrEqual(8); + expect( + 4 + + Number(style.paddingInlineStart) + + Number(style.paddingInlineEnd) + + Number(style.marginInlineStart) + + Number(style.marginInlineEnd), + ).toBe(gutter); + }); + + it("updates the gutter when primary pointer coarseness changes", () => { + const { result } = renderHook(() => useResizablePanel(true)); + expect(result.current.handleProps.style.marginInlineStart).toBe(-HANDLE_OUTWARD_SLIVER_PX); + expect(result.current.handleProps.style.paddingInlineStart).toBe(9); + + act(() => setCoarseMatch(true)); + + expect(result.current.handleProps.style.marginInlineStart).toBe(-HANDLE_OUTWARD_SLIVER_PX); + expect(result.current.handleProps.style.paddingInlineStart).toBe(10); }); it("notifies multiple mounted subscribers from the shared width store", () => { diff --git a/web/src/hooks/useResizablePanel.ts b/web/src/hooks/useResizablePanel.ts index 16bb515c55..c4c7282898 100644 --- a/web/src/hooks/useResizablePanel.ts +++ b/web/src/hooks/useResizablePanel.ts @@ -5,6 +5,25 @@ const MIN_WIDTH_PX = 320; const MAX_WIDTH_RATIO = 0.8; // 80% of viewport /** Tailwind `md` breakpoint — must track the value in tailwind.config. */ const MD_BREAKPOINT = 768; +const PAINTED_HANDLE_WIDTH_PX = 4; +export const HANDLE_OUTWARD_SLIVER_PX = 6; +export const HANDLE_INWARD_SLIVER_PX = 8; +export const HANDLE_COARSE_GUTTER_PX = 12; +export const HANDLE_FINE_GUTTER_PX = 10; + +function handleGutterStyle(isCoarse: boolean): React.CSSProperties { + const gutter = isCoarse ? HANDLE_COARSE_GUTTER_PX : HANDLE_FINE_GUTTER_PX; + const inset = (gutter - PAINTED_HANDLE_WIDTH_PX) / 2; + return { + touchAction: "none", + boxSizing: "content-box", + paddingInlineStart: HANDLE_OUTWARD_SLIVER_PX + inset, + paddingInlineEnd: HANDLE_INWARD_SLIVER_PX + inset, + marginInlineStart: -HANDLE_OUTWARD_SLIVER_PX, + marginInlineEnd: -HANDLE_INWARD_SLIVER_PX, + backgroundClip: "content-box", + }; +} /** Clamp a width value to the allowed range for the current viewport. */ function clampWidth(w: number, minPx = MIN_WIDTH_PX): number { @@ -79,7 +98,7 @@ export function resetSharedWidthStoreForTesting(): void { } /** - * Hook for making a right-side panel resizable via mouse drag on its left edge. + * Hook for making a right-side panel resizable via pointer drag on its left edge. * * On desktop (`≥ md`) the panel width is controlled via an inline style * driven by drag state. On mobile (`< md`) the panel is a full-screen @@ -99,7 +118,9 @@ export function useResizablePanel(open: boolean, defaultWidthVw = 50, minWidthPx getSharedWidthSnapshot, getSharedWidthServerSnapshot, ); - const dragging = useRef(false); + const activePointerId = useRef(null); + const documentFallbackCleanupRef = useRef<(() => void) | null>(null); + const overlayRef = useRef(null); const minWidthRef = useRef(minWidthPx); minWidthRef.current = minWidthPx; @@ -115,6 +136,18 @@ export function useResizablePanel(open: boolean, defaultWidthVw = 50, minWidthPx return () => mql.removeEventListener("change", handler); }, []); + const [isCoarse, setIsCoarse] = useState( + () => typeof window !== "undefined" && !!window.matchMedia?.("(pointer: coarse)").matches, + ); + + useEffect(() => { + const mql = window.matchMedia?.("(pointer: coarse)"); + if (!mql) return; + const handler = (e: MediaQueryListEvent) => setIsCoarse(e.matches); + mql.addEventListener("change", handler); + return () => mql.removeEventListener("change", handler); + }, []); + // Re-clamp the stored width when the viewport resizes so a width // that was valid on a wider monitor doesn't push content off-screen // after shrinking the browser. @@ -147,15 +180,89 @@ export function useResizablePanel(open: boolean, defaultWidthVw = 50, minWidthPx minWidthPx, ); - const onMouseDown = useCallback( - (e: React.MouseEvent) => { + const addDragOverlay = useCallback(() => { + if (overlayRef.current || typeof document === "undefined") return; + const element = document.createElement("div"); + element.style.cssText = + "position:fixed;inset:0;z-index:2147483647;cursor:col-resize;background:transparent;"; + document.body.appendChild(element); + overlayRef.current = element; + }, []); + + const removeDragOverlay = useCallback(() => { + overlayRef.current?.remove(); + overlayRef.current = null; + }, []); + + const endDrag = useCallback( + (persist: boolean) => { + if (activePointerId.current === null) return; + activePointerId.current = null; + documentFallbackCleanupRef.current?.(); + documentFallbackCleanupRef.current = null; + if (persist) persistSharedWidth(); + removeDragOverlay(); + document.body.style.cursor = ""; + document.body.style.userSelect = ""; + }, + [removeDragOverlay], + ); + + const onPointerDown = useCallback( + (e: React.PointerEvent) => { if (!open || !isDesktop) return; + if (activePointerId.current !== null) return; + if (e.button !== 0) return; + try { + e.currentTarget.setPointerCapture(e.pointerId); + } catch { + return; + } e.preventDefault(); - dragging.current = true; + activePointerId.current = e.pointerId; + const onDocumentPointerUp = (event: PointerEvent) => { + if (event.pointerId === activePointerId.current) endDrag(true); + }; + const onDocumentPointerCancel = (event: PointerEvent) => { + if (event.pointerId === activePointerId.current) endDrag(false); + }; + document.addEventListener("pointerup", onDocumentPointerUp); + document.addEventListener("pointercancel", onDocumentPointerCancel); + documentFallbackCleanupRef.current = () => { + document.removeEventListener("pointerup", onDocumentPointerUp); + document.removeEventListener("pointercancel", onDocumentPointerCancel); + }; + addDragOverlay(); document.body.style.cursor = "col-resize"; document.body.style.userSelect = "none"; }, - [open, isDesktop], + [addDragOverlay, endDrag, open, isDesktop], + ); + + const onPointerMove = useCallback((e: React.PointerEvent) => { + if (e.pointerId !== activePointerId.current) return; + // Update the live width only; persist once on pointerup to avoid a + // synchronous localStorage write per pointermove. + setSharedWidth(clampWidth(window.innerWidth - e.clientX, minWidthRef.current)); + }, []); + + const onPointerUp = useCallback( + (e: React.PointerEvent) => { + if (e.pointerId !== activePointerId.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 !== activePointerId.current) return; + endDrag(false); + }, + [endDrag], ); // Keyboard resize: left/right arrow keys adjust width by 20px. @@ -180,36 +287,11 @@ export function useResizablePanel(open: boolean, defaultWidthVw = 50, minWidthPx [open, isDesktop, resolvedWidth], ); - useEffect(() => { - function onMouseMove(e: MouseEvent) { - if (!dragging.current) return; - // Update the live width only; persisting on every move would fire a - // synchronous localStorage write per mousemove. We snapshot once on release. - setSharedWidth(clampWidth(window.innerWidth - e.clientX, minWidthRef.current)); - } + useEffect(() => () => endDrag(false), [endDrag]); - function onMouseUp() { - if (!dragging.current) return; - dragging.current = false; - persistSharedWidth(); - document.body.style.cursor = ""; - document.body.style.userSelect = ""; - } - - window.addEventListener("mousemove", onMouseMove); - window.addEventListener("mouseup", onMouseUp); - return () => { - window.removeEventListener("mousemove", onMouseMove); - window.removeEventListener("mouseup", onMouseUp); - // Reset body styles if unmounted mid-drag (e.g. panel closed - // via Escape while dragging). - if (dragging.current) { - dragging.current = false; - document.body.style.cursor = ""; - document.body.style.userSelect = ""; - } - }; - }, []); + useEffect(() => { + if (!open || !isDesktop) endDrag(false); + }, [endDrag, isDesktop, open]); // On mobile the panel is a fixed full-screen overlay — no inline width. const panelWidth = isDesktop ? (open ? resolvedWidth : 0) : undefined; @@ -219,8 +301,13 @@ export function useResizablePanel(open: boolean, defaultWidthVw = 50, minWidthPx panelWidth, /** Props to spread onto the resize handle element. */ handleProps: { - onMouseDown, + onPointerDown, + onPointerMove, + onPointerUp, + onPointerCancel, + onLostPointerCapture: onPointerCancel, onKeyDown, + style: handleGutterStyle(isCoarse), role: "separator" as const, "aria-orientation": "vertical" as const, "aria-label": "Resize panel", diff --git a/web/src/hooks/useResizableSidebar.test.tsx b/web/src/hooks/useResizableSidebar.test.tsx index ec7f0c8c3d..d4b8689963 100644 --- a/web/src/hooks/useResizableSidebar.test.tsx +++ b/web/src/hooks/useResizableSidebar.test.tsx @@ -1,5 +1,5 @@ -import { act, renderHook } from "@testing-library/react"; -import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { act, renderHook, waitFor } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { readPanelSizePreference } from "@/lib/panelSizePreferences"; import { resetSidebarWidthStoreForTesting, useResizableSidebar } from "./useResizableSidebar"; @@ -28,20 +28,65 @@ function nudge( return result.current.width; } -// Simulate a drag: press the handle, move the cursor to clientX, release. +function createHandle() { + const handle = document.createElement("div"); + handle.dataset.testResizeHandle = "true"; + document.body.appendChild(handle); + const capturedPointers = new Set(); + handle.setPointerCapture = vi.fn((pointerId: number) => capturedPointers.add(pointerId)); + handle.hasPointerCapture = vi.fn((pointerId: number) => capturedPointers.has(pointerId)); + handle.releasePointerCapture = vi.fn((pointerId: number) => capturedPointers.delete(pointerId)); + return handle; +} + +function pointerEvent( + handle: HTMLDivElement, + overrides: Partial<{ + pointerId: number; + pointerType: string; + button: number; + clientX: number; + }> = {}, +): React.PointerEvent { + return { + pointerId: 1, + pointerType: "touch", + button: 0, + clientX: 0, + currentTarget: handle, + preventDefault: () => {}, + ...overrides, + } as unknown as React.PointerEvent; +} + +function documentPointerEvent( + type: "pointerup" | "pointercancel", + pointerId: number, +): PointerEvent { + const event = new Event(type) as PointerEvent; + Object.defineProperty(event, "pointerId", { value: pointerId }); + return event; +} + +function startDrag( + result: { current: ReturnType }, + handle: HTMLDivElement, + overrides: Parameters[1] = {}, +): void { + act(() => result.current.handleProps.onPointerDown(pointerEvent(handle, overrides))); +} + +// Simulate a drag: press the handle, move the captured pointer, then release. // For a left panel the live width tracks the cursor's distance from the // viewport's left edge (clientX). function dragTo( result: { current: ReturnType }, clientX: number, ): void { - act(() => - result.current.handleProps.onMouseDown({ - preventDefault: () => {}, - } as React.MouseEvent), - ); - act(() => window.dispatchEvent(new MouseEvent("mousemove", { clientX }))); - act(() => window.dispatchEvent(new MouseEvent("mouseup"))); + const handle = createHandle(); + startDrag(result, handle); + act(() => result.current.handleProps.onPointerMove(pointerEvent(handle, { clientX }))); + act(() => result.current.handleProps.onPointerUp(pointerEvent(handle))); } beforeEach(() => { @@ -49,6 +94,7 @@ beforeEach(() => { }); afterEach(() => { + document.querySelectorAll("[data-test-resize-handle]").forEach((handle) => handle.remove()); localStorage.clear(); resetSidebarWidthStoreForTesting(); setInnerWidth(originalInnerWidth); @@ -118,4 +164,206 @@ describe("useResizableSidebar", () => { act(() => window.dispatchEvent(new Event("resize"))); expect(result.current.width).toBe(900); }); + + it("captures pointer drags on the handle and exposes touch-safe affordances", () => { + const { result } = renderHook(() => useResizableSidebar()); + const handle = createHandle(); + + startDrag(result, handle, { pointerId: 7 }); + expect(handle.setPointerCapture).toHaveBeenCalledWith(7); + expect(document.body.style.cursor).toBe("col-resize"); + expect(document.body.style.userSelect).toBe("none"); + expect(result.current.handleProps.style).toEqual({ + touchAction: "none", + boxSizing: "content-box", + paddingInlineStart: 9, + paddingInlineEnd: 11, + marginInlineStart: -8, + marginInlineEnd: -10, + backgroundClip: "content-box", + }); + + act(() => + result.current.handleProps.onPointerMove( + pointerEvent(handle, { pointerId: 7, clientX: 480 }), + ), + ); + expect(result.current.width).toBe(480); + act(() => result.current.handleProps.onPointerUp(pointerEvent(handle, { pointerId: 7 }))); + + expect(readPanelSizePreference("sidebarWidthPx")).toBe(480); + expect(handle.releasePointerCapture).toHaveBeenCalledWith(7); + expect(document.body.style.cursor).toBe(""); + expect(document.body.style.userSelect).toBe(""); + }); + + it("reacts to primary-pointer coarseness with asymmetric hit padding", () => { + let coarse = false; + const listeners = new Set<() => void>(); + const query = { + get matches() { + return coarse; + }, + media: "(pointer: coarse)", + onchange: null, + addListener: vi.fn(), + removeListener: vi.fn(), + addEventListener: vi.fn((_type: string, listener: () => void) => listeners.add(listener)), + removeEventListener: vi.fn((_type: string, listener: () => void) => + listeners.delete(listener), + ), + dispatchEvent: vi.fn(() => false), + } as MediaQueryList; + const matchMedia = vi.spyOn(window, "matchMedia").mockReturnValue(query); + const { result, unmount } = renderHook(() => useResizableSidebar()); + + expect(result.current.handleProps.style.paddingInlineStart).toBe(9); + expect(result.current.handleProps.style.paddingInlineEnd).toBe(11); + + coarse = true; + act(() => listeners.forEach((listener) => listener())); + expect(result.current.handleProps.style.paddingInlineStart).toBe(10); + expect(result.current.handleProps.style.paddingInlineEnd).toBe(12); + + unmount(); + expect(query.removeEventListener).toHaveBeenCalledWith("change", expect.any(Function)); + matchMedia.mockRestore(); + }); + + it("stays idle when pointer capture throws", () => { + const { result } = renderHook(() => useResizableSidebar()); + const handle = createHandle(); + handle.setPointerCapture = vi.fn(() => { + throw new DOMException("capture failed"); + }); + const preventDefault = vi.fn(); + + act(() => + result.current.handleProps.onPointerDown({ + ...pointerEvent(handle), + preventDefault, + } as React.PointerEvent), + ); + + expect(preventDefault).not.toHaveBeenCalled(); + expect(document.body.style.cursor).toBe(""); + expect(document.body.style.userSelect).toBe(""); + act(() => result.current.handleProps.onPointerMove(pointerEvent(handle, { clientX: 500 }))); + expect(result.current.width).toBe(320); + }); + + it("ignores concurrent pointers until the captured pointer ends", () => { + const { result } = renderHook(() => useResizableSidebar()); + const handle = createHandle(); + + startDrag(result, handle, { pointerId: 1 }); + startDrag(result, handle, { pointerId: 2 }); + expect(handle.setPointerCapture).toHaveBeenCalledTimes(1); + + act(() => + result.current.handleProps.onPointerMove( + pointerEvent(handle, { pointerId: 2, clientX: 700 }), + ), + ); + expect(result.current.width).toBe(320); + act(() => + result.current.handleProps.onPointerMove( + pointerEvent(handle, { pointerId: 1, clientX: 450 }), + ), + ); + expect(result.current.width).toBe(450); + }); + + it("does not start a drag from a pen barrel button", () => { + const { result } = renderHook(() => useResizableSidebar()); + const handle = createHandle(); + + startDrag(result, handle, { pointerType: "pen", button: 2 }); + expect(handle.setPointerCapture).not.toHaveBeenCalled(); + expect(document.body.style.cursor).toBe(""); + + act(() => result.current.handleProps.onPointerMove(pointerEvent(handle, { clientX: 500 }))); + expect(result.current.width).toBe(320); + }); + + it.each(["onPointerCancel", "onLostPointerCapture"] as const)( + "keeps the last applied width without persisting on %s", + (abortHandler) => { + const { result } = renderHook(() => useResizableSidebar()); + const handle = createHandle(); + + startDrag(result, handle, { pointerId: 3 }); + act(() => + result.current.handleProps.onPointerMove( + pointerEvent(handle, { pointerId: 3, clientX: 500 }), + ), + ); + expect(result.current.width).toBe(500); + + act(() => result.current.handleProps[abortHandler](pointerEvent(handle, { pointerId: 3 }))); + expect(result.current.width).toBe(500); + expect(readPanelSizePreference("sidebarWidthPx")).toBeNull(); + expect(document.body.style.cursor).toBe(""); + expect(document.body.style.userSelect).toBe(""); + }, + ); + + it("keeps the last applied width without persisting on unmount", () => { + const { result, unmount } = renderHook(() => useResizableSidebar()); + const handle = createHandle(); + + startDrag(result, handle, { pointerId: 9 }); + act(() => + result.current.handleProps.onPointerMove( + pointerEvent(handle, { pointerId: 9, clientX: 560 }), + ), + ); + expect(result.current.width).toBe(560); + + unmount(); + expect(readPanelSizePreference("sidebarWidthPx")).toBeNull(); + expect(document.body.style.cursor).toBe(""); + expect(document.body.style.userSelect).toBe(""); + + const remounted = renderHook(() => useResizableSidebar()); + expect(remounted.result.current.width).toBe(560); + remounted.unmount(); + }); + + it("aborts when the handle unmounts mid-drag and allows the next drag", async () => { + const { result } = renderHook(() => useResizableSidebar()); + const firstHandle = createHandle(); + + startDrag(result, firstHandle, { pointerId: 11 }); + expect(document.body.style.cursor).toBe("col-resize"); + firstHandle.remove(); + + await waitFor(() => expect(document.body.style.cursor).toBe("")); + expect(document.body.style.userSelect).toBe(""); + expect(readPanelSizePreference("sidebarWidthPx")).toBeNull(); + + const nextHandle = createHandle(); + startDrag(result, nextHandle, { pointerId: 12 }); + expect(nextHandle.setPointerCapture).toHaveBeenCalledWith(12); + expect(document.body.style.cursor).toBe("col-resize"); + act(() => + result.current.handleProps.onPointerCancel(pointerEvent(nextHandle, { pointerId: 12 })), + ); + }); + + it("uses document fallbacks when the captured handle misses the terminal event", () => { + const { result } = renderHook(() => useResizableSidebar()); + const handle = createHandle(); + + startDrag(result, handle, { pointerId: 13 }); + act(() => + result.current.handleProps.onPointerMove( + pointerEvent(handle, { pointerId: 13, clientX: 620 }), + ), + ); + act(() => document.dispatchEvent(documentPointerEvent("pointerup", 13))); + + expect(readPanelSizePreference("sidebarWidthPx")).toBe(620); + expect(document.body.style.cursor).toBe(""); + }); }); diff --git a/web/src/hooks/useResizableSidebar.ts b/web/src/hooks/useResizableSidebar.ts index 70de623fc4..fa5fff8cba 100644 --- a/web/src/hooks/useResizableSidebar.ts +++ b/web/src/hooks/useResizableSidebar.ts @@ -9,7 +9,7 @@ // Unlike the inline panel this has no "boost" machinery — nothing auto-widens // the sidebar — so the store is just a persisted, viewport-clamped width. -import { useCallback, useEffect, useRef, useSyncExternalStore } from "react"; +import { useCallback, useEffect, useRef, useState, useSyncExternalStore } from "react"; import { readPanelSizePreference, writePanelSizePreference } from "@/lib/panelSizePreferences"; // Default 320px (20rem) — wider than the old fixed ``md:w-64`` (256px) sidebar @@ -19,6 +19,33 @@ import { readPanelSizePreference, writePanelSizePreference } from "@/lib/panelSi const DEFAULT_WIDTH_PX = 320; const MIN_WIDTH_PX = 220; const MAX_WIDTH_RATIO = 0.5; +const PAINTED_STRIP_PX = 4; // must match the handle's `w-1` class +const COARSE_GUTTER_PX = 8; +const FINE_GUTTER_PX = 6; +const INWARD_SLIVER_PX = 8; +const OUTWARD_SLIVER_PX = 10; + +function hasCoarsePrimaryPointer(): boolean { + return ( + typeof window !== "undefined" && + typeof window.matchMedia === "function" && + window.matchMedia("(pointer: coarse)").matches + ); +} + +function gutterStyle(coarsePointer: boolean): React.CSSProperties { + const gutter = coarsePointer ? COARSE_GUTTER_PX : FINE_GUTTER_PX; + const inset = (gutter - PAINTED_STRIP_PX) / 2; + return { + touchAction: "none", + boxSizing: "content-box", + paddingInlineStart: INWARD_SLIVER_PX + inset, + paddingInlineEnd: OUTWARD_SLIVER_PX + inset, + marginInlineStart: -INWARD_SLIVER_PX, + marginInlineEnd: -OUTWARD_SLIVER_PX, + backgroundClip: "content-box", + }; +} function clamp(w: number): number { // No viewport available off the DOM (SSR / node test env) — this runs during @@ -91,7 +118,10 @@ export function resetSidebarWidthStoreForTesting(): void { export function useResizableSidebar() { const raw = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot); const width = clamp(raw ?? DEFAULT_WIDTH_PX); - const dragging = useRef(false); + const [coarsePointer, setCoarsePointer] = useState(hasCoarsePrimaryPointer); + const activePointerId = useRef(null); + const activeHandle = useRef(null); + const dragCleanup = useRef<(() => void) | null>(null); // Re-clamp on viewport resize so a shrunken window pulls the sidebar back // under the ceiling; widening re-derives from the persisted preference so the @@ -104,13 +134,102 @@ export function useResizableSidebar() { return () => window.removeEventListener("resize", onResize); }, []); - const onMouseDown = useCallback((e: React.MouseEvent) => { - e.preventDefault(); - dragging.current = true; - document.body.style.cursor = "col-resize"; - document.body.style.userSelect = "none"; + useEffect(() => { + const query = window.matchMedia("(pointer: coarse)"); + const onChange = () => setCoarsePointer(query.matches); + query.addEventListener("change", onChange); + return () => query.removeEventListener("change", onChange); }, []); + // No iframe shield: the sidebar never adjoins the preview iframe, so capture suffices. + const finishDrag = useCallback((commit: boolean) => { + const pointerId = activePointerId.current; + const handle = activeHandle.current; + if (pointerId === null) return; + activePointerId.current = null; + activeHandle.current = null; + dragCleanup.current?.(); + dragCleanup.current = null; + if (commit) { + persistWidth(storedWidth); + } + try { + if (handle?.hasPointerCapture(pointerId)) { + handle.releasePointerCapture(pointerId); + } + } catch { + // The handle may already be detached when a responsive render gate flips. + } + document.body.style.cursor = ""; + document.body.style.userSelect = ""; + }, []); + + const onPointerDown = useCallback( + (e: React.PointerEvent) => { + // First pointer wins; a right/middle mouse press doesn't start a drag. + if (activePointerId.current !== null) return; + if (e.button !== 0) return; + + try { + e.currentTarget.setPointerCapture(e.pointerId); + } catch { + return; + } + + e.preventDefault(); + const handle = e.currentTarget; + const pointerId = e.pointerId; + activePointerId.current = pointerId; + activeHandle.current = handle; + + function onDocumentPointerUp(event: PointerEvent) { + if (event.pointerId === activePointerId.current) finishDrag(true); + } + + function onDocumentPointerCancel(event: PointerEvent) { + if (event.pointerId === activePointerId.current) finishDrag(false); + } + + const observer = new MutationObserver(() => { + if (activeHandle.current && !activeHandle.current.isConnected) { + finishDrag(false); + } + }); + observer.observe(document.documentElement, { childList: true, subtree: true }); + document.addEventListener("pointerup", onDocumentPointerUp); + document.addEventListener("pointercancel", onDocumentPointerCancel); + dragCleanup.current = () => { + observer.disconnect(); + document.removeEventListener("pointerup", onDocumentPointerUp); + document.removeEventListener("pointercancel", onDocumentPointerCancel); + }; + document.body.style.cursor = "col-resize"; + document.body.style.userSelect = "none"; + }, + [finishDrag], + ); + + const onPointerMove = useCallback((e: React.PointerEvent) => { + if (e.pointerId !== activePointerId.current) return; + setStoredWidth(clamp(e.clientX)); + }, []); + + const onPointerUp = useCallback( + (e: React.PointerEvent) => { + if (e.pointerId !== activePointerId.current) return; + finishDrag(true); + }, + [finishDrag], + ); + + const onPointerCancel = useCallback( + (e: React.PointerEvent) => { + if (e.pointerId !== activePointerId.current) return; + finishDrag(false); + }, + [finishDrag], + ); + const onKeyDown = useCallback( (e: React.KeyboardEvent) => { const step = 20; @@ -126,46 +245,23 @@ export function useResizableSidebar() { [width], ); - useEffect(() => { - function onMouseMove(e: MouseEvent) { - if (!dragging.current) return; - // Left panel: width is the cursor's distance from the viewport's left - // edge. Update the live width only; persist once on release to avoid a - // synchronous localStorage write per mousemove. - setStoredWidth(clamp(e.clientX)); - } - - function onMouseUp() { - if (!dragging.current) return; - dragging.current = false; - persistWidth(storedWidth); - document.body.style.cursor = ""; - document.body.style.userSelect = ""; - } - - window.addEventListener("mousemove", onMouseMove); - window.addEventListener("mouseup", onMouseUp); - return () => { - window.removeEventListener("mousemove", onMouseMove); - window.removeEventListener("mouseup", onMouseUp); - if (dragging.current) { - dragging.current = false; - document.body.style.cursor = ""; - document.body.style.userSelect = ""; - } - }; - }, []); + useEffect(() => () => finishDrag(false), [finishDrag]); return { /** Current sidebar width in px (already viewport-clamped). */ width, handleProps: { - onMouseDown, + onPointerDown, + onPointerMove, + onPointerUp, + onPointerCancel, + onLostPointerCapture: onPointerCancel, onKeyDown, role: "separator" as const, "aria-orientation": "vertical" as const, "aria-label": "Resize sidebar", tabIndex: 0, + style: gutterStyle(coarsePointer), }, }; } diff --git a/web/src/lib/breakpoints.test.ts b/web/src/lib/breakpoints.test.ts new file mode 100644 index 0000000000..3d30828e45 --- /dev/null +++ b/web/src/lib/breakpoints.test.ts @@ -0,0 +1,71 @@ +import { renderHook } from "@testing-library/react"; +import { describe, expect, it, vi, afterEach } from "vitest"; + +import { useIsMobileViewport } from "@/hooks/useIsMobileViewport"; +import { MD_BREAKPOINT_PX, MD_MIN_WIDTH_QUERY, isMobileViewport } from "./breakpoints"; + +// Evaluate min-/max-width queries against a simulated viewport width, so +// boundary behavior at fractional widths can be exercised. +function stubViewportWidth(width: number) { + Object.defineProperty(window, "matchMedia", { + writable: true, + configurable: true, + value: vi.fn((query: string) => ({ + matches: (() => { + const min = query.match(/^\(min-width: ([\d.]+)px\)$/); + if (min) return width >= parseFloat(min[1]); + const max = query.match(/^\(max-width: ([\d.]+)px\)$/); + if (max) return width <= parseFloat(max[1]); + return false; + })(), + media: query, + addEventListener: () => {}, + removeEventListener: () => {}, + })), + }); +} + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe("breakpoints", () => { + it("encodes Tailwind's md breakpoint exactly once", () => { + expect(MD_BREAKPOINT_PX).toBe(768); + // md: variant (inclusive lower bound) — the one canonical query. + expect(MD_MIN_WIDTH_QUERY).toBe("(min-width: 768px)"); + }); + + it("isMobileViewport is true below md and false at md+", () => { + stubViewportWidth(MD_BREAKPOINT_PX); + expect(isMobileViewport()).toBe(false); + + stubViewportWidth(MD_BREAKPOINT_PX - 1); + expect(isMobileViewport()).toBe(true); + }); + + // The hook, the imperative helper, and the native-shell signal must give + // the SAME answer at every width. The pole is "mobile unless provably + // md+": in the fractional sliver (767.98, 768) — reachable under browser + // zoom — Tailwind's md: overrides don't apply, the page renders its mobile + // base styles, and the predicate must say mobile too. + it.each([ + [767, true], + [767.5, true], + [767.98, true], + [767.99, true], + [768, false], + ])("hook, helper, and published signal agree at %spx", (width, mobile) => { + stubViewportWidth(width); + expect(isMobileViewport()).toBe(mobile); + // eslint-disable-next-line no-underscore-dangle -- bridge-global naming + expect(window.__omnigentIsMobileViewport?.()).toBe(mobile); + const { result } = renderHook(() => useIsMobileViewport()); + expect(result.current).toBe(mobile); + }); + + it("publishes the signal native shells consume for their back handler", () => { + // eslint-disable-next-line no-underscore-dangle -- bridge-global naming + expect(window.__omnigentIsMobileViewport).toBe(isMobileViewport); + }); +}); diff --git a/web/src/lib/breakpoints.ts b/web/src/lib/breakpoints.ts new file mode 100644 index 0000000000..c5662de36a --- /dev/null +++ b/web/src/lib/breakpoints.ts @@ -0,0 +1,62 @@ +// Canonical encoding of the shell's `md` layout breakpoint. +// +// Tailwind's `md` breakpoint (768px) is the shell's single mobile/desktop +// layout pivot, used both by CSS (`md:` / `max-md:` classes) and by JS call +// sites. Every JS encoding of that line derives from this module so the +// variants (767px / 767.98px / 768) can't drift apart. Viewport width is a +// LAYOUT concern only — input capability (touch, hover) is a separate axis, +// exposed by `useInputCapabilities`. +// +// Pole choice: the layout predicate is "mobile unless provably md+", i.e. +// `!(min-width: 768px)`. Tailwind's `md:` overrides are what produce the +// desktop layout, and they apply only at >= 768px — at fractional widths in +// (767.98, 768) (reachable under browser zoom) neither `md:` nor `max-md:` +// matches, so the page renders its mobile base styles. A max-md-based +// predicate would call that sliver "desktop" and disagree with what's on +// screen; deliberately none exists here. + +export const MD_BREAKPOINT_PX = 768; + +/** `(min-width: …)` media query — matches Tailwind's `md:` variant. */ +export const MD_MIN_WIDTH_QUERY = `(min-width: ${MD_BREAKPOINT_PX}px)`; + +/** + * Subscribe to change events on a set of media queries. SSR-safe (no-op + * teardown when matchMedia is unavailable). Returns the unsubscribe function + * expected by `useSyncExternalStore` subscribe callbacks. + */ +export function subscribeMatchMedia(queries: readonly string[], callback: () => void): () => void { + if (typeof window === "undefined" || !window.matchMedia) return () => {}; + const lists = queries.map((q) => window.matchMedia(q)); + for (const mql of lists) mql.addEventListener("change", callback); + return () => { + for (const mql of lists) mql.removeEventListener("change", callback); + }; +} + +/** + * True on mobile-layout viewports: anything not provably at `md`+ (see the + * module comment on the pole choice). Non-reactive point-in-time check for + * event handlers; components that must re-render on breakpoint crossings use + * `useIsMobileViewport` instead — its snapshot IS this function, so the two + * can never disagree. SSR-safe (returns false when window is undefined). + */ +export function isMobileViewport(): boolean { + if (typeof window === "undefined" || !window.matchMedia) return false; + return !window.matchMedia(MD_MIN_WIDTH_QUERY).matches; +} + +declare global { + interface Window { + /** Layout signal consumed by native shells (see publication below). */ + __omnigentIsMobileViewport?: () => boolean; + } +} + +// Native shells (e.g. the Android back handler in NativeBridgeScript.kt) +// consume the web layer's breakpoint signal instead of re-deriving it from +// their own copy of the literal. +if (typeof window !== "undefined") { + // eslint-disable-next-line no-underscore-dangle -- bridge-global naming + window.__omnigentIsMobileViewport = isMobileViewport; +} diff --git a/web/src/pages/ChatPage.composer.test.tsx b/web/src/pages/ChatPage.composer.test.tsx index 8703850057..3bc35173c4 100644 --- a/web/src/pages/ChatPage.composer.test.tsx +++ b/web/src/pages/ChatPage.composer.test.tsx @@ -148,6 +148,35 @@ describe("Composer growth layout", () => { }); }); +// Evaluate min-/max-width media queries against a simulated viewport width, +// so each test runs at an explicit real-browser width instead of inheriting +// the global test-setup mock (which answers false to every query). +function stubViewportWidth(width: number) { + Object.defineProperty(window, "matchMedia", { + writable: true, + configurable: true, + value: (query: string) => ({ + matches: (() => { + const min = query.match(/^\(min-width: ([\d.]+)px\)$/); + if (min) return width >= parseFloat(min[1]); + const max = query.match(/^\(max-width: ([\d.]+)px\)$/); + if (max) return width <= parseFloat(max[1]); + return false; + })(), + media: query, + addEventListener: () => {}, + removeEventListener: () => {}, + }), + }); +} + +// These suites assert desktop composer behavior (mount autofocus, +// Enter-sends, hover affordances); tests that need a mobile width re-pin it +// themselves. +beforeEach(() => { + stubViewportWidth(1280); +}); + describe("Composer Claude goal control", () => { afterEach(() => { cleanup(); @@ -1495,6 +1524,15 @@ describe("Composer reply-quote focus", () => { expect(document.activeElement).toBe(ta); }); + // The mobile boundary is the canonical layout predicate (not provably at + // md+), not the historical bespoke 767px — 767.5px sits between the two, + // so this test pins the migrated boundary: suppression must apply there. + it("suppresses mount autofocus at 767.5px (below the canonical md boundary)", () => { + stubViewportWidth(767.5); + render(); + expect(document.activeElement).not.toBe(textarea()); + }); + it("does not steal focus when a quote is removed", () => { // Removing a chip (the X button) shrinks the count — the effect only // fires when the count grows, so focus must stay put. diff --git a/web/src/pages/ChatPage.mention.test.tsx b/web/src/pages/ChatPage.mention.test.tsx index 37989c9400..2496d5bf1c 100644 --- a/web/src/pages/ChatPage.mention.test.tsx +++ b/web/src/pages/ChatPage.mention.test.tsx @@ -200,6 +200,34 @@ describe("mentionMarkerFor", () => { }); }); +// Evaluate min-/max-width media queries against a simulated viewport width, +// so each test runs at an explicit real-browser width instead of inheriting +// the global test-setup mock (which answers false to every query). +function stubViewportWidth(width: number) { + Object.defineProperty(window, "matchMedia", { + writable: true, + configurable: true, + value: (query: string) => ({ + matches: (() => { + const min = query.match(/^\(min-width: ([\d.]+)px\)$/); + if (min) return width >= parseFloat(min[1]); + const max = query.match(/^\(max-width: ([\d.]+)px\)$/); + if (max) return width <= parseFloat(max[1]); + return false; + })(), + media: query, + addEventListener: () => {}, + removeEventListener: () => {}, + }), + }); +} + +// Pin a desktop width for every test: the mention browser's keyboard +// behavior (Enter acts on the highlighted row) is gated off on mobile. +beforeEach(() => { + stubViewportWidth(1280); +}); + describe("Composer @-file-mention browser (native sessions)", () => { // Each test gets a fresh conversation id and a cleared draft store: the // module-scoped ``sessionDrafts`` map (persisted to localStorage) is keyed by diff --git a/web/src/pages/ChatPage.tsx b/web/src/pages/ChatPage.tsx index 5169ced975..93233e29b8 100644 --- a/web/src/pages/ChatPage.tsx +++ b/web/src/pages/ChatPage.tsx @@ -4634,17 +4634,12 @@ export function Composer({ // On mobile, programmatic focus immediately summons the software keyboard. // Keep desktop's fast-type affordance, but let mobile users explicitly tap // the composer when switching back from Terminal or changing sessions. - const [isMobile, setIsMobile] = useState( - () => typeof window !== "undefined" && window.matchMedia("(max-width: 767px)").matches, - ); + // "Mobile" is deliberately the canonical layout predicate (not provably + // at md+), so the keyboard-suppression boundary tracks the shell's layout + // pivot rather than a bespoke 767px threshold. + const isMobile = useIsMobileViewport(); const isMobileRef = useRef(isMobile); isMobileRef.current = isMobile; - useEffect(() => { - const mq = window.matchMedia("(max-width: 767px)"); - const handler = (e: MediaQueryListEvent) => setIsMobile(e.matches); - mq.addEventListener("change", handler); - return () => mq.removeEventListener("change", handler); - }, []); useEffect(() => { const restored = conversationId ? sessionDrafts.get(conversationId) : undefined; diff --git a/web/src/shell/AppShell.test.tsx b/web/src/shell/AppShell.test.tsx index 12e34a0cce..6211ca3b53 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", () => ({ @@ -475,7 +475,33 @@ function withWindowOrigin(origin: string, run: () => void) { } } +// Evaluate min-/max-width media queries against a simulated viewport width, +// so each test runs at an explicit real-browser width instead of inheriting +// the global test-setup mock (which answers false to every query). +function stubViewportWidth(width: number) { + Object.defineProperty(window, "matchMedia", { + writable: true, + configurable: true, + value: (query: string) => ({ + matches: (() => { + const min = query.match(/^\(min-width: ([\d.]+)px\)$/); + if (min) return width >= parseFloat(min[1]); + const max = query.match(/^\(max-width: ([\d.]+)px\)$/); + if (max) return width <= parseFloat(max[1]); + return false; + })(), + media: query, + addEventListener: () => {}, + removeEventListener: () => {}, + }), + }); +} + beforeEach(() => { + // Default to a mobile width: these suites were written against a sidebar + // that starts closed. Tests that need desktop semantics (hover peek) + // re-pin a desktop width themselves. + stubViewportWidth(375); useConvMock.mockReset(); useTerminalsMock.mockReset(); useTerminalsMock.mockReturnValue({ @@ -524,7 +550,10 @@ beforeEach(() => { }); }); -afterEach(cleanup); +afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); +}); describe("AppShell header", () => { it("renders the sidebar toggle on all pages", () => { @@ -1259,8 +1288,13 @@ describe("Workspace rail maximize", () => { // armed from INSIDE it. Armed from the title-bar trigger (outside), a pointer // that never crosses the card leaves it with no pointerenter and therefore no // pointerleave, so the card used to sit open indefinitely. + // Hover peek is a desktop affordance; at a desktop width the sidebar + // starts open, so collapse it first to expose the peek trigger. + stubViewportWidth(1280); mockConversations([{ id: "conv_abc", permission_level: null }]); renderShell("/c/conv_abc"); + fireEvent.keyDown(document, { code: "BracketLeft", metaKey: true, altKey: true }); + expect(screen.getByTestId("sidebar")).toHaveAttribute("data-open", "false"); fireEvent.pointerEnter(screen.getByRole("button", { name: /open sidebar/i })); await waitFor(() => expect(screen.getByTestId("sidebar")).toHaveAttribute("data-peek", "true")); @@ -1275,8 +1309,13 @@ describe("Workspace rail maximize", () => { it("keeps peeking while the pointer is over the card itself", async () => { // The other half: dismissal must not be so eager that moving onto the card — // the entire point of peeking — closes it. + // Same desktop setup as above: collapse the open-by-default sidebar to + // expose the peek trigger. + stubViewportWidth(1280); mockConversations([{ id: "conv_abc", permission_level: null }]); renderShell("/c/conv_abc"); + fireEvent.keyDown(document, { code: "BracketLeft", metaKey: true, altKey: true }); + expect(screen.getByTestId("sidebar")).toHaveAttribute("data-open", "false"); fireEvent.pointerEnter(screen.getByRole("button", { name: /open sidebar/i })); await waitFor(() => expect(screen.getByTestId("sidebar")).toHaveAttribute("data-peek", "true")); @@ -2076,6 +2115,48 @@ describe("FilesPanel visibility", () => { }); describe("Right workspace card visibility", () => { + it("aborts an active resize when the workspace panel closes", () => { + stubViewportWidth(1440); + vi.stubGlobal("innerWidth", 1440); + 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..0eafbbe7f7 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"; @@ -80,7 +81,8 @@ import { FileViewerContext } from "./FileViewerContext"; import { FilesPanelDrawer } from "./FilesPanelDrawer"; import type { ChangedSort } from "./FlatFileList"; import { MobilePanelDrawer } from "./MobilePanelDrawer"; -import { isMobileViewport, Sidebar } from "./Sidebar"; +import { isMobileViewport } from "@/lib/breakpoints"; +import { Sidebar } from "./Sidebar"; import { SidebarHeaderActions } from "./SidebarHeaderActions"; import { useSettingsRoute } from "./settingsNav"; import { SubagentsPanel } from "./SubagentsPanel"; @@ -210,12 +212,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 +1494,13 @@ export function AppShell() { !executionLogsOpen && !filesPanelOpen, ); + const { panelWidth: inlinePanelWidth, handleProps: inlinePanelHandleProps } = + useResizableInlinePanel( + conversationId ?? null, + inlinePanelMinWidth, + sidebarOpen ? sidebarWidth : 0, + workspacePanelVisible && !mobileViewport, + ); return ( @@ -1892,12 +1896,11 @@ export function AppShell() { } /** - * Initial sidebar open state — open on desktop, closed on mobile. SSR- - * safe (returns false when window is undefined). The threshold (`md`) - * matches Tailwind's default 768px, used in the Sidebar's responsive - * classes. + * Initial sidebar open state — open on desktop, closed on mobile, per the + * one canonical layout predicate ("mobile unless provably md+"). SSR-safe + * (returns false when window is undefined). */ function initialSidebarOpen(): boolean { if (typeof window === "undefined") return false; - return window.matchMedia("(min-width: 768px)").matches; + return !isMobileViewport(); } diff --git a/web/src/shell/CommentsPanel.test.tsx b/web/src/shell/CommentsPanel.test.tsx index 439ac00ec8..900a423156 100644 --- a/web/src/shell/CommentsPanel.test.tsx +++ b/web/src/shell/CommentsPanel.test.tsx @@ -414,10 +414,10 @@ describe("CommentsPanel resize affordance", () => { it("renders a resize handle and applies an inline width on desktop", () => { renderPanel([makeComment("c1")], []); - // The separator is the drag handle; its parent is the panel root, which + // The separator is the divider gutter preceding the panel root, which // gets an explicit pixel width (default 240px) so it can be dragged wider. const handle = screen.getByRole("separator", { name: "Resize comments panel" }); - expect((handle.parentElement as HTMLElement).style.width).toBe("240px"); + expect((handle.nextElementSibling as HTMLElement).style.width).toBe("240px"); }); it("omits the handle and inline width on a narrow (mobile) viewport", () => { @@ -511,3 +511,73 @@ describe("CommentsPanel active-comment reveal", () => { expect(screen.getByText("Comment c2")).toBeInTheDocument(); }); }); + +// ── Resize handle geometry ──────────────────────────────────────────────────── + +describe("CommentsPanel resize divider gutter", () => { + // jsdom has no layout engine, so these tests pin the structural invariants + // that produce the geometry in a real browser: the gutter must be a flex + // sibling BEFORE the panel root (i.e. between the viewer and the panel in + // FileViewer's row) and outside every scroll/clip container, with its hit + // overhang capped on both sides. + const getSeparator = () => screen.getByRole("separator", { name: "Resize comments panel" }); + const dragOverlayPresent = () => + [...document.body.children].some( + (c) => c instanceof HTMLElement && c.style.zIndex === "2147483647", + ); + + it("renders the gutter between the viewer and the panel, outside both scroll containers", () => { + const { container } = renderPanel([makeComment("c1")], []); + const separator = getSeparator(); + + // First child of the split row slot, immediately followed by the panel + // root — so in FileViewer's flex row it sits between viewer and panel. + expect(container.firstElementChild).toBe(separator); + const panelRoot = separator.nextElementSibling as HTMLElement; + expect(panelRoot).not.toBeNull(); + expect(panelRoot.contains(separator)).toBe(false); + + // The panel root clips its own content again; the gutter must sit outside + // every clipping/scrolling ancestor or its hit slivers would be cut off. + expect(panelRoot.className).toMatch(/overflow-hidden/); + expect(separator.closest(".overflow-hidden, .overflow-y-auto")).toBeNull(); + }); + + it("caps the hit overhang over the viewer and the panel content", () => { + renderPanel([makeComment("c1")], [makeComment("c2", "addressed")]); + const style = getSeparator().style; + + // Slivers: ≤10px over the viewer (a 14px scrollbar keeps its majority), + // ≤8px inward (within the panel's 12px content gutter). + expect(-parseFloat(style.marginLeft)).toBeLessThanOrEqual(10); + expect(-parseFloat(style.marginRight)).toBeLessThanOrEqual(8); + + // Pressing and tapping a tab must interact with the tab, not the handle: + // no drag overlay appears and the tab actually switches. + const addressedTab = screen.getByRole("button", { name: /addressed/i }); + fireEvent.pointerDown(addressedTab); + expect(dragOverlayPresent()).toBe(false); + fireEvent.click(addressedTab); + expect(screen.getByText("Comment c2")).toBeInTheDocument(); + }); + + it("keeps the painted strip slim inside the gutter's layout footprint", () => { + renderPanel([], []); + const separator = getSeparator(); + const style = separator.style; + + // 4px painted strip (w-1, content-box background); the layout footprint + // is the slim gutter itself — padding minus the cancelling margins. + expect(separator.className).toMatch(/\bw-1\b/); + expect(style.boxSizing).toBe("content-box"); + expect(style.backgroundClip).toBe("content-box"); + const footprint = + 4 + + parseFloat(style.paddingLeft) + + parseFloat(style.paddingRight) + + parseFloat(style.marginLeft) + + parseFloat(style.marginRight); + expect(footprint).toBeLessThanOrEqual(8); + expect(footprint).toBeGreaterThan(0); + }); +}); diff --git a/web/src/shell/CommentsPanel.tsx b/web/src/shell/CommentsPanel.tsx index 95edb63cb7..5b66b507be 100644 --- a/web/src/shell/CommentsPanel.tsx +++ b/web/src/shell/CommentsPanel.tsx @@ -147,130 +147,161 @@ export function CommentsPanel({ }, [activeSelection, tab]); return ( -
- {/* Resize handle — desktop only (mobile stacks the panel full-width below) */} + <> + {/* Divider gutter owning the resize interaction — desktop only (mobile + stacks the panel full-width below). A real flex child BETWEEN the + viewer and the panel, outside both scroll containers, so its + invisible hit slivers overhang each neighbor by only a few px + instead of sitting over the viewer's scrollbar or the panel's + header/tabs/cards. */} {isDesktop && (
)} - {/* Header — fixed height so layout doesn't shift when button is hidden */} -
- Comments - {tab === "open" && ( - - )} -
- - {/* Tabs */} -
- {TABS.map((t) => { - const count = t === "open" ? comments.length : addressedComments.length; - return ( - - ); - })} -
+ + Address All + + )} +
- {!canEdit && ( -
- You have read-only access to this session. + {/* Tabs */} +
+ {TABS.map((t) => { + const count = t === "open" ? comments.length : addressedComments.length; + return ( + + ); + })}
- )} -
- {/* Input section — shown when text is selected with no existing comment at same range and user can edit */} - {tab === "open" && - activeSelection != null && - !comments.some( - (c) => - c.start_index === activeSelection.start_index && - c.end_index === activeSelection.end_index, - ) && - (canEdit ? ( -
- {activeSelection.anchor_content && ( -
- Selection: - {displayAnchorContent(activeSelection.anchor_content).split("\n")[0]} -
- )} -