Refactor compare page dual chat scrolling behavior

This commit is contained in:
sneakr 2026-04-20 23:54:42 +02:00
parent d56bb382b1
commit d056ec09f2
3 changed files with 538 additions and 125 deletions

View file

@ -6,19 +6,27 @@ import {
ComposerAttachments,
UserMessageAttachments,
} from "@/components/assistant-ui/attachment";
import { MessageTiming } from "@/components/assistant-ui/message-timing";
import { CodeToggleIcon } from "@/components/assistant-ui/code-toggle-icon";
import { MarkdownText } from "@/components/assistant-ui/markdown-text";
import { MessageTiming } from "@/components/assistant-ui/message-timing";
import { Reasoning, ReasoningGroup } from "@/components/assistant-ui/reasoning";
import { Sources, SourcesGroup } from "@/components/assistant-ui/sources";
import { ToolFallback } from "@/components/assistant-ui/tool-fallback";
import { ToolGroup } from "@/components/assistant-ui/tool-group";
import { WebSearchToolUI } from "@/components/assistant-ui/tool-ui-web-search";
import { PythonToolUI } from "@/components/assistant-ui/tool-ui-python";
import { TerminalToolUI } from "@/components/assistant-ui/tool-ui-terminal";
import { WebSearchToolUI } from "@/components/assistant-ui/tool-ui-web-search";
import { TooltipIconButton } from "@/components/assistant-ui/tooltip-icon-button";
import { CodeToggleIcon } from "@/components/assistant-ui/code-toggle-icon";
import {
ScrollToBottomProvider,
useIntentAwareAutoScroll,
useIsThreadAtBottom,
useScrollThreadToBottom,
} from "@/components/assistant-ui/use-intent-aware-autoscroll";
import { Button } from "@/components/ui/button";
import { sentAudioNames } from "@/features/chat/api/chat-adapter";
import { useChatRuntimeStore } from "@/features/chat/stores/chat-runtime-store";
import { deleteThreadMessage } from "@/features/chat/utils/delete-thread-message";
import { AUDIO_ACCEPT, MAX_AUDIO_SIZE, fileToBase64 } from "@/lib/audio-utils";
import { copyToClipboard } from "@/lib/copy-to-clipboard";
import { cn } from "@/lib/utils";
@ -35,9 +43,7 @@ import {
useAui,
useAuiEvent,
useAuiState,
useThreadViewport,
} from "@assistant-ui/react";
import { motion } from "motion/react";
import {
ArrowDownIcon,
ArrowUpIcon,
@ -50,9 +56,9 @@ import {
HeadphonesIcon,
LightbulbIcon,
LightbulbOffIcon,
LoaderIcon,
MicIcon,
MoreHorizontalIcon,
LoaderIcon,
PencilIcon,
RefreshCwIcon,
SquareIcon,
@ -60,15 +66,21 @@ import {
Trash2Icon,
XIcon,
} from "lucide-react";
import { motion } from "motion/react";
import { type FC, useCallback, useEffect, useRef, useState } from "react";
import { toast } from "sonner";
import { deleteThreadMessage } from "@/features/chat/utils/delete-thread-message";
import { useChatRuntimeStore } from "@/features/chat/stores/chat-runtime-store";
export const Thread: FC<{ hideComposer?: boolean; hideWelcome?: boolean }> = ({
hideComposer,
hideWelcome,
}) => {
export const Thread: FC<{
hideComposer?: boolean;
hideWelcome?: boolean;
}> = ({ hideComposer, hideWelcome }) => {
// Intent-aware autoscroll: replaces assistant-ui's built-in autoscroll
// to prevent the streaming-mutation race that makes the viewport snap
// back to the bottom while the user is scrolling up (see the hook for
// the full explanation).
const { ref: viewportRef, context: autoScrollContext } =
useIntentAwareAutoScroll();
return (
<ThreadPrimitive.Root
className="aui-root aui-thread-root @container relative flex min-h-0 min-w-0 flex-1 basis-0 flex-col overflow-hidden"
@ -78,7 +90,13 @@ export const Thread: FC<{ hideComposer?: boolean; hideWelcome?: boolean }> = ({
"calc(var(--thread-max-width) - 2.5rem)",
}}
>
<ScrollToBottomProvider value={autoScrollContext}>
<ThreadPrimitive.Viewport
ref={viewportRef}
autoScroll={false}
scrollToBottomOnRunStart={false}
scrollToBottomOnInitialize={false}
scrollToBottomOnThreadSwitch={false}
className={cn(
"aui-thread-viewport relative flex min-h-0 min-w-0 flex-1 basis-0 flex-col overflow-x-auto overflow-y-auto scroll-smooth px-5",
hideComposer ? "pt-4" : "pt-[48px]",
@ -105,7 +123,7 @@ export const Thread: FC<{ hideComposer?: boolean; hideWelcome?: boolean }> = ({
<AuiIf condition={({ thread }) => !thread.isEmpty}>
<div
className={cn("shrink-0", hideComposer ? "h-16" : "h-40")}
aria-hidden
aria-hidden={true}
/>
</AuiIf>
@ -125,7 +143,7 @@ export const Thread: FC<{ hideComposer?: boolean; hideWelcome?: boolean }> = ({
<AuiIf condition={({ thread }) => !thread.isEmpty}>
<div className="aui-thread-composer-dock pointer-events-none absolute bottom-0 left-0 right-0 md:right-2 z-20">
<div
aria-hidden
aria-hidden={true}
className="absolute inset-x-0 bottom-0 top-[10px] bg-background"
/>
<div className="relative px-5 pb-2">
@ -139,25 +157,25 @@ export const Thread: FC<{ hideComposer?: boolean; hideWelcome?: boolean }> = ({
</div>
</AuiIf>
)}
</ScrollToBottomProvider>
</ThreadPrimitive.Root>
);
};
const ThreadScrollToBottom: FC = () => {
// Scoped to the nearest ThreadPrimitive.Root via context, so in compare
// mode each pane reads its own viewport state.
//
// The button stays mounted and toggles visibility via CSS. Conditionally
// rendering (return null) unmounts a DOM node inside the viewport, which
// the assistant-ui autoscroll hook's MutationObserver sees as a content
// change — during streaming that triggered spurious scroll-to-bottom
// calls, especially in the narrower mobile stacked layout.
const isAtBottom = useThreadViewport((vp) => vp.isAtBottom);
// State and action both come from our ScrollToBottomProvider (scoped
// per Thread, so compare panes are independent). We deliberately
// avoid `ThreadPrimitive.ScrollToBottom` + `useThreadViewport` to
// stay off assistant-ui's internal autoscroll path — see the hook
// for why. The button stays mounted and toggles via CSS; unmounting
// would trip the hook's MutationObserver as a content change.
const isAtBottom = useIsThreadAtBottom();
const scrollToBottom = useScrollThreadToBottom();
return (
<ThreadPrimitive.ScrollToBottom asChild={true}>
<TooltipIconButton
tooltip="Scroll to bottom"
variant="outline"
onClick={() => scrollToBottom("auto")}
className={cn(
"aui-thread-scroll-to-bottom pointer-events-auto rounded-full p-4 bg-background hover:bg-accent dark:bg-background dark:hover:bg-accent",
isAtBottom && "invisible pointer-events-none",
@ -165,14 +183,20 @@ const ThreadScrollToBottom: FC = () => {
>
<ArrowDownIcon />
</TooltipIconButton>
</ThreadPrimitive.ScrollToBottom>
);
};
const SUGGESTION_TOOLS: Record<string, Array<"thinking" | "search" | "code">> = {
const SUGGESTION_TOOLS: Record<
string,
Array<"thinking" | "search" | "code">
> = {
"How do you fine-tune an audio model with Unsloth?": ["thinking", "search"],
"Create a live weather dashboard in HTML using no API key. Show me the code": ["thinking", "code", "search"],
"Solve the integral of x·sin(x), and verify it step by step": ["thinking", "code"],
"Create a live weather dashboard in HTML using no API key. Show me the code":
["thinking", "code", "search"],
"Solve the integral of x·sin(x), and verify it step by step": [
"thinking",
"code",
],
"Draw an SVG of a cute sloth & show the code": ["thinking", "code", "search"],
};
@ -182,7 +206,7 @@ const toolIconMap = {
code: { icon: TerminalIcon, label: "Code" },
} as const;
const SuggestionItem: FC = () => {
const _SuggestionItem: FC = () => {
const aui = useAui();
const prompt = useAuiState(({ suggestion }) => suggestion.prompt);
const isDisabled = useAuiState(({ thread }) => thread.isDisabled);
@ -193,7 +217,7 @@ const SuggestionItem: FC = () => {
<button
type="button"
onClick={() => {
if (!isDisabled && !isRunning) {
if (!(isDisabled || isRunning)) {
const store = useChatRuntimeStore.getState();
if (store.supportsReasoning) {
store.setReasoningEnabled(tools.includes("thinking"));
@ -257,7 +281,9 @@ const ThreadWelcome: FC<{ hideComposer?: boolean }> = ({ hideComposer }) => {
const GeneratingSpinner: FC = () => {
const status = useChatRuntimeStore((s) => s.generatingStatus);
if (!status) return null;
if (!status) {
return null;
}
return (
<div className="mx-auto flex w-full max-w-(--thread-max-width) items-center justify-center py-2">
<div className="flex items-center gap-2 text-xs text-muted-foreground">
@ -286,7 +312,9 @@ const ComposerAnimated: FC = () => {
const PendingAudioChip: FC = () => {
const audioName = useChatRuntimeStore((s) => s.pendingAudioName);
const clearPendingAudio = useChatRuntimeStore((s) => s.clearPendingAudio);
if (!audioName) return null;
if (!audioName) {
return null;
}
return (
<div className="mb-2 flex w-full flex-row items-center gap-2 px-1.5 pt-0.5 pb-1">
<div className="flex items-center gap-2 rounded-lg border border-foreground/20 bg-muted px-3 py-1.5 text-xs">
@ -336,7 +364,9 @@ const ComposerAudioUpload: FC = () => {
const handleAudioFile = useCallback(
async (file: File) => {
if (file.size > MAX_AUDIO_SIZE) return;
if (file.size > MAX_AUDIO_SIZE) {
return;
}
try {
const base64 = await fileToBase64(file);
setPendingAudio(base64, file.name);
@ -347,7 +377,9 @@ const ComposerAudioUpload: FC = () => {
[setPendingAudio],
);
if (!activeModel?.hasAudioInput) return null;
if (!activeModel?.hasAudioInput) {
return null;
}
return (
<>
@ -358,7 +390,9 @@ const ComposerAudioUpload: FC = () => {
className="hidden"
onChange={(e) => {
const file = e.target.files?.[0];
if (file) handleAudioFile(file);
if (file) {
handleAudioFile(file);
}
e.target.value = "";
}}
/>
@ -381,7 +415,9 @@ const ComposerAudioUpload: FC = () => {
function applyQwenThinkingParams(thinkingOn: boolean): void {
const store = useChatRuntimeStore.getState();
const checkpoint = store.params.checkpoint?.toLowerCase() ?? "";
if (!checkpoint.includes("qwen3")) return;
if (!checkpoint.includes("qwen3")) {
return;
}
const params = thinkingOn
? { temperature: 0.6, topP: 0.95, topK: 20, minP: 0.0 }
: { temperature: 0.7, topP: 0.8, topK: 20, minP: 0.0 };
@ -395,7 +431,7 @@ const ReasoningToggle: FC = () => {
const supportsReasoning = useChatRuntimeStore((s) => s.supportsReasoning);
const reasoningEnabled = useChatRuntimeStore((s) => s.reasoningEnabled);
const setReasoningEnabled = useChatRuntimeStore((s) => s.setReasoningEnabled);
const disabled = !modelLoaded || !supportsReasoning;
const disabled = !(modelLoaded && supportsReasoning);
return (
<button
@ -433,7 +469,7 @@ const WebSearchToggle: FC = () => {
const supportsTools = useChatRuntimeStore((s) => s.supportsTools);
const toolsEnabled = useChatRuntimeStore((s) => s.toolsEnabled);
const setToolsEnabled = useChatRuntimeStore((s) => s.setToolsEnabled);
const disabled = !modelLoaded || !supportsTools;
const disabled = !(modelLoaded && supportsTools);
return (
<button
@ -462,10 +498,8 @@ const CodeToolsToggle: FC = () => {
);
const supportsTools = useChatRuntimeStore((s) => s.supportsTools);
const codeToolsEnabled = useChatRuntimeStore((s) => s.codeToolsEnabled);
const setCodeToolsEnabled = useChatRuntimeStore(
(s) => s.setCodeToolsEnabled,
);
const disabled = !modelLoaded || !supportsTools;
const setCodeToolsEnabled = useChatRuntimeStore((s) => s.setCodeToolsEnabled);
const disabled = !(modelLoaded && supportsTools);
return (
<button
@ -480,7 +514,9 @@ const CodeToolsToggle: FC = () => {
? "bg-primary/10 text-primary hover:bg-primary/20"
: "bg-muted text-muted-foreground hover:bg-muted-foreground/15",
)}
aria-label={codeToolsEnabled ? "Disable code execution" : "Enable code execution"}
aria-label={
codeToolsEnabled ? "Disable code execution" : "Enable code execution"
}
>
<CodeToggleIcon className="size-3.5" />
<span>Code</span>
@ -493,6 +529,11 @@ const ToolStatusDisplay: FC = () => {
const isThreadRunning = useAuiState(({ thread }) => thread.isRunning);
const [elapsed, setElapsed] = useState(0);
const [visible, setVisible] = useState(false);
const visibleRef = useRef(false);
useEffect(() => {
visibleRef.current = visible;
}, [visible]);
useEffect(() => {
if (!toolStatus) {
@ -510,7 +551,7 @@ const ToolStatusDisplay: FC = () => {
// tools show immediately so the badge does not flicker. Fast
// tool calls that all complete under 300ms never show the badge.
let showTimer: ReturnType<typeof setTimeout> | undefined;
if (!visible) {
if (!visibleRef.current) {
showTimer = setTimeout(() => setVisible(true), 300);
}
@ -519,11 +560,15 @@ const ToolStatusDisplay: FC = () => {
}, 1000);
return () => {
clearInterval(interval);
if (showTimer) clearTimeout(showTimer);
if (showTimer) {
clearTimeout(showTimer);
}
};
}, [toolStatus, isThreadRunning]);
if (!toolStatus || !visible) return null;
if (!(toolStatus && visible)) {
return null;
}
const isRunning = toolStatus.startsWith("Running");
const StatusIcon = isRunning ? TerminalIcon : GlobeIcon;
return (
@ -618,7 +663,9 @@ const GeneratingIndicator: FC = () => {
({ message }) =>
message.content.length === 0 && message.status?.type === "running",
);
if (!show) return null;
if (!show) {
return null;
}
return <span className="text-sm text-muted-foreground">Generating...</span>;
};
@ -678,8 +725,7 @@ const DeleteMessageButton: FC = () => {
messageId,
remoteId,
});
} catch (error) {
console.error("Failed to delete message", error);
} catch (_error) {
toast.error("Failed to delete message");
}
};
@ -705,7 +751,9 @@ const CopyButton: FC = () => {
const text = aui.message().getCopyText();
if (await copyToClipboard(text)) {
setCopied(true);
if (resetTimeoutRef.current) clearTimeout(resetTimeoutRef.current);
if (resetTimeoutRef.current) {
clearTimeout(resetTimeoutRef.current);
}
resetTimeoutRef.current = setTimeout(() => {
setCopied(false);
resetTimeoutRef.current = null;
@ -763,8 +811,12 @@ const AssistantActionBar: FC = () => {
};
const UserMessageAudio: FC = () => {
const audioName = useAuiState(({ message }) => sentAudioNames.get(message.id));
if (!audioName) return null;
const audioName = useAuiState(({ message }) =>
sentAudioNames.get(message.id),
);
if (!audioName) {
return null;
}
return (
<div className="col-start-2 flex justify-end">
<div className="flex items-center gap-2 rounded-lg border border-foreground/20 bg-muted px-3 py-1.5 text-xs">

View file

@ -0,0 +1,358 @@
// SPDX-License-Identifier: AGPL-3.0-only
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
"use client";
import { useAuiEvent } from "@assistant-ui/react";
import {
type ReactNode,
type RefCallback,
createContext,
useCallback,
useContext,
useMemo,
useRef,
useSyncExternalStore,
} from "react";
/**
* Intent-aware autoscroll for a Thread viewport.
*
* Why we don't reuse assistant-ui's built-in autoscroll:
* `useThreadViewportAutoScroll` runs unconditionally whenever
* `ThreadPrimitive.Viewport` is mounted. Even with every
* `scrollToBottomOn*` prop disabled, it still installs observers
* that write `isAtBottom` to the shared viewport store on every
* layout change. On sidebar toggles, browser resizes, or
* mobiledesktop breakpoint crossings, that write races our scroll
* correction whoever writes last wins, and the scroll-to-bottom
* button flickers or sticks depending on observer ordering.
*
* Strategy:
* - Own `isAtBottom` as local state (exposed via
* `useIsThreadAtBottom`). Upstream can still write to its own
* store; nobody reads from it.
* - Drive the viewport with a single rAF loop governed by a follow
* deadline (`followUntilRef`). Any signal that can invalidate
* bottom alignment (resize, mutation, AUI event, programmatic
* scroll) extends the deadline; the loop pins to the bottom and
* reports `isAtBottom=true` until it expires, then settles on
* pure DOM observation.
* - Detect user intent (wheel up, touch swipe up, scroll direction)
* to detach. While detached, resize/mutation signals don't extend
* the deadline, so the button appears and stays. Re-attach when
* the user scrolls *down* within 24px of the bottom.
*/
const AT_BOTTOM_THRESHOLD_PX = 1;
const RE_ATTACH_THRESHOLD_PX = 24;
const TOUCH_MOVE_THRESHOLD_PX = 4;
// Window during which the viewport pins to the bottom through
// layout/content races. Extends on every resize/mutation, so streaming
// keeps the viewport pinned as long as content keeps arriving; settles
// this long after the last change.
const FOLLOW_SETTLE_MS = 600;
export type ScrollToBottom = (behavior?: ScrollBehavior) => void;
type AutoScrollContextValue = {
scrollToBottom: ScrollToBottom;
getIsAtBottom: () => boolean;
subscribe: (listener: () => void) => () => void;
};
const noopContext: AutoScrollContextValue = {
scrollToBottom: () => {
/* no viewport mounted */
},
getIsAtBottom: () => true,
subscribe: () => () => {
/* no-op */
},
};
const AutoScrollContext = createContext<AutoScrollContextValue>(noopContext);
export function ScrollToBottomProvider({
value,
children,
}: {
value: AutoScrollContextValue;
children: ReactNode;
}) {
return (
<AutoScrollContext.Provider value={value}>
{children}
</AutoScrollContext.Provider>
);
}
export function useScrollThreadToBottom(): ScrollToBottom {
return useContext(AutoScrollContext).scrollToBottom;
}
export function useIsThreadAtBottom(): boolean {
const ctx = useContext(AutoScrollContext);
return useSyncExternalStore(ctx.subscribe, ctx.getIsAtBottom, () => true);
}
export function useIntentAwareAutoScroll(): {
ref: RefCallback<HTMLElement>;
context: AutoScrollContextValue;
} {
const cleanupRef = useRef<(() => void) | null>(null);
const userDetachedRef = useRef(false);
const followUntilRef = useRef(0);
const isAtBottomRef = useRef(true);
const listenersRef = useRef<Set<() => void>>(new Set());
const scrollImplRef = useRef<ScrollToBottom>(() => {
/* no viewport mounted */
});
const getIsAtBottom = useCallback(() => isAtBottomRef.current, []);
const subscribe = useCallback((listener: () => void) => {
listenersRef.current.add(listener);
return () => {
listenersRef.current.delete(listener);
};
}, []);
const setIsAtBottom = useCallback((value: boolean) => {
if (isAtBottomRef.current === value) {
return;
}
isAtBottomRef.current = value;
for (const listener of listenersRef.current) {
listener();
}
}, []);
const scrollToBottom = useCallback<ScrollToBottom>((behavior) => {
scrollImplRef.current(behavior);
}, []);
const attach = useCallback(
(el: HTMLElement) => {
let rafId: number | null = null;
let lastScrollTop = el.scrollTop;
let lastClientWidth = el.clientWidth;
let lastClientHeight = el.clientHeight;
let touchStartY = 0;
const distanceFromBottom = (): number => {
if (el.scrollHeight <= el.clientHeight) {
return 0;
}
return el.scrollHeight - el.scrollTop - el.clientHeight;
};
const atBottomStrict = (): boolean =>
distanceFromBottom() <= AT_BOTTOM_THRESHOLD_PX;
const extendFollow = (): void => {
if (userDetachedRef.current) {
return;
}
followUntilRef.current = performance.now() + FOLLOW_SETTLE_MS;
};
const detach = (): void => {
userDetachedRef.current = true;
followUntilRef.current = 0;
};
const requestTick = (): void => {
if (rafId === null) {
rafId = requestAnimationFrame(tick);
}
};
// Single rAF loop. While within the follow window and not
// detached, pin the viewport to the bottom and report
// isAtBottom=true every frame. Otherwise settle on whatever the
// DOM says. Scheduling is edge-triggered: scroll/resize/mutation
// events call requestTick(), and the loop self-perpetuates only
// as long as pinning is still active.
const tick = (): void => {
rafId = null;
const following =
!userDetachedRef.current &&
performance.now() < followUntilRef.current;
if (following) {
if (!atBottomStrict() && el.scrollHeight > el.clientHeight) {
el.scrollTo({ top: el.scrollHeight, behavior: "instant" });
}
setIsAtBottom(true);
requestTick();
return;
}
setIsAtBottom(atBottomStrict());
};
scrollImplRef.current = (behavior = "auto") => {
userDetachedRef.current = false;
followUntilRef.current = performance.now() + FOLLOW_SETTLE_MS;
if (el.scrollHeight > el.clientHeight) {
el.scrollTo({ top: el.scrollHeight, behavior });
}
setIsAtBottom(true);
requestTick();
};
const onWheel = (e: WheelEvent) => {
if (e.deltaY < 0) {
detach();
}
};
const onTouchStart = (e: TouchEvent) => {
touchStartY = e.touches[0]?.clientY ?? 0;
};
const onTouchMove = (e: TouchEvent) => {
const y = e.touches[0]?.clientY ?? 0;
// Finger moves DOWN on the screen = content scrolls UP.
if (y - touchStartY > TOUCH_MOVE_THRESHOLD_PX) {
detach();
}
};
const onScroll = () => {
const scrollTop = el.scrollTop;
const clientWidth = el.clientWidth;
const clientHeight = el.clientHeight;
const sizeChanged =
clientWidth !== lastClientWidth || clientHeight !== lastClientHeight;
// Ignore direction signals that are a consequence of the
// browser clamping scrollTop during a viewport resize; only
// deliberate user-initiated scrolls should flip intent.
const scrollingUp = !sizeChanged && scrollTop < lastScrollTop - 1;
const scrollingDown = !sizeChanged && scrollTop > lastScrollTop + 1;
if (scrollingUp) {
detach();
} else if (
userDetachedRef.current &&
scrollingDown &&
distanceFromBottom() <= RE_ATTACH_THRESHOLD_PX
) {
userDetachedRef.current = false;
extendFollow();
}
lastScrollTop = scrollTop;
lastClientWidth = clientWidth;
lastClientHeight = clientHeight;
requestTick();
};
const resizeObserver = new ResizeObserver(() => {
extendFollow();
requestTick();
});
const mutationObserver = new MutationObserver((mutations) => {
// Ignore pure style-attribute mutations to avoid feedback
// loops with elements that update `style` in response to
// viewport state.
const relevant = mutations.some(
(m) => m.type !== "attributes" || m.attributeName !== "style",
);
if (!relevant) {
return;
}
extendFollow();
requestTick();
});
const onViewportResize = () => {
extendFollow();
requestTick();
};
// Pin to bottom when the ref first attaches. Covers the case
// where `thread.initialize` fires before the ref is bound.
extendFollow();
if (el.scrollHeight > el.clientHeight) {
el.scrollTo({ top: el.scrollHeight, behavior: "instant" });
}
setIsAtBottom(true);
requestTick();
resizeObserver.observe(el);
mutationObserver.observe(el, {
childList: true,
subtree: true,
characterData: true,
attributes: true,
});
el.addEventListener("wheel", onWheel, { passive: true });
el.addEventListener("touchstart", onTouchStart, { passive: true });
el.addEventListener("touchmove", onTouchMove, { passive: true });
el.addEventListener("scroll", onScroll, { passive: true });
// ResizeObserver above covers browser-window resizes (they resize
// the viewport element). visualViewport.resize is the only signal
// for iOS software-keyboard changes, where the visual viewport
// shrinks without the viewport element's clientHeight changing.
window.visualViewport?.addEventListener("resize", onViewportResize);
return () => {
if (rafId !== null) {
cancelAnimationFrame(rafId);
rafId = null;
}
resizeObserver.disconnect();
mutationObserver.disconnect();
el.removeEventListener("wheel", onWheel);
el.removeEventListener("touchstart", onTouchStart);
el.removeEventListener("touchmove", onTouchMove);
el.removeEventListener("scroll", onScroll);
window.visualViewport?.removeEventListener("resize", onViewportResize);
scrollImplRef.current = () => {
/* no viewport mounted */
};
};
},
[setIsAtBottom],
);
// Thread lifecycle moments that always pin to the bottom, regardless
// of prior detach state. "auto" respects CSS smooth scrolling for
// runStart so new turns glide in; "instant" snaps for load/switch
// where any animation would just be wasted motion.
const pinToBottom = useCallback((behavior: ScrollBehavior) => {
userDetachedRef.current = false;
scrollImplRef.current(behavior);
}, []);
useAuiEvent("thread.runStart", () => pinToBottom("auto"));
useAuiEvent("thread.initialize", () => pinToBottom("instant"));
useAuiEvent("threadListItem.switchedTo", () => pinToBottom("instant"));
const ref = useCallback<RefCallback<HTMLElement>>(
(el) => {
if (cleanupRef.current) {
cleanupRef.current();
cleanupRef.current = null;
}
if (el) {
cleanupRef.current = attach(el);
}
},
[attach],
);
const context = useMemo<AutoScrollContextValue>(
() => ({ scrollToBottom, getIsAtBottom, subscribe }),
[scrollToBottom, getIsAtBottom, subscribe],
);
return { ref, context };
}

View file

@ -10,14 +10,9 @@ import { Thread } from "@/components/assistant-ui/thread";
import { cn } from "@/lib/utils";
import { GuidedTour, useGuidedTourController } from "@/features/tour";
import { useSidebar } from "@/components/ui/sidebar";
import {
Settings05Icon,
} from "@hugeicons/core-free-icons";
import { Settings05Icon } from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
import {
Tooltip,
TooltipContent,
} from "@/components/ui/tooltip";
import { Tooltip, TooltipContent } from "@/components/ui/tooltip";
import { Tooltip as TooltipPrimitive } from "radix-ui";
import { useNavigate, useSearch } from "@tanstack/react-router";
import {
@ -533,8 +528,7 @@ export function ChatPage(): ReactElement {
if (search.compare) {
return {
mode: "compare",
pairId:
search.compare,
pairId: search.compare,
};
}
if (search.thread) {
@ -627,8 +621,14 @@ export function ChatPage(): ReactElement {
},
[modelSelectorLocked],
);
const openSettings = useCallback(() => setSettingsOpen(true), [setSettingsOpen]);
const closeSettings = useCallback(() => setSettingsOpen(false), [setSettingsOpen]);
const openSettings = useCallback(
() => setSettingsOpen(true),
[setSettingsOpen],
);
const closeSettings = useCallback(
() => setSettingsOpen(false),
[setSettingsOpen],
);
const { setPinned, isMobile } = useSidebar();
const openSidebar = useCallback(() => setPinned(true), [setPinned]);
@ -655,7 +655,9 @@ export function ChatPage(): ReactElement {
.first()
.then((msg) => {
const metadata = msg?.metadata as Record<string, unknown> | undefined;
const usage = metadata?.contextUsage as ReturnType<typeof useChatRuntimeStore.getState>["contextUsage"];
const usage = metadata?.contextUsage as ReturnType<
typeof useChatRuntimeStore.getState
>["contextUsage"];
if (usage) useChatRuntimeStore.getState().setContextUsage(usage);
});
}
@ -834,7 +836,8 @@ export function ChatPage(): ReactElement {
className={cn(
"absolute top-0 left-0 right-[10px] z-30 flex h-[48px] shrink-0 items-start pt-[11px] pr-2 bg-background",
isMobile ? "pl-12 pr-1.5" : "pl-2",
view.mode === "compare" && "right-[10px] left-auto w-auto bg-transparent pl-0 pr-2",
view.mode === "compare" &&
"right-[10px] left-auto w-auto bg-transparent pl-0 pr-2",
)}
>
<div className="flex items-center gap-1">