mirror of
https://github.com/unslothai/unsloth
synced 2026-04-21 13:37:39 +00:00
Refactor compare page dual chat scrolling behavior
This commit is contained in:
parent
d56bb382b1
commit
d056ec09f2
3 changed files with 538 additions and 125 deletions
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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
|
||||
* mobile↔desktop 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 };
|
||||
}
|
||||
|
|
@ -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">
|
||||
|
|
|
|||
Loading…
Reference in a new issue