Skip to content

feat(ui): make the agent panel draggable - #676

Open
chethanuk wants to merge 1 commit into
alibaba:mainfrom
chethanuk:feat/547-draggable-panel
Open

chethanuk wants to merge 1 commit into
alibaba:mainfrom
chethanuk:feat/547-draggable-panel

Conversation

@chethanuk

Copy link
Copy Markdown

What

The panel is fixed at bottom-centre and cannot be moved, so it sits on top of the page content the agent is working on. This makes the header a drag handle.

The offset is applied as a CSS custom property rather than by rewriting left/top. The wrapper's transform already carries the translateX(-50%) centring and the show/hide entrance animation, and a media query resizes it under 480px, so rewriting the positioning would fight all three. .wrapper gains translate(var(--drag-x, 0px), var(--drag-y, 0px)) and declares both to 0px, and dragging sets only those two properties. The declaration matters because custom properties inherit and the panel is injected into pages we do not control: left to the var() fallback, a host page using either name shifts the panel across the screen.

  • .header is the handle, on Pointer Events so mouse, touch and pen share one path. touch-action: none, cursor: grab/grabbing. Presses on the control buttons, on a non-primary button, or from a second contact are ignored.
  • A 4px threshold on true distance separates a click from a drag, so the header's expand/collapse toggle still works. The click that ends a drag is consumed by a flag cleared on the next press. A one-shot capture listener does not work here: a gesture ending in pointercancel is followed by no click at all, so the listener survives and eats the user's next genuine press.
  • Offsets keep an 8px margin inside the layout viewport — documentElement.clientWidth/Height, which excludes a classic scrollbar, capped by the window so quirks mode cannot report the whole document as the viewport — during a drag, on window resize, and on show(). A hidden panel measures zero, so a resize while it is hidden never re-clamps. Without the show() clamp, dragging right, closing, then narrowing the window leaves the panel off-screen with its only handle out of reach. That clamp waits for the entrance transition to settle: getBoundingClientRect() reports the box mid-animation, and a panel parked against the bottom edge would otherwise be walked upwards by every hide/show. A resize landing inside that window is deferred to the same scheduled clamp for the same reason — clamping a corrupted offset later is a no-op, so the error would stick.
  • dispose() tears down a gesture still in flight, whose move and end handlers live on window rather than on the panel. Only one gesture runs at a time: a mouse and a pen are both primary at once, so a second primary press is ignored rather than allowed to overwrite the teardown handle for the gesture already running.
  • show() and hide() write transform inline, which overrides the class rule, so both carry the same var() suffix. Without it, every show/hide resets the drag.
  • The clamping maths is a pure module so it can be tested without a layout engine.

The default position is unchanged.

Known limitation: clamping measures the wrapper, which is the collapsed header. The history list and input row are absolutely positioned siblings outside that box, so an expanded panel dragged to the top clips history off-screen, and one parked at the bottom clamp leaves only the top few pixels of the input row visible — 8px of 48px — so it cannot be typed into until it is dragged back up. Clamping their union instead would let a tall history exceed the viewport and lock the drag through the degenerate branch, which is worse, and reserving the input row's height would mean clamping against a box that changes with the panel's own state. The handle stays grabbable either way.

The 8px margin is the interactive one. .background overhangs the wrapper by 8px horizontally and carries a 16px blur, so the glow is clipped at the edge; fully containing it would need a 24px dead zone, which costs more than it buys.

Closes #547

Type

  • Breaking change
  • Bug fix
  • Feature / Improvement
  • Refactor / Chores
  • Documentation / Website / Demo / Testing

Testing

  • npm run ci passes
  • Tested in modern browsers
  • Types/doc added

packages/ui had no test setup. This adds vitest.config.js (happy-dom) and a test script, matching the config style in page-controller.

65 tests. A table over the clamping maths covers all four edges, both axes at once, exact boundaries, a panel larger than the viewport, a zero rect, and margins of 0, 8 and 100. A table over the gesture in happy-dom covers the threshold at 0, 3, 2+3, 3+3 and 4px, drag versus click, pointercancel, a second contact, the control buttons, presses that race a drag's trailing click, show/hide preserving the offset, resize and entrance re-clamp, a resize that lands mid-entrance, a scrollbar-inclusive viewport, a quirks-mode viewport larger than the window, degenerate boxes, a second pointer lifting mid-drag, and gesture teardown on dispose for both a single gesture and an overlapping second primary pointer.

Every production line the tests cover was mutation-checked: reverting it makes a named test fail. That caught two tests that had been passing for the wrong reason, one measuring from a base the viewport had moved and one unable to tell Math.hypot from a Manhattan distance.

Verified in Chrome against a built demo bundle driven by real mouse input: the drag tracks the cursor 1:1, clamps to 8px at every edge, a crisp click and a 3px wobble both toggle while a deliberate drag does not, grabbing and the suppressed transition apply mid-drag only, narrowing the window walks the panel back inward to the pixel the unit test predicts, dragging to the bottom edge then hiding and showing leaves the offset untouched, dispose() mid-drag detaches all three window listeners, and the default position matches main exactly at x=460, y=580.

Requirements / 要求

  • I have read and follow the Code of Conduct and Contributing Guide . / 我已阅读并遵守行为准则。
  • This PR is NOT generated by a bot or AI agent acting autonomously. I have authored or meaningfully reviewed every change. / 此 PR 不是由 bot 或 AI 自主生成的,我已亲自编写或充分审查了每一处变更。

The panel is fixed at bottom-centre and cannot be moved, so it sits on top of
the page content the agent is working on. This makes the header a drag handle.

The offset is applied as a CSS custom property rather than by rewriting
`left`/`top`. The wrapper's `transform` already carries the `translateX(-50%)`
centring and the show/hide entrance animation, and a media query resizes it
under 480px, so rewriting the positioning would fight all three. `.wrapper`
gains `translate(var(--drag-x, 0px), var(--drag-y, 0px))`, and dragging sets
only those two properties.

- `.header` is the handle, on Pointer Events so mouse, touch and pen share one
  path. `touch-action: none`, `cursor: grab`/`grabbing`. Presses on the control
  buttons, on a non-primary button, or from a second contact are ignored.
- A 4px threshold on true distance separates a click from a drag, so the
  header's expand/collapse toggle still works. The click that ends a drag is
  consumed by a flag cleared on the next press that could produce one. A
  one-shot capture listener does not work here: a gesture ending in
  `pointercancel` is followed by no click at all, so the listener survives and
  eats the user's next genuine press.
- Offsets keep an 8px margin inside the layout viewport during a drag, on
  window resize, and on `show()`. A hidden panel measures zero, so a resize
  while it is hidden never re-clamps. Without the `show()` clamp, dragging
  right, closing, then narrowing the window leaves the panel off-screen with
  its only handle out of reach. That clamp waits for the entrance transition to
  settle, since `getBoundingClientRect()` reports the box mid-animation and a
  panel parked against the bottom edge would otherwise walk upwards on every
  hide/show.
- `dispose()` tears down a gesture still in flight, whose move and end handlers
  live on `window` rather than on the panel.
- `show()` and `hide()` write `transform` inline, which overrides the class
  rule, so both carry the same `var()` suffix. Without it, every show/hide
  resets the drag.
- The clamping maths is a pure module so it can be tested without a layout
  engine.

The default position is unchanged.

Known limitation: clamping measures the wrapper, which is the collapsed header.
The history list and input row are absolutely positioned siblings outside that
box, so an expanded panel dragged to the top clips history off-screen. Clamping
their union instead would let a tall history exceed the viewport and lock the
drag through the degenerate branch, which is worse. The handle stays grabbable
either way.

Closes alibaba#547
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.

[Feature] 对话框模块支持拖拽

1 participant