diff --git a/.changeset/scroll-into-view-within.md b/.changeset/scroll-into-view-within.md new file mode 100644 index 00000000..4bc61e40 --- /dev/null +++ b/.changeset/scroll-into-view-within.md @@ -0,0 +1,6 @@ +--- +"@intentface/ui": patch +--- + +- [Composer] Fix arrowing through the command list scrolling the page in Safari and Firefox. +- [AskUser] Fix options scrolled into view ignoring their `scroll-margin` and the list's `scroll-padding`. diff --git a/.changeset/thread-scroll-timing.md b/.changeset/thread-scroll-timing.md new file mode 100644 index 00000000..d774610b --- /dev/null +++ b/.changeset/thread-scroll-timing.md @@ -0,0 +1,10 @@ +--- +"@intentface/ui": minor +--- + +- [Thread] Add `--thread-scroll` (with `-duration` / `-easing` longhands) to time every smooth scroll from CSS; any easing works, `linear()` included. +- [Thread] Add `--thread-scroll-speed` to pace smooth scrolls by distance, capped by the duration. +- [Thread] Add `data-scrolling` on `Thread.Root` while a timed scroll runs, naming its kind. +- [Thread] Fix `scrollToMessage` scrolling the page around an embedded thread. +- [Thread] Fix `isAtBottom` staying true after `scrollToTop` or `scrollToMessage` leaves the bottom. +- [Thread] Fix scrolls animating under `prefers-reduced-motion` when the scroller sets `scroll-behavior: smooth`. diff --git a/components/ai/thread.tsx b/components/ai/thread.tsx index e9da5443..e6d74feb 100644 --- a/components/ai/thread.tsx +++ b/components/ai/thread.tsx @@ -23,12 +23,31 @@ export type { ThreadAutoScrollMode, ThreadVisibilityState }; // Root // --------------------------------------------------------------------------- -export type ThreadRootProps = ComponentProps; +export type ThreadRootProps = ComponentProps & { + /** + * Animate the follow with the thread's scroll timing instead of pinning it. + * Off by default: see SCROLL_TIMING. + */ + animateFollow?: boolean; +}; -const ThreadRoot = ({ className, ...props }: ThreadRootProps) => ( +// Scroll timing: every smooth scroll eases out over 400ms, except the follow, +// which pins instantly. A reply grows a line at a time, so pinning reads as +// smooth already, and it leaves no animation on the main thread while it is +// busiest rendering the stream. The follow rule is !important so a timing set +// inline (`style`) can't animate it by accident; `animateFollow` lifts it, and +// `--thread-scroll: none` opts the whole thread back into the native scroll. +const SCROLL_TIMING = [ + "[--thread-scroll:400ms_cubic-bezier(0.2,0.8,0.2,1)]", + "not-data-animate-follow:data-[scrolling=follow]:[--thread-scroll-duration:0ms]!", +]; + +const ThreadRoot = ({ animateFollow = false, className, ...props }: ThreadRootProps) => ( ; const ThreadViewport = ({ children, className, ...props }: ThreadViewportProps) => ( - + // scroll-padding keeps the overlays' insets clear wherever something is + // scrolled into view: scrollToMessage, keyboard paging, focus moves. A row + // jumped to lands below the top fade and above the composer, not under them. +
{ ); }; +// The playground's scroll-timing settings as the thread's CSS variables. +// "default" sets none, keeping the styled Thread's timing; "native" opts back +// into the browser's smooth scroll. A spring prints its own +// ` linear(…)` pair, and that duration stays its cap under a speed; +// the other easings are capped by the slider's duration. +const toScrollTimingStyle = ( + option: ScrollTimingOption, + duration: number, + speed: number, +): CSSProperties | undefined => { + if (option === "default") return undefined; + if (option === "native") return { "--thread-scroll": "none" } as CSSProperties; + const timing = + option === "spring" ? String(spring(duration / 1000, 0)) : `${duration}ms ${option}`; + return { + "--thread-scroll": timing, + ...(speed > 0 && { "--thread-scroll-speed": String(speed) }), + } as CSSProperties; +}; + // memo: ChatRoot re-renders on every stream chunk and re-creates this element; // with no props the memo always bails, so the Thread chrome (header, overlays, // composer dock) renders only when the session context actually changes. @@ -1082,6 +1103,14 @@ const ChatDefaultLayout = memo(() => { const scrollMode = useSettingsStore((s) => s.scrollMode); const showScrollButton = useSettingsStore((s) => s.showScrollButton); const showOverlays = useSettingsStore((s) => s.showOverlays); + const scrollTiming = useSettingsStore((s) => s.scrollTiming); + const scrollDuration = useSettingsStore((s) => s.scrollDuration); + const scrollSpeed = useSettingsStore((s) => s.scrollSpeed); + const animateFollow = useSettingsStore((s) => s.animateFollow); + const scrollTimingStyle = useMemo( + () => toScrollTimingStyle(scrollTiming, scrollDuration, scrollSpeed), + [scrollTiming, scrollDuration, scrollSpeed], + ); return ( <> @@ -1090,7 +1119,12 @@ const ChatDefaultLayout = memo(() => { following, "follow" lands at the top and follows, "off" disables it. */} {/* A row, so the settings card pushes the thread over instead of covering it. */}
- +
{showOverlays && } diff --git a/components/playground-settings.tsx b/components/playground-settings.tsx index 436f6830..37fd13f1 100644 --- a/components/playground-settings.tsx +++ b/components/playground-settings.tsx @@ -37,7 +37,11 @@ import { } from "@/lib/interface-theme"; import { multipleQuestions, singleQuestion } from "@/lib/playground-demo"; import { usePlaygroundStore } from "@/lib/store/playground"; -import { type CommandSurface, useSettingsStore } from "@/lib/store/settings"; +import { + type CommandSurface, + type ScrollTimingOption, + useSettingsStore, +} from "@/lib/store/settings"; import { cn } from "@/lib/utils"; // Right-aligned compact selects share one footprint across the rows. @@ -69,6 +73,42 @@ const SettingsSection = ({ children }: { children: ReactNode }) => (
{children}
); +// A single-thumb slider sized for a row's control slot. +const CompactSlider = ({ + label, + value, + onValueChange, + min, + max, + step, +}: { + label: string; + value: number; + onValueChange: (value: number) => void; + min: number; + max: number; + step: number; +}) => ( + { + if (typeof next === "number") onValueChange(next); + }} + min={min} + max={max} + step={step} + > + + + + + + + +); + // Inline boolean row. The Settings.Label span carries no id, so the switch // names itself. A command prefix character renders as a keycap badge before // the label ("[@] mentions"). @@ -205,24 +245,14 @@ const ThemeTab = () => { - { - if (typeof value === "number") setSeed("con", value); - }} + onValueChange={(value) => setSeed("con", value)} min={CONTRAST_MIN} max={CONTRAST_MAX} step={CONTRAST_STEP} - > - - - - - - - + />
); @@ -239,6 +269,24 @@ const SCROLL_MODE_OPTIONS: ReadonlyArray<{ value: ThreadAutoScrollMode; label: s { value: "off", label: "Off" }, ]; +const SCROLL_TIMING_OPTIONS: ReadonlyArray<{ value: ScrollTimingOption; label: string }> = [ + { value: "default", label: "Default" }, + { value: "native", label: "Native" }, + { value: "ease-out", label: "Ease out" }, + { value: "ease-in-out", label: "Ease in-out" }, + { value: "linear", label: "Linear" }, + { value: "spring", label: "Spring" }, +]; + +// Up to 5s, so a landing still running when the reply starts streaming is +// easy to watch. +const SCROLL_DURATION_MIN = 100; +const SCROLL_DURATION_MAX = 5000; +const SCROLL_DURATION_STEP = 50; +// 0 is off: the duration stays fixed. +const SCROLL_SPEED_MAX = 5000; +const SCROLL_SPEED_STEP = 250; + const ThreadTab = () => { const scrollMode = useSettingsStore((state) => state.scrollMode); const setScrollMode = useSettingsStore((state) => state.setScrollMode); @@ -246,6 +294,16 @@ const ThreadTab = () => { const setShowScrollButton = useSettingsStore((state) => state.setShowScrollButton); const showOverlays = useSettingsStore((state) => state.showOverlays); const setShowOverlays = useSettingsStore((state) => state.setShowOverlays); + const scrollTiming = useSettingsStore((state) => state.scrollTiming); + const setScrollTiming = useSettingsStore((state) => state.setScrollTiming); + // Default and Native bring their own timing; the sliders shape the rest. + const customTiming = scrollTiming !== "default" && scrollTiming !== "native"; + const scrollDuration = useSettingsStore((state) => state.scrollDuration); + const setScrollDuration = useSettingsStore((state) => state.setScrollDuration); + const scrollSpeed = useSettingsStore((state) => state.scrollSpeed); + const setScrollSpeed = useSettingsStore((state) => state.setScrollSpeed); + const animateFollow = useSettingsStore((state) => state.animateFollow); + const setAnimateFollow = useSettingsStore((state) => state.setAnimateFollow); return (
@@ -270,6 +328,72 @@ const ThreadTab = () => { + + + + {customTiming && ( + <> + 0 + ? `At most ${scrollDuration}ms` + : `${scrollDuration}ms` + } + > + + + 0 ? `${scrollSpeed}px/s` : "Off: fixed duration"} + > + + + + )} + {/* Native has no scripted follow to switch on. */} + {scrollTiming !== "native" && ( + + )} } file="primitives/thread/demos/basic.tsx" /> @@ -133,8 +134,77 @@ long transcript adds no per-row setup, only the lookup when you jump. `useThreadVisibility` reads the same attribute to report which rows are on screen, and creates its observers only once something subscribes. +Only the thread's own viewport scrolls, so a thread embedded in a scrolling page +never moves the page. Where the row lands follows the same CSS as the browser's +own scroll-into-view: `scroll-padding` on `Thread.Viewport` keeps room at its +edges (lengths or percentages of its height), and `scroll-margin` on a row adds +room around that row. To land rows clear of the overlays, pad the viewport by +their heights: + +```tsx + +``` + } file="primitives/thread/demos/jump.tsx" /> +### Timing the scroll + +Smooth scrolls are the browser's own until you give them a timing. Set one on +the root and the thread animates every smooth scroll itself — the landing, the +follow, `scrollToBottom`, `scrollToTop` and `scrollToMessage`: + +```css +[data-thread-root] { + --thread-scroll: 450ms cubic-bezier(0.2, 0.8, 0.2, 1); +} +``` + +The value reads like `transition`: a duration, then any CSS easing, `linear()` +included. That makes a Motion spring a one-liner, since `spring()` prints +exactly that pair: + +```tsx +import { spring } from "motion"; + +{/* … */} +``` + +Keep `bounce` at 0: the scroller can't move past its end, so an overshoot at +the bottom is clipped into a hard stop. + +While an animation runs, the root carries `data-scrolling`, naming why it +started: `land`, `follow`, `bottom`, `top` or `message`. It is set before the +timing is read, so a selector can time each kind on its own — a slow landing +and a brisk follow: + +```css +[data-thread-root] { --thread-scroll: 200ms ease-out; } +[data-thread-root][data-scrolling="land"] { --thread-scroll-duration: 600ms; } +``` + +To pace by distance instead, set `--thread-scroll-speed` in pixels per second; +a duration set alongside it becomes the cap. `--thread-scroll: none` opts a +thread back into the native smooth scroll, over any longhand — useful when a +shared stylesheet sets a timing for every thread. + +**Pin the follow instead of animating it.** A reply grows a line at a time, so +pinning reads as smooth already. Animating the follow keeps a frame loop +running for the whole stream — on the main thread, while it is busiest — and +the easing trails the newest text below the fold: + +```css +[data-thread-root][data-scrolling="follow"] { --thread-scroll-duration: 0ms; } +``` + +The destination is re-read every frame, so a reply that starts streaming before +the landing finishes is carried along by it instead of restarting it. In +`"jump"` the landing's destination is fixed when it starts: a reply outgrowing +the reserve mid-flight never turns the landing into a follow. Any wheel, touch, +scroll key or scrollbar press stops the animation where it is, and under +`prefers-reduced-motion` every scroll is instant. + +} file="primitives/thread/demos/scroll-timing.tsx" /> + ## Why the scroll subsystem is cheap Thread's scroll subsystem is built to stay out of the way while a reply streams: @@ -145,6 +215,11 @@ the work it does is event-driven and small, not per token or per frame. - **Landing and follow are event-driven** — a MutationObserver for new turns, a ResizeObserver for growth, one `scrollTo` per change. No per-token geometry reads, no animation-frame polling. +- **A timed scroll costs one frame loop, only while it runs.** With + `--thread-scroll` set, each smooth scroll reads its timing once and then does + one `scrollTo` per frame until it ends. Unlike the native smooth scroll, it + runs on the main thread, so a frame lost to heavy rendering is a frame the + scroll skips. Leave the timing unset to keep the native scroll. - **Edge state lives in external stores** (one per edge), so a flip re-renders only the components that read it (your scroll button) — never the Thread tree. - **Lazy capabilities cost nothing until used**: visibility tracking creates its @@ -190,6 +265,7 @@ export const rootAttrs = [ { attribute: "data-thread-root", description: "The root element." }, { attribute: "data-at-top", description: "Present while the top edge is in view (start of the transcript) — the CSS-only mirror of useThread().isAtTop." }, { attribute: "data-at-bottom", description: "Present while the bottom edge is in view (at the live end) — the CSS-only mirror of useThread().isAtBottom." }, + { attribute: "data-scrolling", values: '"land" | "follow" | "bottom" | "top" | "message"', description: "Present while a timed scroll animates, naming why it started. Set before the timing is read, so selectors can time each kind. Never set for native or instant scrolls." }, ]; @@ -267,6 +343,10 @@ export const cssVars = [ { attribute: "--thread-width", values: "672px", description: "Max width of the content column and overlays." }, { attribute: "--thread-overlay-top-height", values: "4rem", description: "Top overlay height and top inset." }, { attribute: "--thread-overlay-bottom-height", values: "8rem", description: "Bottom overlay height and bottom inset (measured from the Thread.Composer slot at runtime)." }, + { attribute: "--thread-scroll", values: " | none", description: "Times every smooth scroll; the two parts go in either order, like transition. Unset or none keeps the browser's native smooth scroll (none wins over the longhands); 0ms makes them instant. Takes a Motion spring() string as-is." }, + { attribute: "--thread-scroll-duration", values: "", description: "Longhand; overrides the shorthand. The cap when a speed is set." }, + { attribute: "--thread-scroll-easing", values: "", description: "Longhand; overrides the shorthand. Defaults to ease." }, + { attribute: "--thread-scroll-speed", values: "", description: "Pixels per second; the duration follows the distance." }, ]; diff --git a/content/docs/primitives/thread/demos/autoscroll.tsx b/content/docs/primitives/thread/demos/autoscroll.tsx index 005d553c..f791f2d8 100644 --- a/content/docs/primitives/thread/demos/autoscroll.tsx +++ b/content/docs/primitives/thread/demos/autoscroll.tsx @@ -1,12 +1,7 @@ "use client"; -import { Composer, type ComposerSubmitData } from "@intentface/ui/composer"; -import { Message } from "@intentface/ui/message"; -import { Thread } from "@intentface/ui/thread"; -import { ArrowUp } from "@keyline-icons/react"; -import { useEffect, useRef, useState } from "react"; - -type Exchange = { id: string; question: string; answer: string }; +import { useState } from "react"; +import { type Exchange, ModeSwitch, StreamedChat, useStreamedChat } from "./streamed-chat"; const SEED: Exchange[] = [ { @@ -33,8 +28,6 @@ const CAPTIONS: Record = { off: "A plain scroll area — no landing, no follow, no reserve.", }; -const PROMPT = "Is memoising the list enough?"; - const REPLY = "Memoisation compares props, so the comparison itself has to be cheaper than the render you are avoiding. That is usually true for a list and rarely true for a single row."; @@ -47,158 +40,26 @@ const REPLY = * `jump`, notice the space reserved beneath the newest turn: it is what lets the * turn land at the top. * - * Each question and its reply share a Message.Turn, so the reserve the primitive - * publishes lands on the whole turn (it goes on the last child). The docked - * composer is measured, so the reserve fills exactly the visible area. - * * Remounting on a mode change is this demo's doing, not a requirement: the key * forces a fresh scroll subsystem so each mode is observed from its own * opening position rather than wherever the last one left the scroller. */ export const AutoScroll = () => { const [mode, setMode] = useState("follow"); - const [exchanges, setExchanges] = useState(SEED); - const [draft, setDraft] = useState(PROMPT); - const [streaming, setStreaming] = useState(false); - const timers = useRef[]>([]); - - useEffect(() => () => timers.current.forEach(clearTimeout), []); - - const handleSubmit = (data: ComposerSubmitData) => { - if (data.kind !== "message" || !data.text.trim() || streaming) return; - timers.current.forEach(clearTimeout); - timers.current = []; - - const id = `${Date.now()}`; - setExchanges((current) => [...current, { id, question: data.text, answer: "" }]); - setStreaming(true); - - // Word by word, so following is something you can actually watch. - const words = REPLY.split(" "); - words.forEach((_, index) => { - timers.current.push( - setTimeout(() => { - setExchanges((current) => - current.map((exchange) => - exchange.id === id - ? { ...exchange, answer: words.slice(0, index + 1).join(" ") } - : exchange, - ), - ); - }, index * 60), - ); - }); - // Refill the composer once the reply is done, so the next send is one click. - timers.current.push( - setTimeout(() => { - setStreaming(false); - setDraft(PROMPT); - }, words.length * 60), - ); - }; + const chat = useStreamedChat({ seed: SEED, reply: REPLY }); return (
-
- {MODES.map((candidate) => ( - - ))} -
- -
- - {/* Measured by the primitive: a landed turn sits this far below the top. */} - - -
- {/* The reserve the primitive publishes lands on the last child: the newest turn. */} - - {exchanges.map((exchange, index) => ( - - {/* biome-ignore lint/a11y/useValidAriaRole: `role` is the message's author, not an ARIA role */} - - - {exchange.question} - - - {exchange.answer && ( - // biome-ignore lint/a11y/useValidAriaRole: `role` is the message's author, not an ARIA role - - - {exchange.answer} - - - )} - - ))} - -
-
- -
- - - - - - - - - - - - -
-
-
-
- + { + setMode(next); + chat.reset(); + }} + /> +

{CAPTIONS[mode]}

); }; - -// The raised surface of the selected mode. -const RAISED = - "bg-white bg-linear-to-b from-white to-[#fdfdfd] shadow-[inset_0_1px_0_#fff,0_0_0_1px_rgb(0_0_0/0.075),0_1px_2px_rgb(0_0_0/0.07),0_2px_6px_-2px_rgb(0_0_0/0.05)] dark:bg-[#2d2d30] dark:from-[#313134] dark:to-[#2a2a2d] dark:shadow-[inset_0_1px_0_rgb(255_255_255/0.1),inset_0_0_0_1px_rgb(255_255_255/0.05),0_0_0_1px_rgb(0_0_0/0.16),0_1px_2px_rgb(0_0_0/0.1)]"; diff --git a/content/docs/primitives/thread/demos/jump.tsx b/content/docs/primitives/thread/demos/jump.tsx index 00345398..9a9f4906 100644 --- a/content/docs/primitives/thread/demos/jump.tsx +++ b/content/docs/primitives/thread/demos/jump.tsx @@ -56,7 +56,8 @@ const QUESTIONS = TURNS.filter((turn) => turn.role === "user"); export const Jump = () => (
- + {/* scroll-padding: a question jumped to lands 24px below the top, not flush against it. */} + {/* Right padding keeps the text clear of the ladder. */} {TURNS.map((turn) => ( diff --git a/content/docs/primitives/thread/demos/scroll-timing.tsx b/content/docs/primitives/thread/demos/scroll-timing.tsx new file mode 100644 index 00000000..f11a5310 --- /dev/null +++ b/content/docs/primitives/thread/demos/scroll-timing.tsx @@ -0,0 +1,84 @@ +"use client"; + +import { spring } from "motion"; +import { type CSSProperties, useState } from "react"; +import { type Exchange, ModeSwitch, StreamedChat, useStreamedChat } from "./streamed-chat"; + +const SEED: Exchange[] = [ + { + id: "1", + question: "Why does my list re-render on every keystroke?", + answer: + "Because the parent holding the input state re-renders, and every child re-renders with it unless something stops the cascade.", + }, +]; + +// Each preset is plain CSS on the root. Static timings are Tailwind classes; +// the spring is computed by Motion, so it goes through `style`. +const PRESETS = { + native: { className: "", style: {}, caption: "No timing set: the browser's own smooth scroll." }, + snappy: { + className: "[--thread-scroll:250ms_cubic-bezier(0.2,0.8,0.2,1)]", + style: {}, + caption: "A short ease-out for every scroll.", + }, + spring: { + className: "", + // bounce 0: an overshoot past the bottom is clamped by the scroller. + style: { "--thread-scroll": String(spring(0.6, 0)) }, + caption: "Motion's spring(), as a linear() easing.", + }, + paced: { + className: "[--thread-scroll-speed:1200] [--thread-scroll-duration:700ms]", + style: {}, + caption: "1200px/s, capped at 700ms: short scrolls are quick.", + }, + slow: { + className: + "[--thread-scroll:200ms_ease-out] data-[scrolling=land]:[--thread-scroll:5s_ease-in-out]", + style: {}, + caption: "A 5s land: the reply streams in while it is still landing.", + }, +} satisfies Record; + +type Preset = keyof typeof PRESETS; + +const REPLY = + "Memoisation compares props, so the comparison itself has to be cheaper than the render you are avoiding. That is usually true for a list and rarely true for a single row. Measure first: the profiler shows which renders are worth skipping, and which ones were never the problem. Then memoise the expensive subtree, keep its props stable, and leave the cheap rows alone."; + +/* + * Scroll timing, set from CSS. + * + * With no timing, smooth scrolls are the browser's own. Set `--thread-scroll` + * (or its longhands, or a speed) on the root and the thread animates them + * itself. While it does, the root carries `data-scrolling` naming why — so a + * selector can time the landing differently from the follow, as the slow + * preset does. Scroll by hand at any point and the animation stops. + */ +export const ScrollTiming = () => { + const [preset, setPreset] = useState("spring"); + // The reply starts a beat after the send, once the landing is under way. + const chat = useStreamedChat({ seed: SEED, reply: REPLY, firstWordAfter: 300, wordInterval: 50 }); + const { className, style, caption } = PRESETS[preset]; + + return ( +
+ { + setPreset(next); + chat.reset(); + }} + /> + +

{caption}

+
+ ); +}; diff --git a/content/docs/primitives/thread/demos/streamed-chat.tsx b/content/docs/primitives/thread/demos/streamed-chat.tsx new file mode 100644 index 00000000..8a975e65 --- /dev/null +++ b/content/docs/primitives/thread/demos/streamed-chat.tsx @@ -0,0 +1,207 @@ +"use client"; + +import { Composer, type ComposerSubmitData } from "@intentface/ui/composer"; +import { Message } from "@intentface/ui/message"; +import { Thread, type ThreadRootProps } from "@intentface/ui/thread"; +import { ArrowUp } from "@keyline-icons/react"; +import { useEffect, useRef, useState } from "react"; + +export type Exchange = { id: string; question: string; answer: string }; + +const PROMPT = "Is memoising the list enough?"; + +/* + * A scripted chat. Sending appends the question and streams `reply` into it + * word by word, so following is something you can actually watch; the composer + * refills once the reply is done, so the next send is one click. + */ +export const useStreamedChat = ({ + seed, + reply, + firstWordAfter = 0, + wordInterval = 60, +}: { + seed: Exchange[]; + reply: string; + /** Milliseconds from sending to the first word. */ + firstWordAfter?: number; + /** Milliseconds between words. */ + wordInterval?: number; +}) => { + const [exchanges, setExchanges] = useState(seed); + const [draft, setDraft] = useState(PROMPT); + const [streaming, setStreaming] = useState(false); + const timers = useRef[]>([]); + + useEffect(() => () => timers.current.forEach(clearTimeout), []); + + const stop = () => { + timers.current.forEach(clearTimeout); + timers.current = []; + setStreaming(false); + }; + + const later = (delay: number, run: () => void) => { + timers.current.push(setTimeout(run, firstWordAfter + delay)); + }; + + const send = (data: ComposerSubmitData) => { + if (data.kind !== "message" || !data.text.trim() || streaming) return; + stop(); + + const id = `${Date.now()}`; + setExchanges((current) => [...current, { id, question: data.text, answer: "" }]); + setStreaming(true); + + const words = reply.split(" "); + words.forEach((_, index) => { + later(index * wordInterval, () => + setExchanges((current) => + current.map((exchange) => + exchange.id === id + ? { ...exchange, answer: words.slice(0, index + 1).join(" ") } + : exchange, + ), + ), + ); + }); + later(words.length * wordInterval, () => { + setStreaming(false); + setDraft(PROMPT); + }); + }; + + // Back to the seed. A reply still streaming belongs to the old transcript, so + // it stops too. + const reset = () => { + stop(); + setExchanges(seed); + setDraft(PROMPT); + }; + + return { exchanges, draft, setDraft, streaming, send, reset }; +}; + +export type StreamedChatState = ReturnType; + +// The segmented control above a demo, one button per option. +export const ModeSwitch = ({ + options, + value, + onChange, +}: { + options: readonly T[]; + value: T; + onChange: (option: T) => void; +}) => ( +
+ {options.map((option) => ( + + ))} +
+); + +// The raised surface of the selected option. +const RAISED = + "bg-white bg-linear-to-b from-white to-[#fdfdfd] shadow-[inset_0_1px_0_#fff,0_0_0_1px_rgb(0_0_0/0.075),0_1px_2px_rgb(0_0_0/0.07),0_2px_6px_-2px_rgb(0_0_0/0.05)] dark:bg-[#2d2d30] dark:from-[#313134] dark:to-[#2a2a2d] dark:shadow-[inset_0_1px_0_rgb(255_255_255/0.1),inset_0_0_0_1px_rgb(255_255_255/0.05),0_0_0_1px_rgb(0_0_0/0.16),0_1px_2px_rgb(0_0_0/0.1)]"; + +/* + * The chat itself: a thread in a card, with the composer docked over its + * bottom edge. Everything but `chat` goes to Thread.Root, and `className` adds + * to its own. + * + * Each question and its reply share a Message.Turn, so the reserve the primitive + * publishes lands on the whole turn (it goes on the last child). The docked + * composer is measured, so the reserve fills exactly the visible area. + */ +export const StreamedChat = ({ + chat, + className, + ...rootProps +}: Omit & { chat: StreamedChatState }) => ( +
+ + {/* Measured by the primitive: a landed turn sits this far below the top. */} + + +
+ {/* The reserve the primitive publishes lands on the last child: the newest turn. */} + + {chat.exchanges.map((exchange, index) => ( + + {/* biome-ignore lint/a11y/useValidAriaRole: `role` is the message's author, not an ARIA role */} + + + {exchange.question} + + + {exchange.answer && ( + // biome-ignore lint/a11y/useValidAriaRole: `role` is the message's author, not an ARIA role + + + {exchange.answer} + + + )} + + ))} + +
+
+ +
+ + + + + + + + + + + + +
+
+
+
+); diff --git a/lib/store/settings.ts b/lib/store/settings.ts index 04d0f9b9..f0dc2316 100644 --- a/lib/store/settings.ts +++ b/lib/store/settings.ts @@ -23,6 +23,11 @@ type CommandToggles = { // floating Popover anchored to the active command badge. type CommandSurface = "panel" | "popover"; +// The thread's smooth-scroll timing (playground Thread card). "default" keeps +// the styled Thread's own timing, "native" opts back into the browser's smooth +// scroll, and the rest replace the timing with that easing. +type ScrollTimingOption = "default" | "native" | "ease-out" | "ease-in-out" | "linear" | "spring"; + type SettingsStore = { themeOverrides: Record; setThemeOverride: (mode: ThemeMode, key: keyof ThemeOverrides, value: string | number) => void; @@ -30,6 +35,17 @@ type SettingsStore = { resetThemeOverrides: (mode: ThemeMode) => void; scrollMode: ThreadAutoScrollMode; setScrollMode: (mode: ThreadAutoScrollMode) => void; + scrollTiming: ScrollTimingOption; + setScrollTiming: (scrollTiming: ScrollTimingOption) => void; + /** Milliseconds; the cap when a speed is set. */ + scrollDuration: number; + setScrollDuration: (scrollDuration: number) => void; + /** Pixels per second; 0 leaves the duration fixed. */ + scrollSpeed: number; + setScrollSpeed: (scrollSpeed: number) => void; + /** Animate the follow with the scroll timing instead of pinning it. */ + animateFollow: boolean; + setAnimateFollow: (animateFollow: boolean) => void; stickyMessages: boolean; setStickyMessages: (stickyMessages: boolean) => void; showScrollButton: boolean; @@ -52,7 +68,7 @@ type SettingsStore = { setSubmitOn: (submitOn: ComposerSubmitOn) => void; }; -export type { ThemeOverrides, ThemeMode, CommandToggles, CommandSurface }; +export type { ThemeOverrides, ThemeMode, CommandToggles, CommandSurface, ScrollTimingOption }; export const useSettingsStore = create()( persist( @@ -60,6 +76,14 @@ export const useSettingsStore = create()( themeOverrides: { light: {}, dark: {} }, scrollMode: "follow", setScrollMode: (mode) => set({ scrollMode: mode }), + scrollTiming: "default", + setScrollTiming: (scrollTiming) => set({ scrollTiming }), + scrollDuration: 450, + setScrollDuration: (scrollDuration) => set({ scrollDuration }), + scrollSpeed: 0, + setScrollSpeed: (scrollSpeed) => set({ scrollSpeed }), + animateFollow: false, + setAnimateFollow: (animateFollow) => set({ animateFollow }), stickyMessages: false, setStickyMessages: (stickyMessages) => set({ stickyMessages }), showScrollButton: true, diff --git a/packages/ui/src/ask-user/ask-user.tsx b/packages/ui/src/ask-user/ask-user.tsx index 0645a92d..6713d10b 100644 --- a/packages/ui/src/ask-user/ask-user.tsx +++ b/packages/ui/src/ask-user/ask-user.tsx @@ -248,7 +248,7 @@ export const AskUserOptions = ({ if (!element) return; element.focus({ preventScroll: true }); const boundary = element.closest("[data-ask-user]") ?? element; - scrollIntoViewWithin(element.getBoundingClientRect(), element, boundary); + scrollIntoViewWithin(element, boundary); }, []); useImperativeHandle( diff --git a/packages/ui/src/composer/command-list.tsx b/packages/ui/src/composer/command-list.tsx index 8cb509fd..77cd0548 100644 --- a/packages/ui/src/composer/command-list.tsx +++ b/packages/ui/src/composer/command-list.tsx @@ -20,6 +20,7 @@ import { } from "react"; import type { PrimitiveProps } from "../internal/primitive-props"; import { useRenderElement } from "../internal/render/useRenderElement"; +import { scrollIntoViewWithin } from "../internal/scroll-into-view-within"; import { Commands } from "./commands"; import { filterArrayItems, suggestionRemainder } from "./fuzzy"; import { useAsRef, useComposerInternals, useIsomorphicLayoutEffect } from "./internals"; @@ -322,23 +323,16 @@ export const ComposerCommand = ({ // Scroll the highlighted row into view as it becomes the highlight. Stable, so // React calls it only on highlight change — no per-render scroll, no effect. - // - // container:"nearest" hard-scopes the scroll to the list on Chromium. - // Safari/Firefox silently ignore the member (unknown dictionary keys) and - // run an UNSCOPED block:"nearest" — scrollIntoView then scrolls every - // ancestor scrolling box, the window included, whenever the row isn't - // viewport-visible. - // - // KNOWN BUG (accepted 2026-07-10): on Safari, arrowing through the list - // while the popup opens can scroll the page behind it (reproduced; the - // positioner's viewport containment is not sufficient there). Accepted in - // exchange for native scroll-padding support and −24 lines; goes away when - // WebKit ships `container`. If it starts to hurt sooner, the scoped manual - // implementation lives in this function's git history (PR #39, first - // commit). + // Scoped to the list's own container, the popover or (for an in-flow panel) + // the composer, so the page behind it never moves; the list alone when it's + // mounted anywhere else. const scrollHighlightedIntoView = useCallback((node: HTMLElement | null) => { - // Cast: lib.dom doesn't know the `container` member yet (Chromium-only). - node?.scrollIntoView({ block: "nearest", container: "nearest" } as ScrollIntoViewOptions); + if (!node) return; + const boundary = + node.closest("[data-composer-popover], [data-composer-root]") ?? + node.closest("[data-composer-command-list]") ?? + node; + scrollIntoViewWithin(node, boundary); }, []); const navContext = useMemo( diff --git a/packages/ui/src/composer/editor-dom.ts b/packages/ui/src/composer/editor-dom.ts index acbfda6e..2fbd8a38 100644 --- a/packages/ui/src/composer/editor-dom.ts +++ b/packages/ui/src/composer/editor-dom.ts @@ -534,7 +534,7 @@ export const scrollCaretIntoView = (root: HTMLElement): void => { caret.collapse(true); const rect = measureCaret(caret); if (!rect) return; - scrollIntoViewWithin(rect, root, root.closest("[data-composer-root]") ?? root); + scrollIntoViewWithin({ rect, from: root }, root.closest("[data-composer-root]") ?? root); }; // --------------------------------------------------------------------------- diff --git a/packages/ui/src/internal/scroll-into-view-within.ts b/packages/ui/src/internal/scroll-into-view-within.ts index a67692f6..ebd16f02 100644 --- a/packages/ui/src/internal/scroll-into-view-within.ts +++ b/packages/ui/src/internal/scroll-into-view-within.ts @@ -1,25 +1,114 @@ -// Bring a rect into view by scrolling each scrollable ancestor of `from` just -// enough — but never one outside `boundary`. A primitive scrolls only its own -// scroll areas: walking further would reach the page around it (an embedded -// composer's docs page, say) and yank it, which is exactly what scrollIntoView -// does and why it isn't used. `boundary` itself may scroll. -export const scrollIntoViewWithin = (rect: DOMRect, from: HTMLElement, boundary: Element): void => { - let { top, bottom } = rect; - let scroller: HTMLElement | null = from; +// Scroll something into view inside a primitive's own scroll areas, never the +// page around it. scrollIntoView walks every scrollable ancestor up to the +// window, so an embedded composer or thread would yank its page, and its +// `container: "nearest"` scoping is Chromium-only. Hence by hand, with +// scrollIntoView's `block` semantics. + +// scrollIntoView's `block` alignment. +export type ScrollAlign = "start" | "center" | "end" | "nearest"; + +// Vertical edges in viewport coordinates. +type Edges = { top: number; bottom: number }; + +const px = (value: string) => Number.parseFloat(value) || 0; + +// scroll-padding's computed value keeps percentages, which resolve against the +// scroll container's own height. (scroll-margin takes lengths only.) +const paddingPx = (value: string, height: number) => + value.trim().endsWith("%") ? (px(value) / 100) * height : px(value); + +// An element's box grown by its scroll-margin: the box scrollIntoView aligns. +const marginEdges = (element: Element): Edges => { + const rect = element.getBoundingClientRect(); + const style = getComputedStyle(element); + return { + top: rect.top - px(style.scrollMarginTop), + bottom: rect.bottom + px(style.scrollMarginBottom), + }; +}; + +/** + * The scrollTop that aligns `target` inside `scroller` the way scrollIntoView + * would: honoring an element's scroll-margin and the scroller's scroll-padding, + * clamped to the scroll range. Returned, not applied, so a scripted animation + * can head for it. A bare rect (a caret has no element) has no margin. + */ +export const alignedScrollTop = ( + scroller: HTMLElement, + target: Element | Edges, + align: ScrollAlign, +): number => { + const edges = target instanceof Element ? marginEdges(target) : target; + const scrollerStyle = getComputedStyle(scroller); + const scrollerTop = scroller.getBoundingClientRect().top + scroller.clientTop; + + // Target edges in scroll coordinates. + const targetTop = edges.top - scrollerTop + scroller.scrollTop; + const targetBottom = edges.bottom - scrollerTop + scroller.scrollTop; + // The visible band, shrunk by the scroller's scroll-padding. + const paddingTop = paddingPx(scrollerStyle.scrollPaddingTop, scroller.clientHeight); + const paddingBottom = paddingPx(scrollerStyle.scrollPaddingBottom, scroller.clientHeight); + const band = scroller.clientHeight - paddingTop - paddingBottom; + const viewTop = scroller.scrollTop + paddingTop; + const viewBottom = viewTop + band; + + const toStart = targetTop - paddingTop; + const toEnd = targetBottom - paddingTop - band; + const destination = (() => { + if (align === "start") return toStart; + if (align === "end") return toEnd; + if (align === "center") return (targetTop + targetBottom) / 2 - paddingTop - band / 2; + // "nearest", per the CSSOM View rules: leave a target that is fully in + // view, or that overflows the band at both edges, where it is; otherwise + // bring in the edge that is out of view, or the top of one too tall to fit. + const fits = targetBottom - targetTop <= band; + const above = targetTop < viewTop; + const below = targetBottom > viewBottom; + if (above === below) return scroller.scrollTop; + return above === fits ? toStart : toEnd; + })(); + + const max = scroller.scrollHeight - scroller.clientHeight; + return Math.min(Math.max(0, destination), Math.max(0, max)); +}; + +// A scroll area the walk may move: it scrolls on its own and has room to. +const scrolls = (element: HTMLElement) => { + const { overflowY } = getComputedStyle(element); + return ( + (overflowY === "auto" || overflowY === "scroll") && element.scrollHeight > element.clientHeight + ); +}; + +export type ScrollIntoViewWithinOptions = { + /** Where the target lands in each scroll area; scrollIntoView's `block`. Default "nearest". */ + block?: ScrollAlign; +}; + +/** + * Brings `target` into view by scrolling each scrollable ancestor in turn, as + * scrollIntoView does, but never one outside `boundary` (which may scroll + * itself). `target` is an element, or a bare rect measured from inside `from`. + */ +export const scrollIntoViewWithin = ( + target: HTMLElement | { rect: DOMRect; from: HTMLElement }, + boundary: Element, + { block = "nearest" }: ScrollIntoViewWithinOptions = {}, +): void => { + const isElement = target instanceof HTMLElement; + let edges: Edges = isElement ? marginEdges(target) : target.rect; + // An element scrolls its ancestors, never itself; a rect's `from` may scroll. + let scroller = isElement ? target.parentElement : target.from; while (scroller && boundary.contains(scroller)) { - const { overflowY } = getComputedStyle(scroller); - if ( - (overflowY === "auto" || overflowY === "scroll") && - scroller.scrollHeight > scroller.clientHeight - ) { - const view = scroller.getBoundingClientRect(); + if (scrolls(scroller)) { const before = scroller.scrollTop; - if (top < view.top) scroller.scrollTop -= view.top - top; - else if (bottom > view.bottom) scroller.scrollTop += bottom - view.bottom; - // The rect moves with the scrolled content; carry that to the next scroller out. - const moved = scroller.scrollTop - before; - top -= moved; - bottom -= moved; + const top = alignedScrollTop(scroller, edges, block); + scroller.scrollTop = top; + // The target moves with the scrolled content; carry that to the next + // scroller out. From the destination, not a read-back: a CSS smooth + // scroll hasn't moved yet. + const moved = top - before; + edges = { top: edges.top - moved, bottom: edges.bottom - moved }; } scroller = scroller.parentElement; } diff --git a/packages/ui/src/thread/geometry.ts b/packages/ui/src/thread/geometry.ts index 0f2f4f95..999bb0f7 100644 --- a/packages/ui/src/thread/geometry.ts +++ b/packages/ui/src/thread/geometry.ts @@ -6,10 +6,11 @@ export const prefersReducedMotion = (): boolean => typeof window !== "undefined" && window.matchMedia("(prefers-reduced-motion: reduce)").matches; -// "smooth" downgrades to "auto" under prefers-reduced-motion — deliberate -// movements still happen, they just don't animate. +// "smooth" downgrades to "instant" under prefers-reduced-motion — deliberate +// movements still happen, they just don't animate. Not "auto": that defers to +// the scroller's CSS scroll-behavior, which may itself be smooth. export const resolveScrollBehavior = (behavior: ScrollBehavior): ScrollBehavior => - behavior === "smooth" && prefersReducedMotion() ? "auto" : behavior; + behavior === "smooth" && prefersReducedMotion() ? "instant" : behavior; // Single place that performs the scroll, so callers just choose the behavior: // 'instant' for jumps that must not animate, 'smooth' for deliberate movements. diff --git a/packages/ui/src/thread/index.ts b/packages/ui/src/thread/index.ts index 7a5414e2..e33099ee 100644 --- a/packages/ui/src/thread/index.ts +++ b/packages/ui/src/thread/index.ts @@ -10,6 +10,7 @@ export type { ThreadOverlayState, ThreadPlaceholderProps, ThreadRootProps, + ThreadScrollKind, ThreadScrollToMessageOptions, ThreadViewportProps, } from "./thread"; diff --git a/packages/ui/src/thread/scroll-animation.ts b/packages/ui/src/thread/scroll-animation.ts new file mode 100644 index 00000000..523de978 --- /dev/null +++ b/packages/ui/src/thread/scroll-animation.ts @@ -0,0 +1,139 @@ +// Scripted scroll animation for the thread. The styled layer owns the timing +// through CSS variables on the thread root; the thread owns the motion. When no +// timing is configured, callers fall back to the browser's native smooth scroll. + +// Why a scroll was started. Stamped on the root as data-scrolling *before* the +// timing is read, so a selector can give each kind its own timing. +export type ThreadScrollKind = "land" | "follow" | "bottom" | "top" | "message"; + +// What the CSS variables configure. The duration itself is resolved per +// scroll, since a speed makes it depend on the distance. +export type ScrollTiming = { + /** The fixed duration (ms); the cap when a speed is set. */ + duration: number | null; + /** Pixels per second; null when the duration is fixed. */ + speed: number | null; + easing: string; +}; + +const DEFAULT_EASING = "ease"; + +// A CSS