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,
|
ComposerAttachments,
|
||||||
UserMessageAttachments,
|
UserMessageAttachments,
|
||||||
} from "@/components/assistant-ui/attachment";
|
} 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 { MarkdownText } from "@/components/assistant-ui/markdown-text";
|
||||||
|
import { MessageTiming } from "@/components/assistant-ui/message-timing";
|
||||||
import { Reasoning, ReasoningGroup } from "@/components/assistant-ui/reasoning";
|
import { Reasoning, ReasoningGroup } from "@/components/assistant-ui/reasoning";
|
||||||
import { Sources, SourcesGroup } from "@/components/assistant-ui/sources";
|
import { Sources, SourcesGroup } from "@/components/assistant-ui/sources";
|
||||||
import { ToolFallback } from "@/components/assistant-ui/tool-fallback";
|
import { ToolFallback } from "@/components/assistant-ui/tool-fallback";
|
||||||
import { ToolGroup } from "@/components/assistant-ui/tool-group";
|
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 { PythonToolUI } from "@/components/assistant-ui/tool-ui-python";
|
||||||
import { TerminalToolUI } from "@/components/assistant-ui/tool-ui-terminal";
|
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 { 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 { Button } from "@/components/ui/button";
|
||||||
import { sentAudioNames } from "@/features/chat/api/chat-adapter";
|
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 { AUDIO_ACCEPT, MAX_AUDIO_SIZE, fileToBase64 } from "@/lib/audio-utils";
|
||||||
import { copyToClipboard } from "@/lib/copy-to-clipboard";
|
import { copyToClipboard } from "@/lib/copy-to-clipboard";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
@ -35,9 +43,7 @@ import {
|
||||||
useAui,
|
useAui,
|
||||||
useAuiEvent,
|
useAuiEvent,
|
||||||
useAuiState,
|
useAuiState,
|
||||||
useThreadViewport,
|
|
||||||
} from "@assistant-ui/react";
|
} from "@assistant-ui/react";
|
||||||
import { motion } from "motion/react";
|
|
||||||
import {
|
import {
|
||||||
ArrowDownIcon,
|
ArrowDownIcon,
|
||||||
ArrowUpIcon,
|
ArrowUpIcon,
|
||||||
|
|
@ -50,9 +56,9 @@ import {
|
||||||
HeadphonesIcon,
|
HeadphonesIcon,
|
||||||
LightbulbIcon,
|
LightbulbIcon,
|
||||||
LightbulbOffIcon,
|
LightbulbOffIcon,
|
||||||
|
LoaderIcon,
|
||||||
MicIcon,
|
MicIcon,
|
||||||
MoreHorizontalIcon,
|
MoreHorizontalIcon,
|
||||||
LoaderIcon,
|
|
||||||
PencilIcon,
|
PencilIcon,
|
||||||
RefreshCwIcon,
|
RefreshCwIcon,
|
||||||
SquareIcon,
|
SquareIcon,
|
||||||
|
|
@ -60,15 +66,21 @@ import {
|
||||||
Trash2Icon,
|
Trash2Icon,
|
||||||
XIcon,
|
XIcon,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
import { motion } from "motion/react";
|
||||||
import { type FC, useCallback, useEffect, useRef, useState } from "react";
|
import { type FC, useCallback, useEffect, useRef, useState } from "react";
|
||||||
import { toast } from "sonner";
|
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 }> = ({
|
export const Thread: FC<{
|
||||||
hideComposer,
|
hideComposer?: boolean;
|
||||||
hideWelcome,
|
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 (
|
return (
|
||||||
<ThreadPrimitive.Root
|
<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"
|
className="aui-root aui-thread-root @container relative flex min-h-0 min-w-0 flex-1 basis-0 flex-col overflow-hidden"
|
||||||
|
|
@ -78,101 +90,113 @@ export const Thread: FC<{ hideComposer?: boolean; hideWelcome?: boolean }> = ({
|
||||||
"calc(var(--thread-max-width) - 2.5rem)",
|
"calc(var(--thread-max-width) - 2.5rem)",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<ThreadPrimitive.Viewport
|
<ScrollToBottomProvider value={autoScrollContext}>
|
||||||
className={cn(
|
<ThreadPrimitive.Viewport
|
||||||
"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",
|
ref={viewportRef}
|
||||||
hideComposer ? "pt-4" : "pt-[48px]",
|
autoScroll={false}
|
||||||
)}
|
scrollToBottomOnRunStart={false}
|
||||||
>
|
scrollToBottomOnInitialize={false}
|
||||||
{!hideWelcome && (
|
scrollToBottomOnThreadSwitch={false}
|
||||||
<AuiIf condition={({ thread }) => thread.isEmpty}>
|
className={cn(
|
||||||
<ThreadWelcome hideComposer={hideComposer} />
|
"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",
|
||||||
</AuiIf>
|
hideComposer ? "pt-4" : "pt-[48px]",
|
||||||
)}
|
)}
|
||||||
|
>
|
||||||
|
{!hideWelcome && (
|
||||||
|
<AuiIf condition={({ thread }) => thread.isEmpty}>
|
||||||
|
<ThreadWelcome hideComposer={hideComposer} />
|
||||||
|
</AuiIf>
|
||||||
|
)}
|
||||||
|
|
||||||
<ThreadPrimitive.Messages
|
<ThreadPrimitive.Messages
|
||||||
components={{
|
components={{
|
||||||
UserMessage,
|
UserMessage,
|
||||||
EditComposer,
|
EditComposer,
|
||||||
AssistantMessage,
|
AssistantMessage,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Bottom slack so the last message has breathing room above the
|
{/* Bottom slack so the last message has breathing room above the
|
||||||
sticky scroll-to-bottom button (and the floating composer in
|
sticky scroll-to-bottom button (and the floating composer in
|
||||||
single mode). Without this, content would butt against the
|
single mode). Without this, content would butt against the
|
||||||
sticky footer and feel cramped. */}
|
sticky footer and feel cramped. */}
|
||||||
<AuiIf condition={({ thread }) => !thread.isEmpty}>
|
<AuiIf condition={({ thread }) => !thread.isEmpty}>
|
||||||
<div
|
|
||||||
className={cn("shrink-0", hideComposer ? "h-16" : "h-40")}
|
|
||||||
aria-hidden
|
|
||||||
/>
|
|
||||||
</AuiIf>
|
|
||||||
|
|
||||||
<AuiIf condition={({ thread }) => !thread.isEmpty}>
|
|
||||||
<ThreadPrimitive.ViewportFooter
|
|
||||||
className={cn(
|
|
||||||
"aui-thread-viewport-footer pointer-events-none sticky z-20 flex w-full justify-center bg-transparent",
|
|
||||||
hideComposer ? "bottom-3" : "bottom-[140px]",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<ThreadScrollToBottom />
|
|
||||||
</ThreadPrimitive.ViewportFooter>
|
|
||||||
</AuiIf>
|
|
||||||
</ThreadPrimitive.Viewport>
|
|
||||||
|
|
||||||
{!hideComposer && (
|
|
||||||
<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
|
<div
|
||||||
aria-hidden
|
className={cn("shrink-0", hideComposer ? "h-16" : "h-40")}
|
||||||
className="absolute inset-x-0 bottom-0 top-[10px] bg-background"
|
aria-hidden={true}
|
||||||
/>
|
/>
|
||||||
<div className="relative px-5 pb-2">
|
</AuiIf>
|
||||||
<div className="pointer-events-auto mx-auto w-full max-w-(--thread-max-width)">
|
|
||||||
<ComposerAnimated />
|
<AuiIf condition={({ thread }) => !thread.isEmpty}>
|
||||||
|
<ThreadPrimitive.ViewportFooter
|
||||||
|
className={cn(
|
||||||
|
"aui-thread-viewport-footer pointer-events-none sticky z-20 flex w-full justify-center bg-transparent",
|
||||||
|
hideComposer ? "bottom-3" : "bottom-[140px]",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<ThreadScrollToBottom />
|
||||||
|
</ThreadPrimitive.ViewportFooter>
|
||||||
|
</AuiIf>
|
||||||
|
</ThreadPrimitive.Viewport>
|
||||||
|
|
||||||
|
{!hideComposer && (
|
||||||
|
<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={true}
|
||||||
|
className="absolute inset-x-0 bottom-0 top-[10px] bg-background"
|
||||||
|
/>
|
||||||
|
<div className="relative px-5 pb-2">
|
||||||
|
<div className="pointer-events-auto mx-auto w-full max-w-(--thread-max-width)">
|
||||||
|
<ComposerAnimated />
|
||||||
|
</div>
|
||||||
|
<p className="mt-1.5 text-center text-[11px] text-muted-foreground">
|
||||||
|
LLMs can make mistakes. Double-check all responses.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-1.5 text-center text-[11px] text-muted-foreground">
|
|
||||||
LLMs can make mistakes. Double-check all responses.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</AuiIf>
|
||||||
</AuiIf>
|
)}
|
||||||
)}
|
</ScrollToBottomProvider>
|
||||||
</ThreadPrimitive.Root>
|
</ThreadPrimitive.Root>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const ThreadScrollToBottom: FC = () => {
|
const ThreadScrollToBottom: FC = () => {
|
||||||
// Scoped to the nearest ThreadPrimitive.Root via context, so in compare
|
// State and action both come from our ScrollToBottomProvider (scoped
|
||||||
// mode each pane reads its own viewport state.
|
// per Thread, so compare panes are independent). We deliberately
|
||||||
//
|
// avoid `ThreadPrimitive.ScrollToBottom` + `useThreadViewport` to
|
||||||
// The button stays mounted and toggles visibility via CSS. Conditionally
|
// stay off assistant-ui's internal autoscroll path — see the hook
|
||||||
// rendering (return null) unmounts a DOM node inside the viewport, which
|
// for why. The button stays mounted and toggles via CSS; unmounting
|
||||||
// the assistant-ui autoscroll hook's MutationObserver sees as a content
|
// would trip the hook's MutationObserver as a content change.
|
||||||
// change — during streaming that triggered spurious scroll-to-bottom
|
const isAtBottom = useIsThreadAtBottom();
|
||||||
// calls, especially in the narrower mobile stacked layout.
|
const scrollToBottom = useScrollThreadToBottom();
|
||||||
const isAtBottom = useThreadViewport((vp) => vp.isAtBottom);
|
|
||||||
return (
|
return (
|
||||||
<ThreadPrimitive.ScrollToBottom asChild={true}>
|
<TooltipIconButton
|
||||||
<TooltipIconButton
|
tooltip="Scroll to bottom"
|
||||||
tooltip="Scroll to bottom"
|
variant="outline"
|
||||||
variant="outline"
|
onClick={() => scrollToBottom("auto")}
|
||||||
className={cn(
|
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",
|
"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",
|
isAtBottom && "invisible pointer-events-none",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<ArrowDownIcon />
|
<ArrowDownIcon />
|
||||||
</TooltipIconButton>
|
</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"],
|
"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"],
|
"Create a live weather dashboard in HTML using no API key. Show me the code":
|
||||||
"Solve the integral of x·sin(x), and verify it step by step": ["thinking", "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"],
|
"Draw an SVG of a cute sloth & show the code": ["thinking", "code", "search"],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -182,7 +206,7 @@ const toolIconMap = {
|
||||||
code: { icon: TerminalIcon, label: "Code" },
|
code: { icon: TerminalIcon, label: "Code" },
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
const SuggestionItem: FC = () => {
|
const _SuggestionItem: FC = () => {
|
||||||
const aui = useAui();
|
const aui = useAui();
|
||||||
const prompt = useAuiState(({ suggestion }) => suggestion.prompt);
|
const prompt = useAuiState(({ suggestion }) => suggestion.prompt);
|
||||||
const isDisabled = useAuiState(({ thread }) => thread.isDisabled);
|
const isDisabled = useAuiState(({ thread }) => thread.isDisabled);
|
||||||
|
|
@ -193,7 +217,7 @@ const SuggestionItem: FC = () => {
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (!isDisabled && !isRunning) {
|
if (!(isDisabled || isRunning)) {
|
||||||
const store = useChatRuntimeStore.getState();
|
const store = useChatRuntimeStore.getState();
|
||||||
if (store.supportsReasoning) {
|
if (store.supportsReasoning) {
|
||||||
store.setReasoningEnabled(tools.includes("thinking"));
|
store.setReasoningEnabled(tools.includes("thinking"));
|
||||||
|
|
@ -257,7 +281,9 @@ const ThreadWelcome: FC<{ hideComposer?: boolean }> = ({ hideComposer }) => {
|
||||||
|
|
||||||
const GeneratingSpinner: FC = () => {
|
const GeneratingSpinner: FC = () => {
|
||||||
const status = useChatRuntimeStore((s) => s.generatingStatus);
|
const status = useChatRuntimeStore((s) => s.generatingStatus);
|
||||||
if (!status) return null;
|
if (!status) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto flex w-full max-w-(--thread-max-width) items-center justify-center py-2">
|
<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">
|
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||||
|
|
@ -286,7 +312,9 @@ const ComposerAnimated: FC = () => {
|
||||||
const PendingAudioChip: FC = () => {
|
const PendingAudioChip: FC = () => {
|
||||||
const audioName = useChatRuntimeStore((s) => s.pendingAudioName);
|
const audioName = useChatRuntimeStore((s) => s.pendingAudioName);
|
||||||
const clearPendingAudio = useChatRuntimeStore((s) => s.clearPendingAudio);
|
const clearPendingAudio = useChatRuntimeStore((s) => s.clearPendingAudio);
|
||||||
if (!audioName) return null;
|
if (!audioName) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
return (
|
return (
|
||||||
<div className="mb-2 flex w-full flex-row items-center gap-2 px-1.5 pt-0.5 pb-1">
|
<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">
|
<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(
|
const handleAudioFile = useCallback(
|
||||||
async (file: File) => {
|
async (file: File) => {
|
||||||
if (file.size > MAX_AUDIO_SIZE) return;
|
if (file.size > MAX_AUDIO_SIZE) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
const base64 = await fileToBase64(file);
|
const base64 = await fileToBase64(file);
|
||||||
setPendingAudio(base64, file.name);
|
setPendingAudio(base64, file.name);
|
||||||
|
|
@ -347,7 +377,9 @@ const ComposerAudioUpload: FC = () => {
|
||||||
[setPendingAudio],
|
[setPendingAudio],
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!activeModel?.hasAudioInput) return null;
|
if (!activeModel?.hasAudioInput) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
|
@ -358,7 +390,9 @@ const ComposerAudioUpload: FC = () => {
|
||||||
className="hidden"
|
className="hidden"
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
const file = e.target.files?.[0];
|
const file = e.target.files?.[0];
|
||||||
if (file) handleAudioFile(file);
|
if (file) {
|
||||||
|
handleAudioFile(file);
|
||||||
|
}
|
||||||
e.target.value = "";
|
e.target.value = "";
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
@ -381,7 +415,9 @@ const ComposerAudioUpload: FC = () => {
|
||||||
function applyQwenThinkingParams(thinkingOn: boolean): void {
|
function applyQwenThinkingParams(thinkingOn: boolean): void {
|
||||||
const store = useChatRuntimeStore.getState();
|
const store = useChatRuntimeStore.getState();
|
||||||
const checkpoint = store.params.checkpoint?.toLowerCase() ?? "";
|
const checkpoint = store.params.checkpoint?.toLowerCase() ?? "";
|
||||||
if (!checkpoint.includes("qwen3")) return;
|
if (!checkpoint.includes("qwen3")) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
const params = thinkingOn
|
const params = thinkingOn
|
||||||
? { temperature: 0.6, topP: 0.95, topK: 20, minP: 0.0 }
|
? { temperature: 0.6, topP: 0.95, topK: 20, minP: 0.0 }
|
||||||
: { temperature: 0.7, topP: 0.8, 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 supportsReasoning = useChatRuntimeStore((s) => s.supportsReasoning);
|
||||||
const reasoningEnabled = useChatRuntimeStore((s) => s.reasoningEnabled);
|
const reasoningEnabled = useChatRuntimeStore((s) => s.reasoningEnabled);
|
||||||
const setReasoningEnabled = useChatRuntimeStore((s) => s.setReasoningEnabled);
|
const setReasoningEnabled = useChatRuntimeStore((s) => s.setReasoningEnabled);
|
||||||
const disabled = !modelLoaded || !supportsReasoning;
|
const disabled = !(modelLoaded && supportsReasoning);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
|
|
@ -433,7 +469,7 @@ const WebSearchToggle: FC = () => {
|
||||||
const supportsTools = useChatRuntimeStore((s) => s.supportsTools);
|
const supportsTools = useChatRuntimeStore((s) => s.supportsTools);
|
||||||
const toolsEnabled = useChatRuntimeStore((s) => s.toolsEnabled);
|
const toolsEnabled = useChatRuntimeStore((s) => s.toolsEnabled);
|
||||||
const setToolsEnabled = useChatRuntimeStore((s) => s.setToolsEnabled);
|
const setToolsEnabled = useChatRuntimeStore((s) => s.setToolsEnabled);
|
||||||
const disabled = !modelLoaded || !supportsTools;
|
const disabled = !(modelLoaded && supportsTools);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
|
|
@ -462,10 +498,8 @@ const CodeToolsToggle: FC = () => {
|
||||||
);
|
);
|
||||||
const supportsTools = useChatRuntimeStore((s) => s.supportsTools);
|
const supportsTools = useChatRuntimeStore((s) => s.supportsTools);
|
||||||
const codeToolsEnabled = useChatRuntimeStore((s) => s.codeToolsEnabled);
|
const codeToolsEnabled = useChatRuntimeStore((s) => s.codeToolsEnabled);
|
||||||
const setCodeToolsEnabled = useChatRuntimeStore(
|
const setCodeToolsEnabled = useChatRuntimeStore((s) => s.setCodeToolsEnabled);
|
||||||
(s) => s.setCodeToolsEnabled,
|
const disabled = !(modelLoaded && supportsTools);
|
||||||
);
|
|
||||||
const disabled = !modelLoaded || !supportsTools;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
|
|
@ -480,7 +514,9 @@ const CodeToolsToggle: FC = () => {
|
||||||
? "bg-primary/10 text-primary hover:bg-primary/20"
|
? "bg-primary/10 text-primary hover:bg-primary/20"
|
||||||
: "bg-muted text-muted-foreground hover:bg-muted-foreground/15",
|
: "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" />
|
<CodeToggleIcon className="size-3.5" />
|
||||||
<span>Code</span>
|
<span>Code</span>
|
||||||
|
|
@ -493,6 +529,11 @@ const ToolStatusDisplay: FC = () => {
|
||||||
const isThreadRunning = useAuiState(({ thread }) => thread.isRunning);
|
const isThreadRunning = useAuiState(({ thread }) => thread.isRunning);
|
||||||
const [elapsed, setElapsed] = useState(0);
|
const [elapsed, setElapsed] = useState(0);
|
||||||
const [visible, setVisible] = useState(false);
|
const [visible, setVisible] = useState(false);
|
||||||
|
const visibleRef = useRef(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
visibleRef.current = visible;
|
||||||
|
}, [visible]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!toolStatus) {
|
if (!toolStatus) {
|
||||||
|
|
@ -510,7 +551,7 @@ const ToolStatusDisplay: FC = () => {
|
||||||
// tools show immediately so the badge does not flicker. Fast
|
// tools show immediately so the badge does not flicker. Fast
|
||||||
// tool calls that all complete under 300ms never show the badge.
|
// tool calls that all complete under 300ms never show the badge.
|
||||||
let showTimer: ReturnType<typeof setTimeout> | undefined;
|
let showTimer: ReturnType<typeof setTimeout> | undefined;
|
||||||
if (!visible) {
|
if (!visibleRef.current) {
|
||||||
showTimer = setTimeout(() => setVisible(true), 300);
|
showTimer = setTimeout(() => setVisible(true), 300);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -519,11 +560,15 @@ const ToolStatusDisplay: FC = () => {
|
||||||
}, 1000);
|
}, 1000);
|
||||||
return () => {
|
return () => {
|
||||||
clearInterval(interval);
|
clearInterval(interval);
|
||||||
if (showTimer) clearTimeout(showTimer);
|
if (showTimer) {
|
||||||
|
clearTimeout(showTimer);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
}, [toolStatus, isThreadRunning]);
|
}, [toolStatus, isThreadRunning]);
|
||||||
|
|
||||||
if (!toolStatus || !visible) return null;
|
if (!(toolStatus && visible)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
const isRunning = toolStatus.startsWith("Running");
|
const isRunning = toolStatus.startsWith("Running");
|
||||||
const StatusIcon = isRunning ? TerminalIcon : GlobeIcon;
|
const StatusIcon = isRunning ? TerminalIcon : GlobeIcon;
|
||||||
return (
|
return (
|
||||||
|
|
@ -618,7 +663,9 @@ const GeneratingIndicator: FC = () => {
|
||||||
({ message }) =>
|
({ message }) =>
|
||||||
message.content.length === 0 && message.status?.type === "running",
|
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>;
|
return <span className="text-sm text-muted-foreground">Generating...</span>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -678,8 +725,7 @@ const DeleteMessageButton: FC = () => {
|
||||||
messageId,
|
messageId,
|
||||||
remoteId,
|
remoteId,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (_error) {
|
||||||
console.error("Failed to delete message", error);
|
|
||||||
toast.error("Failed to delete message");
|
toast.error("Failed to delete message");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
@ -705,7 +751,9 @@ const CopyButton: FC = () => {
|
||||||
const text = aui.message().getCopyText();
|
const text = aui.message().getCopyText();
|
||||||
if (await copyToClipboard(text)) {
|
if (await copyToClipboard(text)) {
|
||||||
setCopied(true);
|
setCopied(true);
|
||||||
if (resetTimeoutRef.current) clearTimeout(resetTimeoutRef.current);
|
if (resetTimeoutRef.current) {
|
||||||
|
clearTimeout(resetTimeoutRef.current);
|
||||||
|
}
|
||||||
resetTimeoutRef.current = setTimeout(() => {
|
resetTimeoutRef.current = setTimeout(() => {
|
||||||
setCopied(false);
|
setCopied(false);
|
||||||
resetTimeoutRef.current = null;
|
resetTimeoutRef.current = null;
|
||||||
|
|
@ -763,8 +811,12 @@ const AssistantActionBar: FC = () => {
|
||||||
};
|
};
|
||||||
|
|
||||||
const UserMessageAudio: FC = () => {
|
const UserMessageAudio: FC = () => {
|
||||||
const audioName = useAuiState(({ message }) => sentAudioNames.get(message.id));
|
const audioName = useAuiState(({ message }) =>
|
||||||
if (!audioName) return null;
|
sentAudioNames.get(message.id),
|
||||||
|
);
|
||||||
|
if (!audioName) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
return (
|
return (
|
||||||
<div className="col-start-2 flex justify-end">
|
<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">
|
<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 { cn } from "@/lib/utils";
|
||||||
import { GuidedTour, useGuidedTourController } from "@/features/tour";
|
import { GuidedTour, useGuidedTourController } from "@/features/tour";
|
||||||
import { useSidebar } from "@/components/ui/sidebar";
|
import { useSidebar } from "@/components/ui/sidebar";
|
||||||
import {
|
import { Settings05Icon } from "@hugeicons/core-free-icons";
|
||||||
Settings05Icon,
|
|
||||||
} from "@hugeicons/core-free-icons";
|
|
||||||
import { HugeiconsIcon } from "@hugeicons/react";
|
import { HugeiconsIcon } from "@hugeicons/react";
|
||||||
import {
|
import { Tooltip, TooltipContent } from "@/components/ui/tooltip";
|
||||||
Tooltip,
|
|
||||||
TooltipContent,
|
|
||||||
} from "@/components/ui/tooltip";
|
|
||||||
import { Tooltip as TooltipPrimitive } from "radix-ui";
|
import { Tooltip as TooltipPrimitive } from "radix-ui";
|
||||||
import { useNavigate, useSearch } from "@tanstack/react-router";
|
import { useNavigate, useSearch } from "@tanstack/react-router";
|
||||||
import {
|
import {
|
||||||
|
|
@ -533,8 +528,7 @@ export function ChatPage(): ReactElement {
|
||||||
if (search.compare) {
|
if (search.compare) {
|
||||||
return {
|
return {
|
||||||
mode: "compare",
|
mode: "compare",
|
||||||
pairId:
|
pairId: search.compare,
|
||||||
search.compare,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
if (search.thread) {
|
if (search.thread) {
|
||||||
|
|
@ -627,8 +621,14 @@ export function ChatPage(): ReactElement {
|
||||||
},
|
},
|
||||||
[modelSelectorLocked],
|
[modelSelectorLocked],
|
||||||
);
|
);
|
||||||
const openSettings = useCallback(() => setSettingsOpen(true), [setSettingsOpen]);
|
const openSettings = useCallback(
|
||||||
const closeSettings = useCallback(() => setSettingsOpen(false), [setSettingsOpen]);
|
() => setSettingsOpen(true),
|
||||||
|
[setSettingsOpen],
|
||||||
|
);
|
||||||
|
const closeSettings = useCallback(
|
||||||
|
() => setSettingsOpen(false),
|
||||||
|
[setSettingsOpen],
|
||||||
|
);
|
||||||
const { setPinned, isMobile } = useSidebar();
|
const { setPinned, isMobile } = useSidebar();
|
||||||
const openSidebar = useCallback(() => setPinned(true), [setPinned]);
|
const openSidebar = useCallback(() => setPinned(true), [setPinned]);
|
||||||
|
|
||||||
|
|
@ -655,7 +655,9 @@ export function ChatPage(): ReactElement {
|
||||||
.first()
|
.first()
|
||||||
.then((msg) => {
|
.then((msg) => {
|
||||||
const metadata = msg?.metadata as Record<string, unknown> | undefined;
|
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);
|
if (usage) useChatRuntimeStore.getState().setContextUsage(usage);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -834,7 +836,8 @@ export function ChatPage(): ReactElement {
|
||||||
className={cn(
|
className={cn(
|
||||||
"absolute top-0 left-0 right-[10px] z-30 flex h-[48px] shrink-0 items-start pt-[11px] pr-2 bg-background",
|
"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",
|
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">
|
<div className="flex items-center gap-1">
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue