Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/scroll-into-view-within.md
Original file line number Diff line number Diff line change
@@ -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`.
10 changes: 10 additions & 0 deletions .changeset/thread-scroll-timing.md
Original file line number Diff line number Diff line change
@@ -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`.
28 changes: 25 additions & 3 deletions components/ai/thread.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,12 +23,31 @@ export type { ThreadAutoScrollMode, ThreadVisibilityState };
// Root
// ---------------------------------------------------------------------------

export type ThreadRootProps = ComponentProps<typeof ThreadPrimitive.Root>;
export type ThreadRootProps = ComponentProps<typeof ThreadPrimitive.Root> & {
/**
* 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) => (
<ThreadPrimitive.Root
data-animate-follow={animateFollow ? "" : undefined}
className={cn(
"relative flex h-full w-full overflow-hidden [--thread-overlay-top-height:4rem] [--thread-overlay-bottom-height:8rem]",
SCROLL_TIMING,
className,
)}
{...props}
Expand Down Expand Up @@ -74,7 +93,10 @@ ThreadOverlay.displayName = "ThreadOverlay";
export type ThreadViewportProps = ComponentProps<"div">;

const ThreadViewport = ({ children, className, ...props }: ThreadViewportProps) => (
<ThreadPrimitive.Viewport className="h-full w-full overflow-y-auto overflow-x-hidden [overflow-anchor:auto] scrollbar-gutter-stable scrollbar-thin [scrollbar-color:var(--color-ink-tertiary)_transparent] outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-accent-bg/40">
// 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.
<ThreadPrimitive.Viewport className="h-full w-full overflow-y-auto overflow-x-hidden [overflow-anchor:auto] scroll-pt-(--thread-overlay-top-height) scroll-pb-(--thread-overlay-bottom-height) scrollbar-gutter-stable scrollbar-thin [scrollbar-color:var(--color-ink-tertiary)_transparent] outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-accent-bg/40">
<div
data-slot="thread-viewport"
className={cn(
Expand Down
40 changes: 37 additions & 3 deletions components/chat.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,11 +23,12 @@ import {
// Keyline has no sandbox icon, so this one stays on Tabler.
import { IconSandbox } from "@tabler/icons-react";
import type { ChatStatus } from "ai";
import { AnimatePresence, stagger } from "motion/react";
import { AnimatePresence, spring, stagger } from "motion/react";
import * as m from "motion/react-m";
import { useRouter } from "next/navigation";
import {
Children,
type CSSProperties,
createContext,
memo,
use,
Expand Down Expand Up @@ -70,7 +71,7 @@ import { fetchPlaygroundIssues, GroupedIssueCommands } from "@/lib/playground-de
import { useChatStore } from "@/lib/store/chat";
import { useModelStore } from "@/lib/store/model";
import { usePlaygroundStore } from "@/lib/store/playground";
import { useSettingsStore } from "@/lib/store/settings";
import { type ScrollTimingOption, useSettingsStore } from "@/lib/store/settings";
import { cn } from "@/lib/utils";
import { LogoTile } from "./icons/logo-tile";
import { TextShimmer } from "./ui/text-shimmer";
Expand Down Expand Up @@ -1074,6 +1075,26 @@ const ChatPlaceholder = () => {
);
};

// 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
// `<duration> 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.
Expand All @@ -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 (
<>
Expand All @@ -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. */}
<div className="relative flex h-full min-h-0 w-full">
<Thread autoScroll={scrollMode} className="min-w-0 flex-1">
<Thread
autoScroll={scrollMode}
animateFollow={animateFollow}
style={scrollTimingStyle}
className="min-w-0 flex-1"
>
<Header />
{showOverlays && <Thread.Overlay direction="top" />}
<Thread.Viewport>
Expand Down
154 changes: 139 additions & 15 deletions components/playground-settings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -69,6 +73,42 @@ const SettingsSection = ({ children }: { children: ReactNode }) => (
<div className="border-ink-primary/6 border-b py-1 last:border-0">{children}</div>
);

// 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;
}) => (
<Slider
aria-label={label}
className="w-[6.5rem]"
value={value}
onValueChange={(next) => {
if (typeof next === "number") onValueChange(next);
}}
min={min}
max={max}
step={step}
>
<Slider.Control>
<Slider.Track>
<Slider.Indicator />
<Slider.Thumb aria-label={label} />
</Slider.Track>
</Slider.Control>
</Slider>
);

// 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").
Expand Down Expand Up @@ -205,24 +245,14 @@ const ThemeTab = () => {
</LabeledRow>

<LabeledRow label="Contrast" description="Surface separation.">
<Slider
aria-label="Contrast"
className="w-[6.5rem]"
<CompactSlider
label="Contrast"
value={seeds.con}
onValueChange={(value) => {
if (typeof value === "number") setSeed("con", value);
}}
onValueChange={(value) => setSeed("con", value)}
min={CONTRAST_MIN}
max={CONTRAST_MAX}
step={CONTRAST_STEP}
>
<Slider.Control>
<Slider.Track>
<Slider.Indicator />
<Slider.Thumb aria-label="Contrast" />
</Slider.Track>
</Slider.Control>
</Slider>
/>
</LabeledRow>
</div>
);
Expand All @@ -239,13 +269,41 @@ 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);
const showScrollButton = useSettingsStore((state) => state.showScrollButton);
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 (
<div className="py-1">
Expand All @@ -270,6 +328,72 @@ const ThreadTab = () => {
</Select.Content>
</Select>
</LabeledRow>
<LabeledRow label="Scroll timing" description="Easing of every smooth scroll">
<Select
value={scrollTiming}
onValueChange={(value: ScrollTimingOption | null) => {
if (value) setScrollTiming(value);
}}
>
<Select.Trigger className={COMPACT_CONTROL_CLASS} size="sm">
<Select.Value>
{SCROLL_TIMING_OPTIONS.find((option) => option.value === scrollTiming)?.label}
</Select.Value>
</Select.Trigger>
<Select.Content>
{SCROLL_TIMING_OPTIONS.map((option) => (
<Select.Item key={option.value} value={option.value}>
{option.label}
</Select.Item>
))}
</Select.Content>
</Select>
</LabeledRow>
{customTiming && (
<>
<LabeledRow
label="Duration"
description={
scrollTiming === "spring"
? `${scrollDuration}ms spring`
: scrollSpeed > 0
? `At most ${scrollDuration}ms`
: `${scrollDuration}ms`
}
>
<CompactSlider
label="Scroll duration"
value={scrollDuration}
onValueChange={setScrollDuration}
min={SCROLL_DURATION_MIN}
max={SCROLL_DURATION_MAX}
step={SCROLL_DURATION_STEP}
/>
</LabeledRow>
<LabeledRow
label="Speed"
description={scrollSpeed > 0 ? `${scrollSpeed}px/s` : "Off: fixed duration"}
>
<CompactSlider
label="Scroll speed"
value={scrollSpeed}
onValueChange={setScrollSpeed}
min={0}
max={SCROLL_SPEED_MAX}
step={SCROLL_SPEED_STEP}
/>
</LabeledRow>
</>
)}
{/* Native has no scripted follow to switch on. */}
{scrollTiming !== "native" && (
<ToggleRow
label="Animate follow"
description="Ease along the stream instead of pinning"
checked={animateFollow}
onCheckedChange={setAnimateFollow}
/>
)}
<ToggleRow
label="Scroll button"
description="Jump-to-bottom affordance"
Expand Down
Loading
Loading