From 7bc44536ea275a5b6798a99f648e4ded22e6246e Mon Sep 17 00:00:00 2001 From: wasimysaid Date: Sat, 30 May 2026 22:38:10 +0200 Subject: [PATCH] fix(chat): preserve compare state and align composer controls --- .../frontend/src/components/app-sidebar.tsx | 6 +- .../src/components/assistant-ui/thread.tsx | 59 ++++++-- .../frontend/src/features/chat/chat-page.tsx | 23 ++- .../src/features/chat/shared-composer.tsx | 138 ++++++++++++------ .../src/features/chat/utils/compare-id.ts | 6 +- studio/frontend/src/index.css | 9 ++ 6 files changed, 170 insertions(+), 71 deletions(-) diff --git a/studio/frontend/src/components/app-sidebar.tsx b/studio/frontend/src/components/app-sidebar.tsx index b25f3965b4..b33a597f76 100644 --- a/studio/frontend/src/components/app-sidebar.tsx +++ b/studio/frontend/src/components/app-sidebar.tsx @@ -644,7 +644,7 @@ export function AppSidebar() { @@ -754,7 +754,7 @@ export function AppSidebar() { @@ -835,7 +835,7 @@ export function AppSidebar() { = ({ 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<{ )} @@ -214,6 +216,7 @@ export const Thread: FC<{ )} @@ -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} />

@@ -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’s on your mind today? - {!hideComposer && } + {!hideComposer && ( + + )} @@ -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 (

- +
); @@ -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"} >
- + {composerExpanded ? ( <> {toolsEnabled ? : 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 ( @@ -1641,17 +1668,17 @@ const ComposerToolsMenu: FC<{ side?: "top" | "bottom" }> = ({ More - + - Canvas + Canvas (coming soon) startCompare()}> Compare chat - + - RAG + RAG (coming soon) diff --git a/studio/frontend/src/features/chat/chat-page.tsx b/studio/frontend/src/features/chat/chat-page.tsx index 8140f9514b..fb7a2eaeb1 100644 --- a/studio/frontend/src/features/chat/chat-page.tsx +++ b/studio/frontend/src/features/chat/chat-page.tsx @@ -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 (
- +
); @@ -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} /> ) : ( ([]); - const [pendingAudio, setPendingAudio] = useState<{ - name: string; - base64: string; - } | null>(null); + const [pendingAudio, setPendingAudio] = useState(null); const [dragging, setDragging] = useState(false); const [isComposing, setIsComposing] = useState(false); const textareaRef = useRef(null); @@ -414,6 +418,15 @@ export function SharedComposer({ const stuckImeTimerRef = useRef | null>(null); const fileInputRef = useRef(null); const audioInputRef = useRef(null); + const composerDraftRef = useRef({ + 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 - + - Canvas + Canvas (coming soon) {/* 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 - + - RAG + RAG (coming soon) diff --git a/studio/frontend/src/features/chat/utils/compare-id.ts b/studio/frontend/src/features/chat/utils/compare-id.ts index 993603c1d2..3632775603 100644 --- a/studio/frontend/src/features/chat/utils/compare-id.ts +++ b/studio/frontend/src/features/chat/utils/compare-id.ts @@ -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(); +} diff --git a/studio/frontend/src/index.css b/studio/frontend/src/index.css index 52cdbe705a..dd5cf0d6ce 100644 --- a/studio/frontend/src/index.css +++ b/studio/frontend/src/index.css @@ -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);