fix(chat): preserve compare state and align composer controls

This commit is contained in:
wasimysaid 2026-05-30 22:38:10 +02:00
commit 7bc44536ea
6 changed files with 170 additions and 71 deletions

View file

@ -644,7 +644,7 @@ export function AppSidebar() {
</DropdownMenuTrigger>
<DropdownMenuContent
side="bottom"
align="end"
align="start"
sideOffset={4}
className="app-user-menu menu-soft-surface menu-flat-destructive ring-0 w-44 py-2 font-heading rounded-[14px] border-0"
>
@ -754,7 +754,7 @@ export function AppSidebar() {
</DropdownMenuTrigger>
<DropdownMenuContent
side="bottom"
align="end"
align="start"
sideOffset={4}
className="app-user-menu menu-soft-surface menu-flat-destructive ring-0 w-44 py-2 font-heading rounded-[14px] border-0"
>
@ -835,7 +835,7 @@ export function AppSidebar() {
<DropdownMenuContent
side="top"
align="start"
className="app-user-menu menu-soft-surface-up ring-0 w-[15rem] py-2.5 font-heading rounded-[14px] border-0"
className="app-user-menu menu-soft-surface-up ring-0 py-2.5 font-heading rounded-[14px] border-0"
>
<DropdownMenuGroup>
<DropdownMenuItem

View file

@ -125,7 +125,8 @@ export const Thread: FC<{
hideComposer?: boolean;
hideWelcome?: boolean;
targetThreadId?: string;
}> = ({ hideComposer, hideWelcome, targetThreadId }) => {
onEnterCompare?: () => void;
}> = ({ hideComposer, hideWelcome, targetThreadId, onEnterCompare }) => {
// 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
@ -171,6 +172,7 @@ export const Thread: FC<{
<ThreadWelcome
hideComposer={hideComposer}
threadId={threadId}
onEnterCompare={onEnterCompare}
/>
</AuiIf>
)}
@ -214,6 +216,7 @@ export const Thread: FC<{
<ThreadComposerDock
disabled={isComposerAttachPending}
threadId={threadId}
onEnterCompare={onEnterCompare}
/>
</AuiIf>
)}
@ -318,7 +321,8 @@ const GeneratedImageViewportOverlay: FC<{ hideComposer?: boolean }> = ({
const ThreadComposerDock: FC<{
disabled?: boolean;
threadId?: string | null;
}> = ({ disabled, threadId }) => {
onEnterCompare?: () => void;
}> = ({ disabled, threadId, onEnterCompare }) => {
const { overlay } = useGeneratedImageOverlay();
return (
@ -338,6 +342,7 @@ const ThreadComposerDock: FC<{
disabled={disabled}
threadId={threadId}
menuSide="top"
onEnterCompare={onEnterCompare}
/>
</div>
<p className="composer-footer-note">
@ -383,7 +388,8 @@ function getWelcomeEmoji(): string {
const ThreadWelcome: FC<{
hideComposer?: boolean;
threadId?: string | null;
}> = ({ hideComposer, threadId }) => {
onEnterCompare?: () => void;
}> = ({ hideComposer, threadId, onEnterCompare }) => {
const [currentEmoji] = useState(getWelcomeEmoji);
const currentEmojiSrc =
@ -401,7 +407,12 @@ const ThreadWelcome: FC<{
What&rsquo;s on your mind today?
</h1>
</div>
{!hideComposer && <ComposerAnimated threadId={threadId} />}
{!hideComposer && (
<ComposerAnimated
threadId={threadId}
onEnterCompare={onEnterCompare}
/>
)}
</div>
</div>
</div>
@ -412,11 +423,17 @@ const ComposerAnimated: FC<{
disabled?: boolean;
threadId?: string | null;
menuSide?: "top" | "bottom";
}> = ({ disabled, threadId, menuSide }) => {
onEnterCompare?: () => void;
}> = ({ disabled, threadId, menuSide, onEnterCompare }) => {
return (
<div className="relative mx-auto min-w-0 w-full max-w-[660px]">
<div className="relative z-10 w-full">
<Composer disabled={disabled} threadId={threadId} menuSide={menuSide} />
<Composer
disabled={disabled}
threadId={threadId}
menuSide={menuSide}
onEnterCompare={onEnterCompare}
/>
</div>
</div>
);
@ -450,7 +467,8 @@ const Composer: FC<{
disabled?: boolean;
threadId?: string | null;
menuSide?: "top" | "bottom";
}> = ({ disabled, threadId, menuSide }) => {
onEnterCompare?: () => void;
}> = ({ disabled, threadId, menuSide, onEnterCompare }) => {
const aui = useAui();
const { overlay, closeOverlay } = useGeneratedImageOverlay();
const setImageToolsEnabled = useChatRuntimeStore(
@ -634,7 +652,10 @@ const Composer: FC<{
data-expanded={composerExpanded ? "true" : "false"}
>
<div className="unsloth-composer-left">
<ComposerToolsMenu side={effectiveMenuSide} />
<ComposerToolsMenu
side={effectiveMenuSide}
onEnterCompare={onEnterCompare}
/>
{composerExpanded ? (
<>
{toolsEnabled ? <WebSearchToggle /> : null}
@ -969,6 +990,7 @@ const ReasoningToggle: FC<{ side?: "top" | "bottom" }> = ({
{
isReasoningProvider:
selectedExternalProvider?.isReasoningModel === true,
baseUrl: selectedExternalProvider?.baseUrl ?? null,
},
)
: null;
@ -1453,9 +1475,10 @@ const PROJECTS_PR_URL = "https://github.com/unslothai/unsloth/pull/5725";
// Plus menu: attachment and workflow actions. Opens downward in the centered
// welcome composer; the docked composer passes side="top" to open upward.
const ComposerToolsMenu: FC<{ side?: "top" | "bottom" }> = ({
side = "bottom",
}) => {
const ComposerToolsMenu: FC<{
side?: "top" | "bottom";
onEnterCompare?: () => void;
}> = ({ side = "bottom", onEnterCompare }) => {
const navigate = useNavigate();
const setSettingsPanelOpen = useChatRuntimeStore(
(s) => s.setSettingsPanelOpen,
@ -1536,11 +1559,15 @@ const ComposerToolsMenu: FC<{ side?: "top" | "bottom" }> = ({
}, [codeMenuDisabled, codeToolsEnabled, setCodeToolsEnabled]);
const startCompare = useCallback(() => {
if (onEnterCompare) {
onEnterCompare();
return;
}
const store = useChatRuntimeStore.getState();
store.setActiveThreadId(null);
store.setContextUsage(null);
navigate({ to: "/chat", search: { compare: createCompareId() } });
}, [navigate]);
}, [navigate, onEnterCompare]);
return (
<DropdownMenu>
@ -1641,17 +1668,17 @@ const ComposerToolsMenu: FC<{ side?: "top" | "bottom" }> = ({
More
</DropdownMenuSubTrigger>
<DropdownMenuSubContent className="unsloth-plus-menu w-[200px]">
<DropdownMenuItem>
<DropdownMenuItem disabled={true}>
<HugeiconsIcon icon={PencilRulerIcon} strokeWidth={2} />
Canvas
Canvas (coming soon)
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => startCompare()}>
<Columns2Icon />
Compare chat
</DropdownMenuItem>
<DropdownMenuItem>
<DropdownMenuItem disabled={true}>
<HugeiconsIcon icon={DatabaseIcon} strokeWidth={2} />
RAG
RAG (coming soon)
</DropdownMenuItem>
</DropdownMenuSubContent>
</DropdownMenuSub>

View file

@ -159,7 +159,12 @@ function messageHasImage(message: MessageRecord): boolean {
const SingleContent = memo(function SingleContent({
threadId,
newThreadNonce,
}: { threadId?: string; newThreadNonce?: string }): ReactElement {
onEnterCompare,
}: {
threadId?: string;
newThreadNonce?: string;
onEnterCompare?: () => void;
}): ReactElement {
return (
<ChatRuntimeProvider
modelType="base"
@ -167,7 +172,11 @@ const SingleContent = memo(function SingleContent({
newThreadNonce={newThreadNonce}
>
<div className="flex min-h-0 min-w-0 flex-1 basis-0 flex-col overflow-hidden">
<Thread hideWelcome={Boolean(threadId)} targetThreadId={threadId} />
<Thread
hideWelcome={Boolean(threadId)}
targetThreadId={threadId}
onEnterCompare={onEnterCompare}
/>
</div>
</ChatRuntimeProvider>
);
@ -1204,7 +1213,14 @@ export function ChatPage(): ReactElement {
const openSidebar = useCallback(() => setPinned(true), [setPinned]);
const enterCompare = useCallback(() => {
setViewBeforeCompare({ ...search });
const saved: ChatSearch = { ...search };
const active = useChatRuntimeStore.getState().activeThreadId;
if (!(saved.thread || saved.new) && active) {
if (!active.startsWith("__LOCALID_")) {
saved.thread = active;
}
}
setViewBeforeCompare(saved);
useChatRuntimeStore.getState().setActiveThreadId(null);
useChatRuntimeStore.getState().setContextUsage(null);
navigate({ to: "/chat", search: { compare: createCompareId() } });
@ -1618,6 +1634,7 @@ export function ChatPage(): ReactElement {
key={view.threadId ?? "single"}
threadId={view.threadId}
newThreadNonce={view.newThreadNonce}
onEnterCompare={enterCompare}
/>
) : (
<CompareContent

View file

@ -1,15 +1,13 @@
// SPDX-License-Identifier: AGPL-3.0-only
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
import { TooltipIconButton } from "@/components/assistant-ui/tooltip-icon-button";
import { CodeToggleIcon } from "@/components/assistant-ui/code-toggle-icon";
import {
thinkEffortAriaLabel,
thinkToggleAriaLabel,
} from "@/components/assistant-ui/think-aria-label";
import { TooltipIconButton } from "@/components/assistant-ui/tooltip-icon-button";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import { openLink } from "@/lib/open-link";
import {
DropdownMenu,
DropdownMenuContent,
@ -22,11 +20,23 @@ import {
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { applyQwenThinkingParams } from "@/features/chat";
import { AUDIO_ACCEPT, MAX_AUDIO_SIZE, fileToBase64 } from "@/lib/audio-utils";
import { isTauri } from "@/lib/api-base";
import { isMultimodalResponse } from "./types/api";
import { getImageInputUnavailableReason } from "./utils/image-input-support";
import { AUDIO_ACCEPT, MAX_AUDIO_SIZE, fileToBase64 } from "@/lib/audio-utils";
import { openLink } from "@/lib/open-link";
import { toast } from "@/lib/toast";
import { cn } from "@/lib/utils";
import { useAui } from "@assistant-ui/react";
import {
AttachmentIcon,
CodeIcon,
DatabaseIcon,
File02Icon,
Folder01Icon,
Image03Icon,
PencilRulerIcon,
} from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
import { useNavigate } from "@tanstack/react-router";
import {
ArrowUpIcon,
CheckIcon,
@ -42,34 +52,6 @@ import {
SquareIcon,
XIcon,
} from "lucide-react";
import {
AttachmentIcon,
CodeIcon,
DatabaseIcon,
File02Icon,
Folder01Icon,
Image03Icon,
PencilRulerIcon,
} from "@hugeicons/core-free-icons";
import { useNavigate } from "@tanstack/react-router";
import { HugeiconsIcon } from "@hugeicons/react";
import { toast } from "@/lib/toast";
import { loadModel, validateModel } from "./api/chat-api";
import {
parseExternalModelId,
providerTypeSupportsVision,
} from "./external-providers";
import { useExternalProvidersStore } from "./stores/external-providers-store";
import {
type ReasoningEffort,
useChatRuntimeStore,
} from "./stores/chat-runtime-store";
import {
getExternalReasoningCapabilities,
providerSupportsBuiltinCodeExecution,
providerSupportsBuiltinImageGeneration,
providerSupportsBuiltinWebFetch,
} from "./provider-capabilities";
import {
type CompositionEvent,
type FC,
@ -85,6 +67,25 @@ import {
useRef,
useState,
} from "react";
import { loadModel, validateModel } from "./api/chat-api";
import {
parseExternalModelId,
providerTypeSupportsVision,
} from "./external-providers";
import {
getExternalReasoningCapabilities,
providerSupportsBuiltinCodeExecution,
providerSupportsBuiltinImageGeneration,
providerSupportsBuiltinWebFetch,
} from "./provider-capabilities";
import {
type ReasoningEffort,
useChatRuntimeStore,
} from "./stores/chat-runtime-store";
import { useExternalProvidersStore } from "./stores/external-providers-store";
import { isMultimodalResponse } from "./types/api";
import { createSafeId } from "./utils/compare-id";
import { getImageInputUnavailableReason } from "./utils/image-input-support";
// Projects is still in development; its menu entries link to the tracking PR.
const PROJECTS_PR_URL = "https://github.com/unslothai/unsloth/pull/5725";
@ -342,6 +343,12 @@ export function RegisterCompareHandle({
}
type PendingImage = { id: string; file: File };
type PendingAudio = { name: string; base64: string };
type ComposerDraft = {
text: string;
pendingImages: PendingImage[];
pendingAudio: PendingAudio | null;
};
function PendingImageThumb({
file,
@ -403,10 +410,7 @@ export function SharedComposer({
const [running, setRunning] = useState(false);
const [comparing, setComparing] = useState(false);
const [pendingImages, setPendingImages] = useState<PendingImage[]>([]);
const [pendingAudio, setPendingAudio] = useState<{
name: string;
base64: string;
} | null>(null);
const [pendingAudio, setPendingAudio] = useState<PendingAudio | null>(null);
const [dragging, setDragging] = useState(false);
const [isComposing, setIsComposing] = useState(false);
const textareaRef = useRef<HTMLTextAreaElement>(null);
@ -414,6 +418,15 @@ export function SharedComposer({
const stuckImeTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const audioInputRef = useRef<HTMLInputElement>(null);
const composerDraftRef = useRef<ComposerDraft>({
text: "",
pendingImages: [],
pendingAudio: null,
});
useEffect(() => {
composerDraftRef.current = { text, pendingImages, pendingAudio };
}, [text, pendingImages, pendingAudio]);
const activeModel = useChatRuntimeStore((s) => {
const checkpoint = s.params.checkpoint;
@ -702,7 +715,7 @@ export function SharedComposer({
droppedImageForUnavailable = true;
continue;
}
next.push({ id: crypto.randomUUID(), file });
next.push({ id: createSafeId(), file });
}
if (droppedImageForUnavailable && attachUnavailableReason) {
toast.error(attachUnavailableReason);
@ -785,6 +798,38 @@ export function SharedComposer({
return;
}
const draft: ComposerDraft = { text, pendingImages, pendingAudio };
const clearComposerDraft = () => {
composerDraftRef.current = {
text: "",
pendingImages: [],
pendingAudio: null,
};
setText("");
setPendingImages([]);
setPendingAudio(null);
clearPendingAudioStore();
textareaRef.current?.focus();
};
const restoreComposerDraft = () => {
const current = composerDraftRef.current;
const hasNewContent =
current.text.trim().length > 0 ||
current.pendingImages.length > 0 ||
current.pendingAudio !== null;
if (hasNewContent) return;
composerDraftRef.current = draft;
setText(draft.text);
setPendingImages(draft.pendingImages);
setPendingAudio(draft.pendingAudio);
if (draft.pendingAudio) {
setPendingAudioStore(
draft.pendingAudio.base64,
draft.pendingAudio.name,
);
}
};
const content: CompareMessagePart[] = [];
for (const { file } of pendingImages) {
try {
@ -802,11 +847,7 @@ export function SharedComposer({
}
if (content.length === 0) return;
setText("");
setPendingImages([]);
setPendingAudio(null);
clearPendingAudioStore();
textareaRef.current?.focus();
clearComposerDraft();
// Generalized compare: load each model before dispatching to its side
if (isGeneralizedCompare) {
@ -960,6 +1001,7 @@ export function SharedComposer({
toast.success("Compare complete", { id: toastId, duration: 2000 });
} catch (err) {
restoreComposerDraft();
toast.error("Compare failed", {
id: toastId,
description: err instanceof Error ? err.message : "Unknown error",
@ -1225,9 +1267,9 @@ export function SharedComposer({
More
</DropdownMenuSubTrigger>
<DropdownMenuSubContent className="unsloth-plus-menu w-[200px]">
<DropdownMenuItem>
<DropdownMenuItem disabled={true}>
<HugeiconsIcon icon={PencilRulerIcon} strokeWidth={2} />
Canvas
Canvas (coming soon)
</DropdownMenuItem>
{/* Always active: this menu only renders in compare mode.
Ticked like Web search/Code; click toggles it off. */}
@ -1239,9 +1281,9 @@ export function SharedComposer({
Compare chat
<CheckIcon className="ml-auto" />
</DropdownMenuItem>
<DropdownMenuItem>
<DropdownMenuItem disabled={true}>
<HugeiconsIcon icon={DatabaseIcon} strokeWidth={2} />
RAG
RAG (coming soon)
</DropdownMenuItem>
</DropdownMenuSubContent>
</DropdownMenuSub>

View file

@ -1,9 +1,13 @@
// SPDX-License-Identifier: AGPL-3.0-only
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
export function createCompareId(): string {
export function createSafeId(): string {
if (typeof globalThis.crypto?.randomUUID === "function") {
return globalThis.crypto.randomUUID();
}
return `${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
}
export function createCompareId(): string {
return createSafeId();
}

View file

@ -859,6 +859,15 @@
--menu-soft-shadow: rgba(0, 0, 0, 0.28);
}
.app-user-menu.menu-soft-surface,
.app-user-menu.menu-soft-surface-up {
box-shadow: 0 2px 8px -2px rgba(27, 27, 31, 0.16);
}
.dark .app-user-menu.menu-soft-surface,
.dark .app-user-menu.menu-soft-surface-up {
box-shadow: 0 3px 12px -6px rgba(0, 0, 0, 0.46);
}
.chat-composer-surface {
@apply relative flex w-full flex-col rounded-[32px] bg-white dark:bg-card px-3 py-3 outline-none transition-shadow;
font-family: var(--font-sans);