feat(web): toast anchoring and model-selection rejection banner

The toast sits 120px from the viewport top and centers over its anchor —
the composer card, so it centers on the chat column rather than the window;
a rejected model selection (e.g. picking a text-only model while the
session holds images) announces through the same banner while the in-menu
strip with Retry stays the catalog-load surface. The attachment rail
consumes every wheel tick with a vertical component: a diagonal pan keeps
its horizontal intent and nothing scrolls the conversation behind the
composer.
This commit is contained in:
creatixchu
2026-08-11 18:00:56 +08:00
parent 87e3c95027
commit dc42e6b822
17 changed files with 158 additions and 42 deletions

View File

@@ -109,20 +109,25 @@ export function AttachmentRail<T extends AttachmentRailItem>({ items, labels, on
observer.observe(el)
disconnect = () => { observer.disconnect() }
}
// A vertical wheel pans the rail horizontally and is consumed: without
// preventDefault the same tick would also scroll the conversation behind
// the composer. React's root wheel listener is passive, so the exclusive
// conversion needs this manually attached non-passive listener. LINE and
// PAGE deltas (Firefox notch wheels) are normalized to pixels before the
// per-tick clamp that keeps a fast wheel followable.
// The rail scrolls horizontally ONLY: any wheel tick with a vertical
// component is consumed — without preventDefault it would also scroll the
// conversation behind the composer, and React's root wheel listener is
// passive, so the exclusion needs this manually attached non-passive
// listener. A diagonal trackpad pan keeps its horizontal intent; a pure
// vertical wheel converts to a horizontal step, with LINE and PAGE deltas
// (Firefox notch wheels) normalized to pixels before the per-tick clamp
// that keeps a fast wheel followable. A purely horizontal pan stays
// native.
const onWheel = (event: globalThis.WheelEvent): void => {
if (event.deltaX !== 0 || event.deltaY === 0) return
if (event.deltaY === 0) return
const scale = event.deltaMode === WheelEvent.DOM_DELTA_LINE
? WHEEL_LINE_PX
: event.deltaMode === WheelEvent.DOM_DELTA_PAGE ? el.clientWidth : 1
event.preventDefault()
el.scrollBy({
left: Math.sign(event.deltaY) * Math.min(Math.abs(event.deltaY) * scale, 60),
left: event.deltaX !== 0
? event.deltaX * scale
: Math.sign(event.deltaY) * Math.min(Math.abs(event.deltaY) * scale, 60),
behavior: 'auto',
})
}