Skip to content

fix(ui): unify resize handle cursor, lock and indicator (MUL-4911)#5570

Open
NevilleQingNY wants to merge 4 commits into
mainfrom
agent/walt/4585c07c
Open

fix(ui): unify resize handle cursor, lock and indicator (MUL-4911)#5570
NevilleQingNY wants to merge 4 commits into
mainfrom
agent/walt/4585c07c

Conversation

@NevilleQingNY

Copy link
Copy Markdown
Collaborator

MUL-4911 — 统一 resize 交互的 cursor / 光标锁 / 指示线

四套 resize 实现各自重写了同样的三个契约并已漂移。本 PR 把其中最健壮的一版(sidebar rail)提取成共享原语,让手写的把手接入。

根因

react-resizable-panels 按 UA 选光标:Chrome/Firefox 走 ew-resize,其它浏览器才退到 col-resize。Multica 的运行环境(Electron = Chromium、Web = Chrome/FF)一律命中 ew-resize;但 chat 和 DataTable 硬编码的恰好是只有 Safari 才会用到的 col-resize 分支。同时它们的拖拽期只设 document.body.style.cursor(无 !important),压不过后代自己的 cursor-pointer

改动

  • packages/ui/hooks/use-resize-gesture.ts(新)— 拥有 pointer capture、全局光标锁、以及单一 teardown(pointerup / pointercancel / lostpointercapture / window blur / disabled 中途 / unmount / 回调抛错)。不持有任何 React state,因此 sidebar 直写 gap/container width 的性能路径完全保留。
  • packages/ui/components/ui/resize-handle.tsx(新)— 光标 token 的唯一收口:x/y/xyew-resize/ns-resize/nwse-resize。调用方从此写不出 col-resize
  • packages/ui/styles/base.css — 把 sidebar 专属的光标锁泛化成 data-resize-axis,保留 * + !important 契约。
  • Chat — 补上 hover/active 指示线(此前零反馈)、命中区 4px → 8px、以及它从来没有的 cancel 路径;角把手 nw-resizenwse-resize(角可双向缩放,单向箭头语义就是错的)。
  • rrp — 仍由库自己管光标,只对齐 active 指示线 token(foreground/15foreground/25)。

不含 DataTable

唯一消费方在 PR #5454(MUL-4797)中,且该 PR 重写同一文件(+120/-56)。拿掉后本 PR 与 #5454 零文件重叠。等其合并后再对齐。

验证

pnpm typecheck / pnpm lint(0 error)/ pnpm test(220 文件 2236 用例)全绿;新增 28 个用例。

真实 Chromium 端到端实测(本地栈 + Playwright,一次性脚本已删除):

检查 结果
三个把手计算后的 cursor ew-resize / ns-resize / nwse-resize
拖拽中划过 chat 内 <button> 的 cursor ew-resize(此前会跳回 pointer —— 本 issue 的原始 bug)
拖拽中 <html data-resize-axis> "x";松手后 null(无泄漏)
指示线 ::after 静置 / hover / 拖拽 transparent.../0.15.../0.25
8px 命中区是否遮挡 header 控件 4 个按钮全部 blockedByHandle: false

sidebar-resize.test.tsxcursor-ew-resizestableConsumerRender 断言未改动且通过 —— 即 sidebar 重构行为等价、且拖拽仍未进入 React state。

待确认(视觉)

chat 的指示线按定案规格(1px foreground/15)实现后,实测观感几乎不可见 —— 它正好落在窗口已有的 ring-1 ring-surface-border 上。机制确认生效(计算样式如上表),但视觉增益接近零。是否接受 / 内缩 / 加重,见 issue 内说明。

🤖 Generated with Claude Code

The four resize surfaces each re-implemented the same three contracts and
had drifted apart. Extract the strongest existing implementation (the
sidebar rail) into a shared primitive and route the hand-written handles
through it.

- useResizeGesture owns pointer capture, the global cursor lock and a
  single teardown path covering pointerup, pointercancel,
  lostpointercapture, window blur, disabled-mid-drag, unmount and a
  throwing callback. It holds no React state, so the sidebar's direct
  gap/container width writes stay off the React commit path.
- resizeHandleVariants is the only place a resize cursor is chosen:
  x/y/xy map to ew-resize/ns-resize/nwse-resize. Chat and DataTable had
  hardcoded col-resize/row-resize/nw-resize, which is the branch
  react-resizable-panels only takes on Safari.
- base.css generalises the sidebar-only cursor lock to data-resize-axis,
  keeping the `*` + !important contract that beats descendants declaring
  their own cursor. Chat previously set document.body.style.cursor, so
  the cursor jumped back to a pointer when the drag crossed a button.
- Chat gains a hover/active indicator and an 8px hit area (was 4px, and
  no hover feedback at all), plus the cancel paths it never had.
- react-resizable-panels keeps owning its own cursor; only its indicator
  token is aligned (active foreground/15 -> foreground/25).

DataTable is deliberately left out: its only consumer is in flight in
PR #5454, which rewrites the same file. It gets aligned once that lands.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
@vercel

vercel Bot commented Jul 17, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
multica-docs Ready Ready Preview, Comment Jul 17, 2026 9:04am

Request Review

The four rrp panel dividers had their hover/active feedback swallowed: each
call site painted its own border-r/border-l at the boundary and the
separator drew a 1px indicator on top of it, so hover only re-tinted a line
that was already there and read as no change.

Hand the divider line to the separator, which is the element that owns the
interaction:

- ResizableHandle renders the resting rule itself (before:bg-border), so
  resting looks exactly as before, hover darkens to foreground/15 and a
  live drag to foreground/25.
- inbox, chat page and the shared right sidebar drop their edge borders.
- issue/project pass before:bg-transparent while the right sidebar is
  collapsed: no panel on the far side to divide from, and hover still
  reveals the grab hint.

Verified in Chromium against the running app: the inbox divider now goes
oklch(0.945) at rest -> foreground/15 on hover -> foreground/25 while
dragging, with no neighbouring element painting a border (0px on both
panels). react-resizable-panels keeps owning its cursor: a button inside a
panel still computes ew-resize mid-drag.

Covers the four panel-width surfaces left over from the first pass; with
the sidebar rail, all five are now consistent.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
The first pass extracted a primitive for the hand-written handles but left
resizable.tsx hardcoding its own copy of the same tokens — it never imported
resizeHandleVariants. The two matched only because the values were typed
twice, which is the drift this issue exists to remove.

- resizable.tsx now consumes resizeHandleVariants. `foreground/15`,
  `foreground/25` and the 8px grab zone each appear exactly once in the repo.
- New variants carry the two real differences instead of a second copy:
  `cursor: "none"` for handles whose library already drives the cursor
  document-wide, and `indicator: "rule"` for a permanent panel divider vs
  `"line"` for a hover-only hint. The active state names both
  `data-resizing` and `data-separator=active`; only ever one matches, so one
  declaration serves both kinds of handle.
- Grab zone is 8px everywhere: the sidebar rail drops 16px -> 8px. Its
  offsets are half the width, so -right-4 -> -right-2 and the offcanvas
  -right-2/-left-2 -> -right-1/-left-1 move with it; changing the width
  alone would park the rail beside the sidebar edge instead of on it.

Measured in Chromium, not inferred: the rail is 8px spanning -4..+4 around
the sidebar edge with cursor ew-resize, and the panel divider offers the
same 8px grab zone over a 1px oklch(0.945) rule (= --border, unchanged at
rest) while leaving the cursor to the library.

Only the orientation flip stays local to resizable.tsx: the library resolves
it at runtime from aria-orientation, where the variants take a compile-time
axis.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
…-4911)

Howard's blocker: 60f4fd3 moved the resting rule from ::before to ::after,
but issue-detail and project-detail kept hiding the collapsed-sidebar rule
with `before:bg-transparent`. They were then making the (already invisible)
hit area transparent while the rule stayed painted, so a collapsed right
sidebar still showed a divider.

The literal fix is to swap those two strings to `after:`, but that keeps the
coupling that caused this: a caller naming the component's pseudo-element
cannot be right for long. ResizableHandle takes `rule` instead, so which
pseudo draws the line stays private.

- resizable.tsx: `rule` (default true) selects the rule/line indicator.
- issue-detail / project-detail: `rule={desktopSidebarVisualOpen}`; no
  caller names a pseudo-element any more.
- The regression test asserts no resting background on any pseudo rather
  than on `after:` specifically — asserting the pseudo by name is exactly
  what let this through.

Verified in Chromium on issue detail: open -> 320px sidebar with an
oklch(0.945) rule; collapsed -> 0px sidebar and transparent on both
pseudo-elements. Re-running that check against the pre-fix behaviour
reproduces the bug (0px sidebar, rule still painted), so it is not vacuous.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant