fix(web): scroll assistant to newest turn and settle the rail on release

Opening the panel left the user at the oldest message because the scroll
effect did not depend on `open`. Add it and defer the scroll to the next
frame so the popover has laid out before the sentinel is measured.

A click or an aborted swipe never arms the long press, so no drop handler
ran and a hover-expanded button stayed expanded after the pointer left.
Track real pointer presence and re-sync the rail on release.
This commit is contained in:
TyperBody
2026-09-25 13:32:29 +08:00
parent e6e1958fc5
commit 6283d21311
@@ -90,6 +90,7 @@ function AssistantPanel({ storageKey }: { storageKey: string }) {
// control restores the full button, and the two states never race because
// every transition is derived from the same `dockedEdge` snapshot.
const [railExpanded, setRailExpanded] = useState(false);
const pointerOver = useRef(false);
const containerRef = useRef<HTMLDivElement>(null);
const hoverLocked = useRef(false);
const dragState = useRef<{
@@ -234,12 +235,14 @@ function AssistantPanel({ storageKey }: { storageKey: string }) {
* reliable) and clears the lock; only then does hovering reveal the button.
*/
function handlePointerEnter() {
pointerOver.current = true;
if (hoverLocked.current) return;
if (shouldExpandRail({ dockedEdge, railExpanded, dragging }))
setRailExpanded(true);
}
function handlePointerLeave() {
pointerOver.current = false;
hoverLocked.current = false;
// Never collapse mid-drag; the drop handler owns the final state.
if (dragState.current || dragging) return;
@@ -247,6 +250,17 @@ function AssistantPanel({ storageKey }: { storageKey: string }) {
setRailExpanded(false);
}
/*
* A click or an aborted swipe never arms the long press, so no drop handler
* runs. Re-sync the rail from the real pointer position on release, otherwise
* a button expanded by hover would stay expanded with the cursor gone.
*/
function settleRailAfterRelease() {
if (pointerOver.current || dragState.current || dragging) return;
if (shouldCollapseRail({ dockedEdge, railExpanded, dragging }))
setRailExpanded(false);
}
function lockHoverUntilPointerExit() {
hoverLocked.current = true;
const releaseOnExit = (moveEvent: PointerEvent) => {
@@ -309,6 +323,9 @@ function AssistantPanel({ storageKey }: { storageKey: string }) {
window.addEventListener('pointerup', onWindowUp);
window.addEventListener('pointercancel', onWindowUp);
// Release outside a drag still needs to reconcile the rail.
window.addEventListener('pointerup', settleRailAfterRelease, { once: true });
longPressTimer.current = window.setTimeout(() => {
if (dragState.current !== state) return;
state.active = true;
@@ -371,9 +388,16 @@ function AssistantPanel({ storageKey }: { storageKey: string }) {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open, storageKey]);
// `open` is a dependency so a freshly opened panel jumps to the newest turn
// instead of leaving the user at the oldest message. The rAF waits for the
// popover to lay out before measuring the sentinel.
useEffect(() => {
end.current?.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
}, [conversation, busy, pendingText]);
if (!open) return;
const frame = window.requestAnimationFrame(() => {
end.current?.scrollIntoView({ behavior: 'auto', block: 'nearest' });
});
return () => window.cancelAnimationFrame(frame);
}, [open, conversation, busy, pendingText]);
async function submit(approved?: boolean) {
if (