Compare commits

...
Sign in to create a new pull request.

20 commits

Author SHA1 Message Date
shimmyshimmer
fbc5b81b54 style(chat): fade composer dock edge and tune plus morph timing
The composer dock backdrop was a solid block with a hard top edge, so
chat text scrolling underneath got cut off abruptly. Replace it with a
gradient that stays solid behind the composer and fades to transparent
over the top 28px.

Also set the plus to x morph to 250ms; 200ms felt too abrupt and 300ms
too slow.
2026-06-03 20:38:47 -07:00
wasimysaid
b24a450f26 style(chat): restore composer active control states 2026-05-31 04:07:43 +02:00
wasimysaid
5d2b189068 fix(chat): gate compare menu by model support 2026-05-30 23:20:34 +02:00
wasimysaid
72eff32065 style(chat): polish composer and sidebar interactions 2026-05-30 23:00:41 +02:00
wasimysaid
7bc44536ea fix(chat): preserve compare state and align composer controls 2026-05-30 22:45:14 +02:00
wasimysaid
def605d5b6 fix(chat): harden composer tool and compare flows 2026-05-30 22:02:08 +02:00
wasimysaid
19436f589a style(chat): refine composer pills 2026-05-30 21:19:16 +02:00
wasimysaid
14384cd4e5 style(studio): polish chat navigation and composer 2026-05-30 21:02:49 +02:00
wasimysaid
65a9cf102a feat(sidebar): polish chat navigation 2026-05-30 20:22:14 +02:00
wasimysaid
b76d90d1b3 feat(chat): align compare composer 2026-05-30 20:22:14 +02:00
wasimysaid
f7b92e5f11 feat(chat): update single composer tools 2026-05-30 20:22:14 +02:00
wasimysaid
1b58287c07 style(studio): tune chat ui styles 2026-05-30 20:22:14 +02:00
shimmyshimmer
7d53d49f22 Studio: refine chat composer and add compare-mode parity
- Composer expands to two rows only once the input wraps to a second line,
  not on the first keystroke. Re-measure the autosize textarea on the width
  swap so expanding no longer leaves a stray blank row.
- Light-mode composer shadow now matches Gemini's soft elevation.
- Plus menu: replace Canvas with a More submenu (Canvas, Compare chat, RAG)
  and add Code above MCP. Active Web search/Code items use medium weight.
- Compare mode: the plus side menu, Search/Code toggles, and a Compare exit
  pill now match single chat, with the thinking control on the right.
- Projects menu entries link to their tracking PR (#5725).
- Add cursor-pointer to the composer plus button.
2026-05-30 07:28:58 -07:00
Michael Han
a879ca1d82
Merge branch 'main' into studio-composer 2026-05-30 05:11:22 -07:00
shimmyshimmer
fc23036470 Merge branch 'main' into studio-composer 2026-05-30 01:07:40 -07:00
shimmyshimmer
cbb92749a8 Studio: link MCP Servers heading to its PR and fix composer pill cursors
Make the "MCP Servers" heading in the chat Configuration sheet link to the
MCP PR, keeping the chevron as the toggle. The label and chevron are rendered
as siblings so we don't nest an <a> inside a <button>.

Also add cursor-pointer to the composer pills and the thinking pill so hovering
a clickable pill shows the hand cursor instead of the default arrow.
2026-05-30 01:06:08 -07:00
Unsloth
e56d69f528 studio: reflect pre-selected Search/Code tools when no model is loaded
The Search and Code pills only lit up when the tool was usable right now
(a model loaded and capable), so a tool turned on from the + menu showed
as off in the pill while the menu showed it on. toolsEnabled is persisted
and takes effect once a capable model loads, so the pill should reflect it.
The pills now disable only when a loaded model lacks the capability, and
otherwise reflect the selected state. Applied to the main and compare
composers.
2026-05-29 03:19:06 -07:00
Michael Han
518c7e9916
Merge branch 'main' into studio-composer 2026-05-29 02:56:53 -07:00
Unsloth
83b2dfd08e studio: sync compare-composer reasoning state and harden compare id
- Compare composer: keep "Preserve thinking" consistent with reasoning,
  matching the main composer. Enabling it now turns reasoning on, and
  disabling reasoning (the None option or the Thinking toggle) turns it
  off, so the invalid "preserve on while thinking off" state can't occur.
- Guard crypto.randomUUID in the Compare action. It is undefined in
  non-secure contexts (HTTP over a LAN IP) and would throw; fall back to
  a timestamped random id, matching createNavigationNonce.
2026-05-29 02:38:11 -07:00
Unsloth
cd67cb2223 studio: redesign chat composer
Reworks the new-chat composer and the compare composer into a single
rounded pill surface with a softer, lighter look.

- New welcome screen with a time-of-day sloth mascot and a lighter
  heading.
- One rounded composer surface with a soft drop shadow. The input grows
  inline as you type and collapses back to a single row when cleared.
- Tools and attachments live in a single plus menu; the thinking control
  is a compact pill with a reasoning-effort submenu.
- Inlined glyphs for the thinking, send, and dictate controls, kept in
  sync across the main and compare composers.
- Toast notifications match the composer surface: no border line, the
  same drop shadow, and the same dark surface color, with a ring-less
  close button.
- Dark mode: the side-menu shadow blends into the background, hovered
  menu rows read clearly, and their roundness matches light mode.
- Composer styles use dedicated unsloth- prefixed classes so compare
  mode keeps its own stacked layout.
2026-05-29 01:40:22 -07:00
13 changed files with 3800 additions and 2153 deletions

File diff suppressed because it is too large Load diff

View file

@ -10,7 +10,6 @@ import {
} from "@/components/ui/popover";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { usePlatformStore } from "@/config/env";
import { isCustomProviderType } from "@/features/chat/external-providers";
import { cn } from "@/lib/utils";
import {
ArrowDown01Icon,
@ -32,6 +31,22 @@ import type {
import { HubModelPicker, LoraModelPicker } from "./model-selector/pickers";
import { Input } from "../ui/input";
const CUSTOM_PROVIDER_TYPES_WITH_FALLBACK_LOGO = new Set([
"custom",
"llama_cpp",
"vllm",
"ollama",
]);
function isCustomProviderTypeWithFallbackLogo(
providerType: string | undefined,
): boolean {
return (
providerType !== undefined &&
CUSTOM_PROVIDER_TYPES_WITH_FALLBACK_LOGO.has(providerType)
);
}
const PROVIDER_LOGO_EXT: Record<string, "svg" | "png" | "jpg"> = {
openai: "svg",
mistral: "svg",
@ -64,7 +79,7 @@ function ExternalProviderLogo({
title?: string;
}) {
const src = providerLogoSrc(providerType);
if (!src && isCustomProviderType(providerType)) {
if (!src && isCustomProviderTypeWithFallbackLogo(providerType)) {
return (
<span title={title} aria-hidden={true} className="inline-flex shrink-0">
<HugeiconsIcon
@ -146,10 +161,11 @@ function ModelSelectorTrigger({
type="button"
data-tour={dataTour}
className={cn(
"flex min-w-0 items-center gap-2 transition-colors",
"flex min-w-0 cursor-pointer items-center gap-2 transition-colors",
variant === "outline" &&
"rounded-[10px] border border-border/60 hover:bg-[#ececec] dark:hover:bg-[#2d2e32]",
variant === "ghost" && "rounded-[10px] hover:bg-[#ececec] dark:hover:bg-[#2d2e32]",
"rounded-[10px] border border-border/60 hover:bg-[#ececec] dark:hover:bg-[#2d2e32]",
variant === "ghost" &&
"rounded-[10px] hover:bg-[#ececec] dark:hover:bg-[#2d2e32]",
variant === "muted" && "rounded-[10px] bg-muted hover:bg-muted/80",
size === "sm" && "h-8 px-3 text-xs",
size === "default" && "h-9 px-3.5 text-sm",
@ -161,7 +177,9 @@ function ModelSelectorTrigger({
<span className="size-2 shrink-0 rounded-full bg-emerald-500" />
)}
{currentModel?.icon ? (
<span className="flex shrink-0 items-center">{currentModel.icon}</span>
<span className="flex shrink-0 items-center">
{currentModel.icon}
</span>
) : null}
<span className="flex min-w-0 flex-1 items-baseline">
<span className="min-w-0 flex flex-1 items-baseline truncate font-heading text-[16px] font-medium leading-tight text-black dark:text-white">
@ -228,7 +246,10 @@ function ModelSelectorContent({
const chatOnly = usePlatformStore((s) => s.isChatOnly());
const hasExternal = externalModels.length > 0;
const chatOnlyTabsDefault = useMemo(
() => (value && externalModels.some((model) => model.id === value) ? "external" : "hub"),
() =>
value && externalModels.some((model) => model.id === value)
? "external"
: "hub",
[externalModels, value],
);
const studioTabsDefault = useMemo((): "hub" | "lora" | "external" => {
@ -246,7 +267,7 @@ function ModelSelectorContent({
align="start"
data-tour={dataTour}
className={cn(
"menu-soft-surface ring-0 w-[min(440px,calc(100vw-1rem))] max-w-[calc(100vw-1rem)] min-w-0 gap-0 p-2",
"model-selector-menu menu-soft-surface ring-0 w-[min(440px,calc(100vw-1rem))] max-w-[calc(100vw-1rem)] min-w-0 gap-0 p-2",
className,
)}
>
@ -258,7 +279,12 @@ function ModelSelectorContent({
<TabsTrigger value="external">Connected</TabsTrigger>
</TabsList>
<TabsContent value="hub" className="m-0">
<HubModelPicker models={models} value={value} onSelect={onSelect} onFoldersChange={onFoldersChange} />
<HubModelPicker
models={models}
value={value}
onSelect={onSelect}
onFoldersChange={onFoldersChange}
/>
</TabsContent>
<TabsContent value="external" className="m-0">
<ExternalModelPicker
@ -269,18 +295,30 @@ function ModelSelectorContent({
</TabsContent>
</Tabs>
) : (
<HubModelPicker models={models} value={value} onSelect={onSelect} onFoldersChange={onFoldersChange} />
<HubModelPicker
models={models}
value={value}
onSelect={onSelect}
onFoldersChange={onFoldersChange}
/>
)
) : (
<Tabs defaultValue={studioTabsDefault} className="w-full">
<TabsList className="mb-2 w-full">
<TabsTrigger value="hub">Hub models</TabsTrigger>
<TabsTrigger value="lora">Fine-tuned</TabsTrigger>
{hasExternal ? <TabsTrigger value="external">Connected</TabsTrigger> : null}
{hasExternal ? (
<TabsTrigger value="external">Connected</TabsTrigger>
) : null}
</TabsList>
<TabsContent value="hub" className="m-0">
<HubModelPicker models={models} value={value} onSelect={onSelect} onFoldersChange={onFoldersChange} />
<HubModelPicker
models={models}
value={value}
onSelect={onSelect}
onFoldersChange={onFoldersChange}
/>
</TabsContent>
<TabsContent value="lora" className="m-0">
@ -420,7 +458,9 @@ export function ModelSelector({
const found = optionById.get(selected);
if (activeGgufVariant) {
const desc = `GGUF · ${activeGgufVariant}`;
return found ? { ...found, description: desc } : { id: selected, name: selected, description: desc };
return found
? { ...found, description: desc }
: { id: selected, name: selected, description: desc };
}
return found ?? { id: selected, name: selected };
}, [selected, optionById, activeGgufVariant]);

File diff suppressed because it is too large Load diff

View file

@ -30,7 +30,7 @@ import { LayoutAlignLeftIcon } from "@hugeicons/core-free-icons"
const noop = () => {}
const SIDEBAR_WIDTH = "16rem"
const SIDEBAR_WIDTH = "18rem"
const SIDEBAR_WIDTH_ICON = "3rem"
const SIDEBAR_KEYBOARD_SHORTCUT = "b"
@ -305,9 +305,8 @@ function Sidebar({
data-sidebar="sidebar"
data-slot="sidebar-inner"
className={cn(
"bg-sidebar flex size-full flex-col overflow-hidden border-r border-sidebar-border",
"bg-sidebar flex size-full flex-col overflow-hidden",
"group-data-[variant=floating]:ring-sidebar-border group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:shadow-sm group-data-[variant=floating]:ring-1",
hasPinMode && "ring-1 ring-sidebar-border/60",
)}
>
{children}

View file

@ -63,7 +63,8 @@ const Toaster = ({ ...props }: ToasterProps) => {
{
"--normal-bg": "var(--popover)",
"--normal-text": "var(--popover-foreground)",
"--normal-border": "var(--border)",
// No border line; elevation comes from the composer's drop shadow.
"--normal-border": "transparent",
"--border-radius": "var(--radius)",
// Pin close button to the top-right corner inside the toast.
// Overrides sonner's default left placement and outside-corner

View file

@ -12,13 +12,15 @@ import {
import { Thread } from "@/components/assistant-ui/thread";
import { useSidebar } from "@/components/ui/sidebar";
import { Tooltip, TooltipContent } from "@/components/ui/tooltip";
import { NativeModelChip } from "@/features/native-intents/components/native-model-chip";
import { NativeModelDropOverlay } from "@/features/native-intents/components/native-model-drop-overlay";
import { useNativeIntentStore } from "@/features/native-intents/store";
import type { NativeIntent } from "@/features/native-intents/types";
import { useChooseNativeModel } from "@/features/native-intents/use-native-dialogs";
import { useNativeModelDrop } from "@/features/native-intents/use-native-drop";
import { useNativePathLeasesSupported } from "@/features/native-intents/use-native-readiness";
import {
NativeModelChip,
NativeModelDropOverlay,
type NativeIntent,
useChooseNativeModel,
useNativeIntentStore,
useNativeModelDrop,
useNativePathLeasesSupported,
} from "@/features/native-intents";
import { GuidedTour, useGuidedTourController } from "@/features/tour";
import { isTauri } from "@/lib/api-base";
import { cn } from "@/lib/utils";
@ -85,6 +87,7 @@ import {
listStoredChatMessages,
listStoredChatThreads,
} from "./utils/chat-history-storage";
import { createCompareId } from "./utils/compare-id";
type LoraCandidate = {
id: string;
@ -156,7 +159,14 @@ function messageHasImage(message: MessageRecord): boolean {
const SingleContent = memo(function SingleContent({
threadId,
newThreadNonce,
}: { threadId?: string; newThreadNonce?: string }): ReactElement {
canCompare,
onEnterCompare,
}: {
threadId?: string;
newThreadNonce?: string;
canCompare?: boolean;
onEnterCompare?: () => void;
}): ReactElement {
return (
<ChatRuntimeProvider
modelType="base"
@ -164,7 +174,12 @@ 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}
canCompare={canCompare}
onEnterCompare={onEnterCompare}
/>
</div>
</ChatRuntimeProvider>
);
@ -207,6 +222,7 @@ const CompareContent = memo(function CompareContent({
onFoldersChange,
onModelsChange,
deleteDisabled,
onExitCompare,
}: {
pairId: string;
models: ModelOption[];
@ -214,11 +230,12 @@ const CompareContent = memo(function CompareContent({
onFoldersChange?: () => void;
onModelsChange?: (deletedModel?: DeletedModelRef) => void;
deleteDisabled?: boolean;
onExitCompare?: () => void;
}): ReactElement {
const isLoraCompare = useIsLoraCompare();
return isLoraCompare ? (
<LoraCompareContent pairId={pairId} />
<LoraCompareContent pairId={pairId} onExitCompare={onExitCompare} />
) : (
<GeneralCompareContent
pairId={pairId}
@ -227,6 +244,7 @@ const CompareContent = memo(function CompareContent({
onFoldersChange={onFoldersChange}
onModelsChange={onModelsChange}
deleteDisabled={deleteDisabled}
onExitCompare={onExitCompare}
/>
);
});
@ -311,7 +329,7 @@ function CompareShell({
{children}
</div>
<div className="shrink-0 bg-background pl-5 pr-5 md:pr-[30px] pb-2 pt-1">
<div className="mx-auto w-full max-w-[48rem]">{composer}</div>
<div className="mx-auto w-full max-w-[660px]">{composer}</div>
<p className="composer-footer-note">
LLMs can make mistakes. Double-check responses.
</p>
@ -324,22 +342,25 @@ function CompareShell({
/** Fast path: same model, adapter on/off, simultaneous generation. */
const LoraCompareContent = memo(function LoraCompareContent({
pairId,
}: { pairId: string }): ReactElement {
onExitCompare,
}: { pairId: string; onExitCompare?: () => void }): ReactElement {
const handlesRef = useRef<Record<string, CompareHandle>>({});
const [baseThreadId, setBaseThreadId] = useState<string>();
const [loraThreadId, setLoraThreadId] = useState<string>();
useEffect(() => {
let isActive = true;
listStoredChatThreads({ pairId }).then((threads) => {
if (!isActive) return;
setBaseThreadId(threads.find((t) => t.modelType === "base")?.id);
setLoraThreadId(threads.find((t) => t.modelType === "lora")?.id);
}).catch((error) => {
if (!isExpectedBackgroundChatStorageError(error)) {
throw error;
}
});
listStoredChatThreads({ pairId })
.then((threads) => {
if (!isActive) return;
setBaseThreadId(threads.find((t) => t.modelType === "base")?.id);
setLoraThreadId(threads.find((t) => t.modelType === "lora")?.id);
})
.catch((error) => {
if (!isExpectedBackgroundChatStorageError(error)) {
throw error;
}
});
return () => {
isActive = false;
};
@ -348,7 +369,9 @@ const LoraCompareContent = memo(function LoraCompareContent({
return (
<CompareShell
handlesRef={handlesRef}
composer={<SharedComposer handlesRef={handlesRef} />}
composer={
<SharedComposer handlesRef={handlesRef} onExitCompare={onExitCompare} />
}
>
<>
<ComparePane
@ -441,6 +464,7 @@ const GeneralCompareContent = memo(function GeneralCompareContent({
onFoldersChange,
onModelsChange,
deleteDisabled,
onExitCompare,
}: {
pairId: string;
models: ModelOption[];
@ -448,6 +472,7 @@ const GeneralCompareContent = memo(function GeneralCompareContent({
onFoldersChange?: () => void;
onModelsChange?: (deletedModel?: DeletedModelRef) => void;
deleteDisabled?: boolean;
onExitCompare?: () => void;
}): ReactElement {
const handlesRef = useRef<Record<string, CompareHandle>>({});
const [model1ThreadId, setModel1ThreadId] = useState<string>();
@ -480,21 +505,25 @@ const GeneralCompareContent = memo(function GeneralCompareContent({
useEffect(() => {
let isActive = true;
listStoredChatThreads({ pairId }).then((threads) => {
if (!isActive) return;
setModel1ThreadId(
threads.find((t) => t.modelType === "model1" || t.modelType === "base")
?.id,
);
setModel2ThreadId(
threads.find((t) => t.modelType === "model2" || t.modelType === "lora")
?.id,
);
}).catch((error) => {
if (!isExpectedBackgroundChatStorageError(error)) {
throw error;
}
});
listStoredChatThreads({ pairId })
.then((threads) => {
if (!isActive) return;
setModel1ThreadId(
threads.find(
(t) => t.modelType === "model1" || t.modelType === "base",
)?.id,
);
setModel2ThreadId(
threads.find(
(t) => t.modelType === "model2" || t.modelType === "lora",
)?.id,
);
})
.catch((error) => {
if (!isExpectedBackgroundChatStorageError(error)) {
throw error;
}
});
return () => {
isActive = false;
};
@ -508,6 +537,7 @@ const GeneralCompareContent = memo(function GeneralCompareContent({
handlesRef={handlesRef}
model1={model1}
model2={model2}
onExitCompare={onExitCompare}
/>
}
>
@ -617,7 +647,9 @@ export function ChatPage(): ReactElement {
const [modelSelectorOpen, setModelSelectorOpen] = useState(false);
const [modelSelectorLocked, setModelSelectorLocked] = useState(false);
const viewBeforeCompareRef = useRef<ChatSearch | null>(null);
const [viewBeforeCompare, setViewBeforeCompare] = useState<ChatSearch | null>(
null,
);
const inferenceParams = useChatRuntimeStore((state) => state.params);
const setInferenceParams = useChatRuntimeStore((state) => state.setParams);
const activeGgufVariant = useChatRuntimeStore(
@ -647,8 +679,7 @@ export function ChatPage(): ReactElement {
} = useChatModelRuntime();
const prevConnectionsEnabledRef = useRef(connectionsEnabled);
useEffect(() => {
const turnedOff =
prevConnectionsEnabledRef.current && !connectionsEnabled;
const turnedOff = prevConnectionsEnabledRef.current && !connectionsEnabled;
if (!connectionsEnabled && isExternalModelId(inferenceParams.checkpoint)) {
clearCheckpoint();
if (turnedOff) {
@ -658,11 +689,7 @@ export function ChatPage(): ReactElement {
}
}
prevConnectionsEnabledRef.current = connectionsEnabled;
}, [
clearCheckpoint,
connectionsEnabled,
inferenceParams.checkpoint,
]);
}, [clearCheckpoint, connectionsEnabled, inferenceParams.checkpoint]);
const pendingNativeModelIntent = useNativeIntentStore(
(state) => state.pendingModelIntent,
);
@ -681,17 +708,19 @@ export function ChatPage(): ReactElement {
const reasoningEnabled = useChatRuntimeStore((s) => s.reasoningEnabled);
const reasoningStyle = useChatRuntimeStore((s) => s.reasoningStyle);
const reasoningEffort = useChatRuntimeStore((s) => s.reasoningEffort);
const supportsReasoningOff = useChatRuntimeStore((s) => s.supportsReasoningOff);
const supportsReasoningOff = useChatRuntimeStore(
(s) => s.supportsReasoningOff,
);
const activeExternalProvider = useMemo(() => {
const selection = parseExternalModelId(inferenceParams.checkpoint);
if (!selection) return null;
return (
externalProvidersForChat.find(
(p) => p.id === selection.providerId,
) ?? null
externalProvidersForChat.find((p) => p.id === selection.providerId) ??
null
);
}, [externalProvidersForChat, inferenceParams.checkpoint]);
const activeExternalProviderType = activeExternalProvider?.providerType ?? null;
const activeExternalProviderType =
activeExternalProvider?.providerType ?? null;
const activeProviderCapabilities = useMemo(() => {
const selection = parseExternalModelId(inferenceParams.checkpoint);
if (!selection) return null;
@ -807,7 +836,9 @@ export function ChatPage(): ReactElement {
(provider?.providerType === "anthropic" ||
provider?.providerType === "openai");
const storedToolsEnabled = loadOptionalBool(CHAT_TOOLS_ENABLED_KEY);
const storedCodeToolsEnabled = loadOptionalBool(CHAT_CODE_TOOLS_ENABLED_KEY);
const storedCodeToolsEnabled = loadOptionalBool(
CHAT_CODE_TOOLS_ENABLED_KEY,
);
const storedImageToolsEnabled = loadOptionalBool(
CHAT_IMAGE_TOOLS_ENABLED_KEY,
);
@ -971,8 +1002,7 @@ export function ChatPage(): ReactElement {
selectedProvider?.providerType,
selectedExternal?.modelId,
{
isReasoningProvider:
selectedProvider?.isReasoningModel === true,
isReasoningProvider: selectedProvider?.isReasoningModel === true,
baseUrl: selectedProvider?.baseUrl ?? null,
},
);
@ -1018,11 +1048,12 @@ export function ChatPage(): ReactElement {
selectedExternal?.modelId,
selectedProvider?.baseUrl,
);
const supportsBuiltinCodeExecution = providerSupportsBuiltinCodeExecution(
selectedProvider?.providerType,
selectedExternal?.modelId,
selectedProvider?.baseUrl,
);
const supportsBuiltinCodeExecution =
providerSupportsBuiltinCodeExecution(
selectedProvider?.providerType,
selectedExternal?.modelId,
selectedProvider?.baseUrl,
);
const supportsBuiltinImageGeneration =
providerSupportsBuiltinImageGeneration(
selectedProvider?.providerType,
@ -1185,16 +1216,27 @@ export function ChatPage(): ReactElement {
const openSidebar = useCallback(() => setPinned(true), [setPinned]);
const enterCompare = useCallback(() => {
viewBeforeCompareRef.current = { ...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: crypto.randomUUID() } });
navigate({ to: "/chat", search: { compare: createCompareId() } });
}, [navigate, search]);
const exitCompare = useCallback(() => {
const saved = viewBeforeCompareRef.current;
if (!saved) return;
viewBeforeCompareRef.current = null;
const saved = viewBeforeCompare;
// No saved view (compare opened by direct URL); fall back to a fresh chat.
if (!saved) {
navigate({ to: "/chat" });
return;
}
setViewBeforeCompare(null);
navigate({ to: "/chat", search: saved });
// Restore usage from the last assistant message, but only if it
// matches the currently active checkpoint. Without this guard the
@ -1215,8 +1257,7 @@ export function ChatPage(): ReactElement {
if (!usage) return;
const store = useChatRuntimeStore.getState();
const activeCheckpoint = store.params.checkpoint;
const usageModelId =
(usage as { modelId?: unknown }).modelId;
const usageModelId = (usage as { modelId?: unknown }).modelId;
// Scope by modelId when present; reject if no active checkpoint
// (model-scoped usage cannot be attributed to "nothing").
if (typeof usageModelId === "string" && usageModelId) {
@ -1242,7 +1283,7 @@ export function ChatPage(): ReactElement {
}
});
}
}, [navigate]);
}, [navigate, viewBeforeCompare]);
const models = useMemo<ModelOption[]>(
() =>
@ -1398,7 +1439,7 @@ export function ChatPage(): ReactElement {
if (canceled) return;
useChatRuntimeStore.getState().setActiveThreadId(null);
useChatRuntimeStore.getState().setContextUsage(null);
navigate({ to: "/chat", search: { compare: crypto.randomUUID() } });
navigate({ to: "/chat", search: { compare: createCompareId() } });
clearHandoff();
console.info("[chat-handoff] loaded lora + opened compare");
return;
@ -1596,6 +1637,8 @@ export function ChatPage(): ReactElement {
key={view.threadId ?? "single"}
threadId={view.threadId}
newThreadNonce={view.newThreadNonce}
canCompare={canCompare}
onEnterCompare={enterCompare}
/>
) : (
<CompareContent
@ -1606,6 +1649,7 @@ export function ChatPage(): ReactElement {
onFoldersChange={refreshLocalModels}
onModelsChange={refreshModelLists}
deleteDisabled={modelOperationInProgress}
onExitCompare={exitCompare}
/>
)}
</div>

View file

@ -58,7 +58,7 @@ import {
LayoutAlignRightIcon,
} from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
import { ChevronDown } from "lucide-react";
import { ChevronDown, ExternalLink } from "lucide-react";
import { Tooltip as TooltipPrimitive } from "radix-ui";
import { Fragment, type ReactNode } from "react";
import { useEffect, useMemo, useRef, useState } from "react";
@ -327,11 +327,14 @@ function saveCollapsibleOpen(label: string, open: boolean) {
function CollapsibleSection({
label,
labelHref,
children,
defaultOpen = false,
first = false,
}: {
label: string;
/** Optional link for the label (e.g. its PR); chevron still toggles. */
labelHref?: string;
children?: ReactNode;
defaultOpen?: boolean;
first?: boolean;
@ -341,6 +344,17 @@ function CollapsibleSection({
return Object.hasOwn(saved, label) ? saved[label] : defaultOpen;
});
const toggle = () => {
const next = !open;
setOpen(next);
saveCollapsibleOpen(label, next);
};
const headerClasses = cn(
"flex w-full items-center justify-between text-[12px] font-medium normal-case tracking-[0.04em] text-nav-fg-muted transition-colors focus-visible:outline-none focus-visible:ring-0",
first ? "pt-4 pb-5" : "py-5",
);
return (
<div
className={cn(
@ -348,25 +362,42 @@ function CollapsibleSection({
"border-t border-black/[0.13] dark:border-white/[0.09]",
)}
>
<button
type="button"
onClick={() => {
const next = !open;
setOpen(next);
saveCollapsibleOpen(label, next);
}}
className={cn(
"flex w-full cursor-pointer items-center justify-between text-[12px] font-medium normal-case tracking-[0.04em] text-nav-fg-muted transition-colors hover:text-nav-fg focus-visible:outline-none focus-visible:ring-0",
first ? "pt-4 pb-5" : "py-5",
)}
>
<span className="leading-none">{label}</span>
<span className="flex shrink-0 items-center leading-none">
<ChevronDown
className={cn("size-3.5", open ? "rotate-0" : "-rotate-90")}
/>
</span>
</button>
{labelHref ? (
<div className={headerClasses}>
<a
href={labelHref}
target="_blank"
rel="noopener noreferrer"
className="inline-flex cursor-pointer items-center gap-1 leading-none transition-colors hover:text-nav-fg"
>
<span>{label}</span>
<ExternalLink className="size-3" />
</a>
<button
type="button"
onClick={toggle}
aria-label={open ? `Collapse ${label}` : `Expand ${label}`}
className="flex shrink-0 cursor-pointer items-center leading-none transition-colors hover:text-nav-fg"
>
<ChevronDown
className={cn("size-3.5", open ? "rotate-0" : "-rotate-90")}
/>
</button>
</div>
) : (
<button
type="button"
onClick={toggle}
className={cn("cursor-pointer hover:text-nav-fg", headerClasses)}
>
<span className="leading-none">{label}</span>
<span className="flex shrink-0 items-center leading-none">
<ChevronDown
className={cn("size-3.5", open ? "rotate-0" : "-rotate-90")}
/>
</span>
</button>
)}
{open && <div className="pb-7">{children}</div>}
</div>
);
@ -1345,7 +1376,10 @@ export function ChatSettingsPanel({
) : null}
{!isExternalModel ? (
<CollapsibleSection label="MCP Servers">
<CollapsibleSection
label="MCP Servers"
labelHref="https://github.com/unslothai/unsloth/pull/5852"
>
<McpServersSection />
</CollapsibleSection>
) : null}

View file

@ -88,7 +88,7 @@ export function ModelLoadDescription({
size="xs"
variant="ghost"
aria-label="Stop model loading"
className="h-auto self-stretch shrink-0 !rounded-none !border-0 bg-transparent px-1 text-[10px] text-muted-foreground hover:bg-transparent hover:text-destructive focus-visible:text-destructive"
className="h-auto self-stretch shrink-0 !rounded-none !border-0 bg-transparent px-1 text-[10px] text-muted-foreground hover:!bg-transparent dark:hover:!bg-transparent hover:text-destructive focus-visible:text-destructive"
onClick={onStop}
>
Cancel

View file

@ -2,6 +2,13 @@
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
export { ChatPage } from "./chat-page";
export { sentAudioNames } from "./api/chat-adapter";
export { parseExternalModelId } from "./external-providers";
export { getExternalReasoningCapabilities } from "./provider-capabilities";
export { useExternalProvidersStore } from "./stores/external-providers-store";
export { deleteThreadMessage } from "./utils/delete-thread-message";
export { createCompareId } from "./utils/compare-id";
export { applyQwenThinkingParams } from "./utils/qwen-params";
export {
getInferenceStatus,
listGgufVariants,

File diff suppressed because it is too large Load diff

View file

@ -0,0 +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 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

@ -0,0 +1,8 @@
export { NativeModelChip } from "./components/native-model-chip";
export { NativeModelDropOverlay } from "./components/native-model-drop-overlay";
export { useNativeIntentStore } from "./store";
export type { NativeIntent } from "./types";
export { useChooseNativeModel } from "./use-native-dialogs";
export { useNativeModelDrop } from "./use-native-drop";
export type { NativeModelDropState } from "./use-native-drop";
export { useNativePathLeasesSupported } from "./use-native-readiness";

File diff suppressed because it is too large Load diff