polish(web-shell): address ytahdn review on mode picker

- ApprovalModeMessage: dismiss on touchstart too (tap-outside on touch
  devices) and skip when the press was already defaultPrevented.
- MessageList: re-check shouldFollow inside the rAF so a scroll-up during
  the frame gap doesn't get fought by the tail reveal.
- StatusBar: add aria-haspopup="listbox" so the trigger announces it opens
  a picker.
This commit is contained in:
Shaojin Wen 2026-06-09 11:01:02 +08:00
parent 743cc8a1ae
commit 1ef1144610
3 changed files with 24 additions and 12 deletions

View file

@ -429,7 +429,11 @@ export function MessageList({
!prevHasTailContent.current
) {
shouldFollow.current = true;
requestAnimationFrame(scrollToBottom);
// Re-check follow inside the frame: if the user scrolls up in the gap
// before it fires (Rule 2 clears the flag), don't fight them.
requestAnimationFrame(() => {
if (shouldFollow.current) scrollToBottom();
});
}
prevHasTailContent.current = hasTailContent;
}, [autoScrollTailIntoView, hasTailContent, scrollToBottom]);

View file

@ -54,6 +54,7 @@ export function StatusBar({ onSelectMode }: StatusBarProps) {
onClick={onSelectMode}
onMouseDown={(e) => e.stopPropagation()}
title={t('mode.select')}
aria-haspopup="listbox"
>
<span className={`${styles.modeLabel} ${modeIndicator.className}`}>
{modeIndicator.label}

View file

@ -59,24 +59,31 @@ export function ApprovalModeMessage({
return () => emitActive(false);
}, [emitActive]);
// Close when the user presses the mouse outside the panel. The panel is
// rendered inline (no modal backdrop), so we listen on the document. The
// click that opened the panel has already finished propagating by the time
// this effect runs, so it cannot self-close.
// Close when the user presses outside the panel. The panel is rendered
// inline (no modal backdrop), so we listen on the document. The press that
// opened the panel has already finished propagating by the time this effect
// runs, so it cannot self-close. We cover touch as well so a tap outside
// dismisses on touch devices, not only via Escape / a row click.
useEffect(() => {
const onMouseDownOutside = (event: MouseEvent) => {
// Only the primary (left) button dismisses. Middle-click on Linux/X11
// pastes, and right-click opens a context menu — neither should close
// the panel out from under the user.
if (event.button !== 0) return;
const onPointerOutside = (event: Event) => {
// Only the primary (left) mouse button dismisses. Middle-click on
// Linux/X11 pastes, and right-click opens a context menu — neither should
// close the panel out from under the user. (Touch events have no button.)
if (event instanceof MouseEvent && event.button !== 0) return;
// If another handler already consumed the press, leave the panel alone.
if (event.defaultPrevented) return;
const panel = panelRef.current;
const target = event.target;
if (panel && target instanceof Node && !panel.contains(target)) {
onCloseRef.current();
}
};
window.addEventListener('mousedown', onMouseDownOutside);
return () => window.removeEventListener('mousedown', onMouseDownOutside);
window.addEventListener('mousedown', onPointerOutside);
window.addEventListener('touchstart', onPointerOutside);
return () => {
window.removeEventListener('mousedown', onPointerOutside);
window.removeEventListener('touchstart', onPointerOutside);
};
}, []);
useEffect(() => {