Studio chat: declutter the plus menu, session-only pre-selectable RAG (#6140)

This commit is contained in:
Daniel Han 2026-06-10 05:15:12 -07:00 committed by GitHub
commit e4b5bec248
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 190 additions and 183 deletions

View file

@ -64,7 +64,7 @@ import { NewProjectDialog } from "@/features/chat/components/new-project-dialog"
import { parseExternalModelId } from "@/features/chat/external-providers";
import { McpComposerButton } from "@/features/chat/mcp-composer-button";
import { getExternalReasoningCapabilities } from "@/features/chat/provider-capabilities";
import { useRagToolAvailable } from "@/features/chat/hooks/use-rag-tool-available";
import { useRagToolDisabled } from "@/features/chat/hooks/use-rag-tool-disabled";
import { useChatRuntimeStore } from "@/features/chat/stores/chat-runtime-store";
import { useExternalProvidersStore } from "@/features/chat/stores/external-providers-store";
import { deleteThreadMessage } from "@/features/chat/utils/delete-thread-message";
@ -876,12 +876,11 @@ const Composer: FC<{
const artifactsEnabled = useChatRuntimeStore((s) => s.artifactsEnabled);
const mcpEnabledForChat = useChatRuntimeStore((s) => s.mcpEnabledForChat);
const ragEnabled = useChatRuntimeStore((s) => s.ragEnabled);
const ragToolAvailable = useRagToolAvailable();
// More than 4 pills: collapse to icons only. Search and Code always show;
// Images, Canvas and MCP are conditional.
// Images, RAG, Canvas and MCP are conditional.
const pillsCompact =
2 +
(ragEnabled && ragToolAvailable ? 1 : 0) +
(ragEnabled ? 1 : 0) +
(supportsBuiltinImageGeneration ? 1 : 0) +
(artifactsEnabled ? 1 : 0) +
(mcpEnabledForChat ? 1 : 0) >
@ -2017,8 +2016,8 @@ const ComposerToolsMenu: FC<{ side?: "top" | "bottom" }> = ({
);
const ragEnabled = useChatRuntimeStore((s) => s.ragEnabled);
const setRagEnabled = useChatRuntimeStore((s) => s.setRagEnabled);
// Shared gate so the menu row agrees with the RAG pill and Add Files bar.
const ragAvailable = useRagToolAvailable();
// Shared gate so the menu row agrees with the RAG pill.
const ragDisabled = useRagToolDisabled();
// Capability gating mirrors the visible pills so menu and pills agree on
// what a loaded model supports (a tool the backend drops must not look on).
const modelLoaded = useChatRuntimeStore(
@ -2180,10 +2179,11 @@ const ComposerToolsMenu: FC<{ side?: "top" | "bottom" }> = ({
}
onSelect={() => setCodeToolsEnabled(!codeToolsEnabled)}
>
{/* Scale, not width: an oversized box pushed the label out of line. */}
<HugeiconsIcon
icon={CodeIcon}
strokeWidth={2}
className="size-[1.175rem]!"
className="scale-[1.12]"
/>
Code
{codeToolsEnabled && !codeDisabled ? (
@ -2217,15 +2217,15 @@ const ComposerToolsMenu: FC<{ side?: "top" | "bottom" }> = ({
)}
<DropdownMenuSeparator />
<DropdownMenuItem
disabled={!ragAvailable}
disabled={ragDisabled}
className={
ragEnabled && ragAvailable ? "text-primary font-medium" : undefined
ragEnabled && !ragDisabled ? "text-primary font-medium" : undefined
}
onSelect={() => setRagEnabled(!ragEnabled)}
>
<HugeiconsIcon icon={FileDatabaseIcon} strokeWidth={2} />
RAG
{ragEnabled && ragAvailable ? (
Chat with Files
{ragEnabled && !ragDisabled ? (
<HugeiconsIcon
icon={Tick02Icon}
strokeWidth={2}
@ -2252,80 +2252,82 @@ const ComposerToolsMenu: FC<{ side?: "top" | "bottom" }> = ({
/>
) : null}
</DropdownMenuItem>
{/* Top-level so it stays one click away (not buried in More). */}
<DropdownMenuItem onSelect={() => startCompare()}>
<Columns2Icon />
Compare chat
</DropdownMenuItem>
<DropdownMenuSub>
<DropdownMenuSubTrigger>
<HugeiconsIcon icon={Bookmark02Icon} strokeWidth={2} />
Saved prompts
</DropdownMenuSubTrigger>
<DropdownMenuSubContent className="unsloth-plus-menu w-[176px]">
{recentPrompts.map((p) => (
<DropdownMenuItem
key={p.id}
onSelect={() => aui.composer().setText(p.text)}
>
<span className="truncate">{p.name}</span>
</DropdownMenuItem>
))}
{recentPrompts.length > 0 ? <DropdownMenuSeparator /> : null}
<DropdownMenuItem onSelect={() => setPromptStorageOpen(true)}>
All saved prompts
</DropdownMenuItem>
</DropdownMenuSubContent>
</DropdownMenuSub>
{/* Top-level: a third-level submenu collision-flips at narrow widths
and is awkward to reach. */}
<DropdownMenuSub>
<DropdownMenuSubTrigger disabled={!activeThreadId || messageCount === 0}>
<HugeiconsIcon icon={Download01Icon} strokeWidth={2} />
Export chat
</DropdownMenuSubTrigger>
<DropdownMenuSubContent
collisionPadding={16}
className="unsloth-plus-menu w-[176px]"
>
<DropdownMenuItem
onSelect={() => {
if (!activeThreadId) return;
exportConversationRawJsonl(activeThreadId).catch(() =>
toast.error("Export failed."),
);
}}
>
Raw JSONL
</DropdownMenuItem>
<DropdownMenuItem
onSelect={() => {
if (!activeThreadId) return;
exportConversationCsv(activeThreadId).catch(() =>
toast.error("Export failed."),
);
}}
>
CSV
</DropdownMenuItem>
<DropdownMenuItem
onSelect={() => {
if (!activeThreadId) return;
exportConversationShareGPT(activeThreadId).catch(() =>
toast.error("Export failed."),
);
}}
>
ShareGPT JSONL
</DropdownMenuItem>
</DropdownMenuSubContent>
</DropdownMenuSub>
<DropdownMenuSub>
<DropdownMenuSubTrigger>
<MoreHorizontalIcon className="size-4" />
More
</DropdownMenuSubTrigger>
<DropdownMenuSubContent className="w-[200px]">
<DropdownMenuItem onSelect={() => startCompare()}>
<Columns2Icon />
Compare chat
</DropdownMenuItem>
<DropdownMenuSub>
<DropdownMenuSubTrigger>
<HugeiconsIcon icon={Bookmark02Icon} strokeWidth={2} />
Saved prompts
</DropdownMenuSubTrigger>
<DropdownMenuSubContent
collisionPadding={16}
className="unsloth-plus-menu w-[176px]"
>
{recentPrompts.map((p) => (
<DropdownMenuItem
key={p.id}
onSelect={() => aui.composer().setText(p.text)}
>
<span className="truncate">{p.name}</span>
</DropdownMenuItem>
))}
{recentPrompts.length > 0 ? <DropdownMenuSeparator /> : null}
<DropdownMenuItem onSelect={() => setPromptStorageOpen(true)}>
All saved prompts
</DropdownMenuItem>
</DropdownMenuSubContent>
</DropdownMenuSub>
<DropdownMenuSub>
<DropdownMenuSubTrigger
disabled={!activeThreadId || messageCount === 0}
>
<HugeiconsIcon icon={Download01Icon} strokeWidth={2} />
Export chat
</DropdownMenuSubTrigger>
<DropdownMenuSubContent
collisionPadding={16}
className="unsloth-plus-menu w-[176px]"
>
<DropdownMenuItem
onSelect={() => {
if (!activeThreadId) return;
exportConversationRawJsonl(activeThreadId).catch(() =>
toast.error("Export failed."),
);
}}
>
Raw JSONL
</DropdownMenuItem>
<DropdownMenuItem
onSelect={() => {
if (!activeThreadId) return;
exportConversationCsv(activeThreadId).catch(() =>
toast.error("Export failed."),
);
}}
>
CSV
</DropdownMenuItem>
<DropdownMenuItem
onSelect={() => {
if (!activeThreadId) return;
exportConversationShareGPT(activeThreadId).catch(() =>
toast.error("Export failed."),
);
}}
>
ShareGPT JSONL
</DropdownMenuItem>
</DropdownMenuSubContent>
</DropdownMenuSub>
<DropdownMenuItem
className={
artifactsEnabled ? "text-primary font-medium" : undefined

View file

@ -4,15 +4,16 @@
import { parseExternalModelId } from "../external-providers";
import { useChatRuntimeStore } from "../stores/chat-runtime-store";
// Single source of truth for the RAG pill's disabled gate and the Add Files bar's
// visibility so the bar never shows while the pill is inert.
export function useRagToolAvailable(): boolean {
// Pre-select gate for the RAG toggle, mirroring Web search/Code/MCP: armable
// with no model; disabled only when a loaded model can't run
// search_knowledge_base. The send path checks supportsTools independently.
export function useRagToolDisabled(): boolean {
const modelLoaded = useChatRuntimeStore(
(s) => !!s.params.checkpoint && !s.modelLoading,
);
const checkpoint = useChatRuntimeStore((s) => s.params.checkpoint);
const supportsTools = useChatRuntimeStore((s) => s.supportsTools);
return (
modelLoaded && parseExternalModelId(checkpoint) === null && supportsTools
modelLoaded && (parseExternalModelId(checkpoint) !== null || !supportsTools)
);
}

View file

@ -656,8 +656,10 @@ export function SharedComposer({
// Fetch pill: Anthropic-only (web_fetch_20250910 / web_fetch_20260209).
const webFetchDisabled = !modelLoaded || !supportsBuiltinWebFetch;
const showWebFetchPill = supportsBuiltinWebFetch;
// Docs (RAG) pill is local-only: search_knowledge_base needs the local runtime.
const ragDisabled = !modelLoaded || isExternalModel || !supportsTools;
// Docs (RAG) is local-only: search_knowledge_base needs the local runtime.
// Disable only when a loaded model can't run it; with no model the toggle
// can still be pre-selected, matching Web search/Code/MCP.
const ragDisabled = modelLoaded && (isExternalModel || !supportsTools);
const showRagPill = !isExternalModel;
// Above 4 pills, collapse to icons only to cut clutter. Compare, Search and
// Code always show; the rest are conditional.
@ -1323,10 +1325,12 @@ export function SharedComposer({
}
onSelect={() => setCodeToolsEnabled(!codeToolsEnabled)}
>
{/* Scale, not width: an oversized box pushed the label out of
line. */}
<HugeiconsIcon
icon={CodeIcon}
strokeWidth={2}
className="size-[1.175rem]!"
className="scale-[1.12]"
/>
Code
{codeToolsEnabled && !codeDisabled ? (
@ -1369,7 +1373,7 @@ export function SharedComposer({
onSelect={() => setRagEnabled(!ragEnabled)}
>
<HugeiconsIcon icon={FileDatabaseIcon} strokeWidth={2} />
RAG
Chat with Files
{ragEnabled && !ragDisabled ? (
<HugeiconsIcon
icon={Tick02Icon}
@ -1396,88 +1400,95 @@ export function SharedComposer({
) : null}
</DropdownMenuItem>
{/* RAG hidden temporarily */}
{/* Always active: this menu only renders in compare mode. Ticked
like Web search/Code; click toggles it off. */}
<DropdownMenuItem
className="text-primary font-medium"
onSelect={handleExitCompare}
>
<Columns2Icon />
Compare chat
<HugeiconsIcon
icon={Tick02Icon}
strokeWidth={2}
className="ml-auto"
/>
</DropdownMenuItem>
<DropdownMenuSub>
<DropdownMenuSubTrigger>
<HugeiconsIcon icon={Bookmark02Icon} strokeWidth={2} />
Saved prompts
</DropdownMenuSubTrigger>
<DropdownMenuSubContent className="unsloth-plus-menu w-[176px]">
{recentPrompts.map((p) => (
<DropdownMenuItem
key={p.id}
onSelect={() => {
setText(p.text);
requestAnimationFrame(() =>
textareaRef.current?.focus(),
);
}}
>
<span className="truncate">{p.name}</span>
</DropdownMenuItem>
))}
{recentPrompts.length > 0 ? <DropdownMenuSeparator /> : null}
<DropdownMenuItem onSelect={() => setPromptStorageOpen(true)}>
All saved prompts
</DropdownMenuItem>
</DropdownMenuSubContent>
</DropdownMenuSub>
{/* Top-level: a third-level submenu collision-flips at narrow
widths and is awkward to reach. */}
<DropdownMenuSub>
<DropdownMenuSubTrigger disabled={exportThreadIds.length === 0}>
<HugeiconsIcon icon={Download01Icon} strokeWidth={2} />
Export chat
</DropdownMenuSubTrigger>
<DropdownMenuSubContent
collisionPadding={16}
className="unsloth-plus-menu w-[176px]"
>
{[
{ label: "Raw JSONL", fn: exportConversationRawJsonl },
{ label: "CSV", fn: exportConversationCsv },
{
label: "ShareGPT JSONL",
fn: exportConversationShareGPT,
},
].map(({ label, fn }) => (
<DropdownMenuItem
key={label}
disabled={exportThreadIds.length === 0}
onSelect={() => {
if (!exportThreadIds.length) {
toast.error("No conversation to export yet.");
return;
}
Promise.all(exportThreadIds.map((id) => fn(id))).catch(
() => toast.error("Export failed."),
);
}}
>
{label}
</DropdownMenuItem>
))}
</DropdownMenuSubContent>
</DropdownMenuSub>
<DropdownMenuSub>
<DropdownMenuSubTrigger>
<MoreHorizontalIcon className="size-4" />
More
</DropdownMenuSubTrigger>
<DropdownMenuSubContent className="unsloth-plus-menu w-[200px]">
{/* Always active: this menu only renders in compare mode. Ticked
like Web search/Code; click toggles it off. */}
<DropdownMenuItem
className="text-primary font-medium"
onSelect={handleExitCompare}
>
<Columns2Icon />
Compare chat
<HugeiconsIcon
icon={Tick02Icon}
strokeWidth={2}
className="ml-auto"
/>
</DropdownMenuItem>
<DropdownMenuSub>
<DropdownMenuSubTrigger>
<HugeiconsIcon icon={Bookmark02Icon} strokeWidth={2} />
Saved prompts
</DropdownMenuSubTrigger>
<DropdownMenuSubContent
collisionPadding={16}
className="unsloth-plus-menu w-[176px]"
>
{recentPrompts.map((p) => (
<DropdownMenuItem
key={p.id}
onSelect={() => {
setText(p.text);
requestAnimationFrame(() =>
textareaRef.current?.focus(),
);
}}
>
<span className="truncate">{p.name}</span>
</DropdownMenuItem>
))}
{recentPrompts.length > 0 ? (
<DropdownMenuSeparator />
) : null}
<DropdownMenuItem
onSelect={() => setPromptStorageOpen(true)}
>
All saved prompts
</DropdownMenuItem>
</DropdownMenuSubContent>
</DropdownMenuSub>
<DropdownMenuSub>
<DropdownMenuSubTrigger
disabled={exportThreadIds.length === 0}
>
<HugeiconsIcon icon={Download01Icon} strokeWidth={2} />
Export chat
</DropdownMenuSubTrigger>
<DropdownMenuSubContent
collisionPadding={16}
className="unsloth-plus-menu w-[176px]"
>
{[
{ label: "Raw JSONL", fn: exportConversationRawJsonl },
{ label: "CSV", fn: exportConversationCsv },
{
label: "ShareGPT JSONL",
fn: exportConversationShareGPT,
},
].map(({ label, fn }) => (
<DropdownMenuItem
key={label}
disabled={exportThreadIds.length === 0}
onSelect={() => {
if (!exportThreadIds.length) {
toast.error("No conversation to export yet.");
return;
}
Promise.all(
exportThreadIds.map((id) => fn(id)),
).catch(() => toast.error("Export failed."));
}}
>
{label}
</DropdownMenuItem>
))}
</DropdownMenuSubContent>
</DropdownMenuSub>
<DropdownMenuItem
className={
artifactsEnabled ? "text-primary font-medium" : undefined

View file

@ -36,7 +36,6 @@ export const CHAT_ALLOW_ARTIFACT_NETWORK_ACCESS_KEY =
export const CHAT_MCP_ENABLED_KEY = "unsloth_chat_mcp_enabled";
export const CHAT_WEB_FETCH_TOOLS_ENABLED_KEY =
"unsloth_chat_web_fetch_tools_enabled";
export const CHAT_RAG_ENABLED_KEY = "unsloth_chat_rag_enabled";
export const CHAT_RAG_SOURCE_KEY = "unsloth_chat_rag_source";
export const CHAT_RAG_MODE_KEY = "unsloth_chat_rag_mode";
export const CHAT_RAG_TOP_K_KEY = "unsloth_chat_rag_top_k";
@ -480,7 +479,7 @@ type ChatRuntimeStore = {
setAllowArtifactNetworkAccess: (enabled: boolean) => void;
setMcpEnabledForChat: (enabled: boolean) => void;
setWebFetchToolsEnabled: (enabled: boolean) => void;
setRagEnabled: (enabled: boolean, options?: { persist?: boolean }) => void;
setRagEnabled: (enabled: boolean) => void;
setRagSource: (source: RagSource) => void;
setRagMode: (mode: RagMode) => void;
setRagTopK: (topK: number) => void;
@ -746,7 +745,8 @@ export const useChatRuntimeStore = create<ChatRuntimeStore>((set, get) => ({
),
mcpEnabledForChat: loadBool(CHAT_MCP_ENABLED_KEY, false),
webFetchToolsEnabled: loadBool(CHAT_WEB_FETCH_TOOLS_ENABLED_KEY, false),
ragEnabled: loadBool(CHAT_RAG_ENABLED_KEY, false),
// RAG is opt-in per session: always starts off, never restored from storage.
ragEnabled: false,
ragSource: loadRagSource(),
ragMode: loadRagMode(),
ragTopK: loadRagTopK(),
@ -1072,13 +1072,7 @@ export const useChatRuntimeStore = create<ChatRuntimeStore>((set, get) => ({
saveBool(CHAT_WEB_FETCH_TOOLS_ENABLED_KEY, webFetchToolsEnabled);
return { webFetchToolsEnabled };
}),
setRagEnabled: (ragEnabled, options) =>
set(() => {
if (options?.persist !== false) {
saveBool(CHAT_RAG_ENABLED_KEY, ragEnabled);
}
return { ragEnabled };
}),
setRagEnabled: (ragEnabled) => set(() => ({ ragEnabled })),
setRagSource: (ragSource) =>
set(() => {
saveRagSource(ragSource);

View file

@ -14,7 +14,7 @@ import {
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { useRagToolAvailable } from "@/features/chat/hooks/use-rag-tool-available";
import { useRagToolDisabled } from "@/features/chat/hooks/use-rag-tool-disabled";
import { useChatRuntimeStore } from "@/features/chat/stores/chat-runtime-store";
import { listKnowledgeBases } from "../api/rag-api";
@ -38,8 +38,8 @@ const ArrowDownStandardIcon: FC<{ className?: string }> = ({ className }) => (
</svg>
);
// Picks the retrieval source. Only rendered when retrieval is on and the loaded
// model can run search_knowledge_base.
// Picks the retrieval source. Shown whenever retrieval is on; dims but stays
// interactive (so it can be turned off) while the loaded model can't run it.
export function KnowledgeBaseComposerButton({
side = "bottom",
}: {
@ -47,7 +47,7 @@ export function KnowledgeBaseComposerButton({
} = {}) {
const ragEnabled = useChatRuntimeStore((s) => s.ragEnabled);
const setRagEnabled = useChatRuntimeStore((s) => s.setRagEnabled);
const ragAvailable = useRagToolAvailable();
const ragDisabled = useRagToolDisabled();
const ragSource = useChatRuntimeStore((s) => s.ragSource);
const setRagSource = useChatRuntimeStore((s) => s.setRagSource);
@ -85,7 +85,7 @@ export function KnowledgeBaseComposerButton({
}
}, [kbs, kbsLoaded, ragSource, setRagSource]);
if (!ragEnabled || !ragAvailable) return null;
if (!ragEnabled) return null;
return (
<>
@ -100,7 +100,7 @@ export function KnowledgeBaseComposerButton({
<button
type="button"
className="composer-pill-btn"
data-active="true"
data-active={ragDisabled ? "false" : "true"}
aria-label="Retrieval source"
>
{/* Icon doubles as an off switch: hover swaps to an X; clicking it

View file

@ -7,7 +7,6 @@ import { AttachmentIcon, FileDatabaseIcon } from "@hugeicons/core-free-icons";
import { useAui } from "@assistant-ui/react";
import { cn } from "@/lib/utils";
import { useChatRuntimeStore } from "@/features/chat/stores/chat-runtime-store";
import { useRagToolAvailable } from "@/features/chat/hooks/use-rag-tool-available";
import { toast } from "@/lib/toast";
import { listKnowledgeBases, listThreadDocuments } from "../api/rag-api";
import { RAG_UPLOAD_ACCEPT } from "../types/rag";
@ -55,7 +54,6 @@ export function ThreadDocumentsBar({
onIndexingChange?: (active: boolean) => void;
}) {
const ragEnabled = useChatRuntimeStore((s) => s.ragEnabled);
const ragAvailable = useRagToolAvailable();
const ragSource = useChatRuntimeStore((s) => s.ragSource);
const aui = useAui();
const fileInputRef = useRef<HTMLInputElement>(null);
@ -137,8 +135,9 @@ export function ThreadDocumentsBar({
fileInputRef.current?.click();
}, []);
// Only when the RAG pill is on: enabled AND a tool-capable model loaded.
if (!ragEnabled || !ragAvailable) return null;
// Shown whenever the RAG pill is on: ingestion only needs the embedder, so
// files can index before a chat model loads.
if (!ragEnabled) return null;
// A KB source uploads via the KB dialog, not here; show which KB is active.
if (ragSource.type === "kb") {
return <KnowledgeBaseSourceChip kbId={ragSource.kbId} />;