Studio: pinnable plus menu items and saved prompt pins (#6237)

Adjustable items (Chat with Files, MCP, Saved prompts, Compare chat,
Export chat, Canvas, Projects) can be pinned to the top level of the
composer plus menu from Settings -> Chat. Unpinned items move into the
More submenu, which hides itself when empty. Saved prompts can be
pinned individually so they surface in the Saved prompts submenu.
This commit is contained in:
Michael Han 2026-06-12 04:17:45 -07:00 committed by GitHub
commit c836228de8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 609 additions and 349 deletions

View file

@ -69,6 +69,11 @@ import { getExternalReasoningCapabilities } from "@/features/chat/provider-capab
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 {
PLUS_MENU_ORDER,
type PlusMenuItemId,
usePlusMenuPrefsStore,
} from "@/features/chat";
import { deleteThreadMessage } from "@/features/chat/utils/delete-thread-message";
import { ThreadDocumentsBar } from "@/features/rag/components/thread-documents-bar";
import { KnowledgeBaseComposerButton } from "@/features/rag/components/knowledge-base-composer-button";
@ -134,6 +139,7 @@ import {
type KeyboardEvent,
type DragEvent as ReactDragEvent,
type ReactNode,
Fragment,
createContext,
useCallback,
useContext,
@ -2093,17 +2099,179 @@ const ComposerToolsMenu: FC<{ side?: "top" | "bottom" }> = ({
const messageCount = useAuiState(({ thread }) => thread.messages.length);
const { startQueue } = useContext(PromptQueueContext);
const plusPins = usePlusMenuPrefsStore((s) => s.pins);
const [recentPrompts, setRecentPrompts] = useState<PromptEntry[]>([]);
const refreshRecentPrompts = useCallback(async () => {
try {
const rows = await listPromptEntries();
setRecentPrompts(
[...rows].sort((a, b) => b.updatedAt - a.updatedAt).slice(0, 3),
);
const byRecent = [...rows].sort((a, b) => b.updatedAt - a.updatedAt);
// Pinned prompts take over the submenu; fall back to the 3 most recent
// when nothing is pinned.
const pinnedIds = usePlusMenuPrefsStore.getState().pinnedPromptIds;
const pinned = byRecent.filter((p) => pinnedIds.includes(p.id));
setRecentPrompts(pinned.length > 0 ? pinned : byRecent.slice(0, 3));
} catch {
}
}, []);
// Adjustable "+" menu items, keyed by id. Pinned ones render at the top
// level; the rest fall into the "More" overflow submenu. The core items
// (photos, web search, code) and "More" itself are always shown and live
// outside this map.
const plusMenuNodes: Record<PlusMenuItemId, ReactNode> = {
chatWithFiles: (
<DropdownMenuItem
disabled={ragDisabled}
className={
ragEnabled && !ragDisabled ? "text-primary font-medium" : undefined
}
onSelect={() => setRagEnabled(!ragEnabled)}
>
<HugeiconsIcon icon={FileDatabaseIcon} strokeWidth={2} />
Chat with Files
{ragEnabled && !ragDisabled ? (
<HugeiconsIcon icon={Tick02Icon} strokeWidth={2} className="ml-auto" />
) : null}
</DropdownMenuItem>
),
mcp: (
<DropdownMenuItem
disabled={mcpDisabled}
className={
mcpEnabledForChat && !mcpDisabled
? "text-primary font-medium"
: undefined
}
onSelect={() => setMcpEnabledForChat(!mcpEnabledForChat)}
>
<HugeiconsIcon icon={McpServerIcon} strokeWidth={2} />
MCP
{mcpEnabledForChat && !mcpDisabled ? (
<HugeiconsIcon icon={Tick02Icon} strokeWidth={2} className="ml-auto" />
) : null}
</DropdownMenuItem>
),
savedPrompts: (
<DropdownMenuSub>
<DropdownMenuSubTrigger>
<HugeiconsIcon icon={Bookmark02Icon} strokeWidth={2} />
Saved prompts
</DropdownMenuSubTrigger>
<DropdownMenuSubContent
collisionPadding={16}
className="unsloth-plus-menu w-[208px]"
>
{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>
),
compareChat: (
<DropdownMenuItem onSelect={() => startCompare()}>
<Columns2Icon />
Compare chat
</DropdownMenuItem>
),
exportChat: (
<DropdownMenuSub>
<DropdownMenuSubTrigger disabled={!activeThreadId || messageCount === 0}>
<HugeiconsIcon icon={Download01Icon} strokeWidth={2} />
Export chat
</DropdownMenuSubTrigger>
<DropdownMenuSubContent
collisionPadding={16}
className="unsloth-plus-menu w-[208px]"
>
<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>
),
canvas: (
<DropdownMenuItem
className={artifactsEnabled ? "text-primary font-medium" : undefined}
onSelect={() => setArtifactsEnabled(!artifactsEnabled)}
>
<HugeiconsIcon icon={PencilRulerIcon} strokeWidth={2} />
Canvas
{artifactsEnabled ? (
<HugeiconsIcon icon={Tick02Icon} strokeWidth={2} className="ml-auto" />
) : null}
</DropdownMenuItem>
),
projects: (
<DropdownMenuSub>
<DropdownMenuSubTrigger>
<HugeiconsIcon icon={Folder01Icon} strokeWidth={2} />
Projects
</DropdownMenuSubTrigger>
<DropdownMenuSubContent className="unsloth-plus-menu w-[232px]">
<DropdownMenuItem onSelect={() => setNewProjectOpen(true)}>
<HugeiconsIcon icon={FolderAddIcon} strokeWidth={2} />
New project
</DropdownMenuItem>
<DropdownMenuLabel>Recents</DropdownMenuLabel>
{recentProjects.length > 0 ? (
recentProjects.map((project) => (
<DropdownMenuItem
key={project.id}
onSelect={() => openProject(project.id)}
>
<HugeiconsIcon icon={Folder01Icon} strokeWidth={2} />
<span className="truncate">{project.name}</span>
</DropdownMenuItem>
))
) : (
<DropdownMenuItem disabled={true}>
No recent projects
</DropdownMenuItem>
)}
</DropdownMenuSubContent>
</DropdownMenuSub>
),
};
const pinnedPlusItems = PLUS_MENU_ORDER.filter((id) => plusPins[id]);
const overflowPlusItems = PLUS_MENU_ORDER.filter((id) => !plusPins[id]);
return (
<>
<PromptStorageDialog
@ -2136,7 +2304,7 @@ const ComposerToolsMenu: FC<{ side?: "top" | "bottom" }> = ({
align="start"
sideOffset={0}
avoidCollisions={true}
className="unsloth-plus-menu w-[212px]"
className="unsloth-plus-menu w-[244px]"
// Don't refocus the + on close; restored focus showed a stray ring.
onCloseAutoFocus={(event) => event.preventDefault()}
>
@ -2220,165 +2388,22 @@ const ComposerToolsMenu: FC<{ side?: "top" | "bottom" }> = ({
</DropdownMenuItem>
)}
<DropdownMenuSeparator />
<DropdownMenuItem
disabled={ragDisabled}
className={
ragEnabled && !ragDisabled ? "text-primary font-medium" : undefined
}
onSelect={() => setRagEnabled(!ragEnabled)}
>
<HugeiconsIcon icon={FileDatabaseIcon} strokeWidth={2} />
Chat with Files
{ragEnabled && !ragDisabled ? (
<HugeiconsIcon
icon={Tick02Icon}
strokeWidth={2}
className="ml-auto"
/>
) : null}
</DropdownMenuItem>
<DropdownMenuItem
disabled={mcpDisabled}
className={
mcpEnabledForChat && !mcpDisabled
? "text-primary font-medium"
: undefined
}
onSelect={() => setMcpEnabledForChat(!mcpEnabledForChat)}
>
<HugeiconsIcon icon={McpServerIcon} strokeWidth={2} />
MCP
{mcpEnabledForChat && !mcpDisabled ? (
<HugeiconsIcon
icon={Tick02Icon}
strokeWidth={2}
className="ml-auto"
/>
) : null}
</DropdownMenuItem>
<DropdownMenuSub>
<DropdownMenuSubTrigger>
<MoreHorizontalIcon className="size-4" />
More
</DropdownMenuSubTrigger>
<DropdownMenuSubContent className="unsloth-plus-menu w-[200px]">
<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>
<DropdownMenuItem onSelect={() => startCompare()}>
<Columns2Icon />
Compare chat
</DropdownMenuItem>
<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
}
onSelect={() => setArtifactsEnabled(!artifactsEnabled)}
>
<HugeiconsIcon icon={PencilRulerIcon} strokeWidth={2} />
Canvas
{artifactsEnabled ? (
<HugeiconsIcon
icon={Tick02Icon}
strokeWidth={2}
className="ml-auto"
/>
) : null}
</DropdownMenuItem>
</DropdownMenuSubContent>
</DropdownMenuSub>
<DropdownMenuSeparator />
<DropdownMenuSub>
<DropdownMenuSubTrigger>
<HugeiconsIcon icon={Folder01Icon} strokeWidth={2} />
Projects
</DropdownMenuSubTrigger>
<DropdownMenuSubContent className="unsloth-plus-menu w-[200px]">
<DropdownMenuItem onSelect={() => setNewProjectOpen(true)}>
<HugeiconsIcon icon={FolderAddIcon} strokeWidth={2} />
New project
</DropdownMenuItem>
<DropdownMenuLabel>Recents</DropdownMenuLabel>
{recentProjects.length > 0 ? (
recentProjects.map((project) => (
<DropdownMenuItem
key={project.id}
onSelect={() => openProject(project.id)}
>
<HugeiconsIcon icon={Folder01Icon} strokeWidth={2} />
<span className="truncate">{project.name}</span>
</DropdownMenuItem>
))
) : (
<DropdownMenuItem disabled={true}>
No recent projects
</DropdownMenuItem>
)}
</DropdownMenuSubContent>
</DropdownMenuSub>
{pinnedPlusItems.map((id) => (
<Fragment key={id}>{plusMenuNodes[id]}</Fragment>
))}
{overflowPlusItems.length > 0 ? (
<DropdownMenuSub>
<DropdownMenuSubTrigger>
<MoreHorizontalIcon className="size-4" />
More
</DropdownMenuSubTrigger>
<DropdownMenuSubContent className="unsloth-plus-menu w-[232px]">
{overflowPlusItems.map((id) => (
<Fragment key={id}>{plusMenuNodes[id]}</Fragment>
))}
</DropdownMenuSubContent>
</DropdownMenuSub>
) : null}
</DropdownMenuContent>
</DropdownMenu>
<NewProjectDialog
@ -2674,10 +2699,10 @@ const AssistantActionBar: FC = () => {
side="bottom"
align="start"
onCloseAutoFocus={(e) => e.preventDefault()}
className="aui-action-bar-more-content z-50 min-w-32 overflow-hidden rounded-md [--radius:1.1rem] bg-popover p-1 text-popover-foreground shadow-[0_2px_8px_-2px_rgba(0,0,0,0.16)] dark:shadow-none"
className="aui-action-bar-more-content z-50 min-w-32 overflow-hidden rounded-full bg-popover p-1 text-popover-foreground shadow-[0_2px_8px_-2px_rgba(0,0,0,0.16)] dark:shadow-none"
>
<ActionBarPrimitive.ExportMarkdown asChild={true}>
<ActionBarMorePrimitive.Item className="aui-action-bar-more-item flex cursor-pointer select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground">
<ActionBarMorePrimitive.Item className="aui-action-bar-more-item flex cursor-pointer select-none items-center gap-2 rounded-full px-3 py-1.5 text-sm outline-none hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground">
<HugeiconsIcon icon={Download01Icon} strokeWidth={1.75} className="size-icon" />
Export as Markdown
</ActionBarMorePrimitive.Item>

View file

@ -18,6 +18,11 @@ export {
} from "./chat-settings-sheet";
export { useChatRuntimeStore } from "./stores/chat-runtime-store";
export { useChatSearchStore } from "./stores/chat-search-store";
export {
PLUS_MENU_ORDER,
usePlusMenuPrefsStore,
type PlusMenuItemId,
} from "./stores/plus-menu-prefs-store";
export { useChatModelRuntime } from "./hooks/use-chat-model-runtime";
export { isExternalModelId } from "./external-providers";
export { ChatSearchDialog } from "./components/chat-search-dialog";

View file

@ -53,6 +53,7 @@ import {
syncStoredChatMessages,
} from "../utils/chat-history-storage";
import { notifyChatHistoryUpdated } from "../api/chat-api";
import { usePlusMenuPrefsStore } from "../stores/plus-menu-prefs-store";
import type { ThreadRecord, MessageRecord } from "../types";
function newId(): string {
@ -1235,6 +1236,9 @@ function PromptCard({
const [editing, setEditing] = useState(false);
const [name, setName] = useState(entry.name);
const [text, setText] = useState(entry.text);
const pinnedPromptIds = usePlusMenuPrefsStore((s) => s.pinnedPromptIds);
const togglePinnedPrompt = usePlusMenuPrefsStore((s) => s.togglePinnedPrompt);
const isPinned = pinnedPromptIds.includes(entry.id);
const handleSave = useCallback(async () => {
const trimName = name.trim();
@ -1281,6 +1285,9 @@ function PromptCard({
return (
<div className="group rounded-xl border border-border/60 bg-card p-4 flex flex-col gap-2 hover:border-border hover:shadow-sm transition-all">
<div className="flex items-center gap-2">
{isPinned ? (
<BookmarkIcon className="size-3.5 shrink-0 fill-primary text-primary" />
) : null}
<span className="font-semibold text-sm flex-1 truncate tracking-tight">{entry.name}</span>
<div className="flex items-center gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity">
<button
@ -1292,6 +1299,21 @@ function PromptCard({
<PlayIcon className="size-3" />Use
</button>
<div className="mx-1 h-4 w-px bg-border/60" />
<button
type="button"
onClick={() => togglePinnedPrompt(entry.id)}
className={cn(
"flex h-7 w-7 items-center justify-center rounded-lg transition-colors",
isPinned
? "text-primary hover:bg-primary/10"
: "text-muted-foreground hover:bg-muted hover:text-foreground",
)}
title={isPinned ? "Unpin from + menu" : "Pin to + menu"}
>
<BookmarkIcon
className={cn("size-3.5", isPinned && "fill-primary")}
/>
</button>
<button
type="button"
onClick={() => onExport(entry)}

View file

@ -68,6 +68,11 @@ import {
providerTypeSupportsVision,
} from "./external-providers";
import { useExternalProvidersStore } from "./stores/external-providers-store";
import {
PLUS_MENU_ORDER,
type PlusMenuItemId,
usePlusMenuPrefsStore,
} from "./stores/plus-menu-prefs-store";
import {
type ReasoningEffort,
useChatRuntimeStore,
@ -85,6 +90,7 @@ import {
type MutableRefObject,
type ReactElement,
type ReactNode,
Fragment,
createContext,
useCallback,
useContext,
@ -435,12 +441,15 @@ export function SharedComposer({
const refreshRecentPrompts = useCallback(async () => {
try {
const rows = await listPromptEntries();
setRecentPrompts(
[...rows].sort((a, b) => b.updatedAt - a.updatedAt).slice(0, 3),
);
const byRecent = [...rows].sort((a, b) => b.updatedAt - a.updatedAt);
// Pinned prompts take over the submenu; fall back to the 3 most recent.
const pinnedIds = usePlusMenuPrefsStore.getState().pinnedPromptIds;
const pinned = byRecent.filter((p) => pinnedIds.includes(p.id));
setRecentPrompts(pinned.length > 0 ? pinned : byRecent.slice(0, 3));
} catch {
}
}, []);
const plusPins = usePlusMenuPrefsStore((s) => s.pins);
const [isQueueRunning, setIsQueueRunning] = useState(false);
const [queueProgress, setQueueProgress] = useState({ current: 0, total: 0 });
const queueRef = useRef<string[]>([]);
@ -1104,6 +1113,157 @@ export function SharedComposer({
!busy &&
!isComposing;
// Adjustable "+" menu items, keyed by id. Pinned ones render at the top
// level; the rest fall into the "More" overflow submenu. Core items (photos,
// web search, code) and "More" itself live outside this map.
const plusMenuNodes: Record<PlusMenuItemId, ReactNode> = {
chatWithFiles: (
<DropdownMenuItem
disabled={ragDisabled}
className={
ragEnabled && !ragDisabled ? "text-primary font-medium" : undefined
}
onSelect={() => setRagEnabled(!ragEnabled)}
>
<HugeiconsIcon icon={FileDatabaseIcon} strokeWidth={2} />
Chat with Files
{ragEnabled && !ragDisabled ? (
<HugeiconsIcon icon={Tick02Icon} strokeWidth={2} className="ml-auto" />
) : null}
</DropdownMenuItem>
),
mcp: (
<DropdownMenuItem
disabled={!supportsTools}
className={mcpEnabledForChat ? "text-primary font-medium" : undefined}
onSelect={() => setMcpEnabledForChat(!mcpEnabledForChat)}
>
<HugeiconsIcon icon={McpServerIcon} strokeWidth={2} />
MCP
{mcpEnabledForChat ? (
<HugeiconsIcon icon={Tick02Icon} strokeWidth={2} className="ml-auto" />
) : null}
</DropdownMenuItem>
),
savedPrompts: (
<DropdownMenuSub>
<DropdownMenuSubTrigger>
<HugeiconsIcon icon={Bookmark02Icon} strokeWidth={2} />
Saved prompts
</DropdownMenuSubTrigger>
<DropdownMenuSubContent
collisionPadding={16}
className="unsloth-plus-menu w-[208px]"
>
{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>
),
compareChat: (
// Always active: this menu only renders in compare mode. Click exits.
<DropdownMenuItem
className="text-primary font-medium"
onSelect={handleExitCompare}
>
<Columns2Icon />
Compare chat
<HugeiconsIcon icon={Tick02Icon} strokeWidth={2} className="ml-auto" />
</DropdownMenuItem>
),
exportChat: (
<DropdownMenuSub>
<DropdownMenuSubTrigger disabled={exportThreadIds.length === 0}>
<HugeiconsIcon icon={Download01Icon} strokeWidth={2} />
Export chat
</DropdownMenuSubTrigger>
<DropdownMenuSubContent
collisionPadding={16}
className="unsloth-plus-menu w-[208px]"
>
{[
{ 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>
),
canvas: (
<DropdownMenuItem
className={artifactsEnabled ? "text-primary font-medium" : undefined}
onSelect={() => setArtifactsEnabled(!artifactsEnabled)}
>
<HugeiconsIcon icon={PencilRulerIcon} strokeWidth={2} />
Canvas
{artifactsEnabled ? (
<HugeiconsIcon icon={Tick02Icon} strokeWidth={2} className="ml-auto" />
) : null}
</DropdownMenuItem>
),
projects: (
<DropdownMenuSub>
<DropdownMenuSubTrigger>
<HugeiconsIcon icon={Folder01Icon} strokeWidth={2} />
Projects
</DropdownMenuSubTrigger>
<DropdownMenuSubContent className="unsloth-plus-menu w-[232px]">
<DropdownMenuItem onSelect={() => setNewProjectOpen(true)}>
<HugeiconsIcon icon={FolderAddIcon} strokeWidth={2} />
New project
</DropdownMenuItem>
<DropdownMenuLabel>Recents</DropdownMenuLabel>
{recentProjects.length > 0 ? (
recentProjects.map((project) => (
<DropdownMenuItem
key={project.id}
onSelect={() => openProject(project.id)}
>
<HugeiconsIcon icon={Folder01Icon} strokeWidth={2} />
<span className="truncate">{project.name}</span>
</DropdownMenuItem>
))
) : (
<DropdownMenuItem disabled={true}>
No recent projects
</DropdownMenuItem>
)}
</DropdownMenuSubContent>
</DropdownMenuSub>
),
};
const pinnedPlusItems = PLUS_MENU_ORDER.filter((id) => plusPins[id]);
const overflowPlusItems = PLUS_MENU_ORDER.filter((id) => !plusPins[id]);
return (
<div
className="chat-composer-surface"
@ -1278,7 +1438,7 @@ export function SharedComposer({
align="start"
sideOffset={0}
avoidCollisions={true}
className="unsloth-plus-menu w-[212px]"
className="unsloth-plus-menu w-[244px]"
onCloseAutoFocus={(event) => event.preventDefault()}
>
<DropdownMenuItem onSelect={() => fileInputRef.current?.click()}>
@ -1367,179 +1527,22 @@ export function SharedComposer({
</DropdownMenuItem>
)}
<DropdownMenuSeparator />
<DropdownMenuItem
disabled={ragDisabled}
className={
ragEnabled && !ragDisabled
? "text-primary font-medium"
: undefined
}
onSelect={() => setRagEnabled(!ragEnabled)}
>
<HugeiconsIcon icon={FileDatabaseIcon} strokeWidth={2} />
Chat with Files
{ragEnabled && !ragDisabled ? (
<HugeiconsIcon
icon={Tick02Icon}
strokeWidth={2}
className="ml-auto"
/>
) : null}
</DropdownMenuItem>
<DropdownMenuItem
disabled={!supportsTools}
className={
mcpEnabledForChat ? "text-primary font-medium" : undefined
}
onSelect={() => setMcpEnabledForChat(!mcpEnabledForChat)}
>
<HugeiconsIcon icon={McpServerIcon} strokeWidth={2} />
MCP
{mcpEnabledForChat ? (
<HugeiconsIcon
icon={Tick02Icon}
strokeWidth={2}
className="ml-auto"
/>
) : null}
</DropdownMenuItem>
{/* RAG hidden temporarily */}
<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
}
onSelect={() => setArtifactsEnabled(!artifactsEnabled)}
>
<HugeiconsIcon icon={PencilRulerIcon} strokeWidth={2} />
Canvas
{artifactsEnabled ? (
<HugeiconsIcon
icon={Tick02Icon}
strokeWidth={2}
className="ml-auto"
/>
) : null}
</DropdownMenuItem>
</DropdownMenuSubContent>
</DropdownMenuSub>
<DropdownMenuSeparator />
<DropdownMenuSub>
<DropdownMenuSubTrigger>
<HugeiconsIcon icon={Folder01Icon} strokeWidth={2} />
Projects
</DropdownMenuSubTrigger>
<DropdownMenuSubContent className="unsloth-plus-menu w-[200px]">
<DropdownMenuItem onSelect={() => setNewProjectOpen(true)}>
<HugeiconsIcon icon={FolderAddIcon} strokeWidth={2} />
New project
</DropdownMenuItem>
<DropdownMenuLabel>Recents</DropdownMenuLabel>
{recentProjects.length > 0 ? (
recentProjects.map((project) => (
<DropdownMenuItem
key={project.id}
onSelect={() => openProject(project.id)}
>
<HugeiconsIcon icon={Folder01Icon} strokeWidth={2} />
<span className="truncate">{project.name}</span>
</DropdownMenuItem>
))
) : (
<DropdownMenuItem disabled={true}>
No recent projects
</DropdownMenuItem>
)}
</DropdownMenuSubContent>
</DropdownMenuSub>
{pinnedPlusItems.map((id) => (
<Fragment key={id}>{plusMenuNodes[id]}</Fragment>
))}
{overflowPlusItems.length > 0 ? (
<DropdownMenuSub>
<DropdownMenuSubTrigger>
<MoreHorizontalIcon className="size-4" />
More
</DropdownMenuSubTrigger>
<DropdownMenuSubContent className="unsloth-plus-menu w-[232px]">
{overflowPlusItems.map((id) => (
<Fragment key={id}>{plusMenuNodes[id]}</Fragment>
))}
</DropdownMenuSubContent>
</DropdownMenuSub>
) : null}
</DropdownMenuContent>
</DropdownMenu>
{/* Active in compare mode; sits first. Click to exit back to single chat. */}

View file

@ -0,0 +1,84 @@
// 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 { create } from "zustand";
import { persist } from "zustand/middleware";
// Adjustable items in the composer "+" menu. The core items (Add photos &
// files, Web search, Code) and the "More" overflow itself are always shown and
// are intentionally NOT represented here.
export type PlusMenuItemId =
| "chatWithFiles"
| "mcp"
| "savedPrompts"
| "compareChat"
| "exportChat"
| "canvas"
| "projects";
// Canonical order used both for the pinned items at the top level and for the
// items that fall into the "More" overflow submenu.
export const PLUS_MENU_ORDER: PlusMenuItemId[] = [
"chatWithFiles",
"mcp",
"savedPrompts",
"compareChat",
"exportChat",
"canvas",
"projects",
];
// Defaults reproduce the historical layout: Chat with Files, MCP and Projects
// pinned to the top level; everything else living under "More".
const DEFAULT_PINS: Record<PlusMenuItemId, boolean> = {
chatWithFiles: true,
mcp: true,
projects: true,
savedPrompts: false,
compareChat: false,
exportChat: false,
canvas: false,
};
export interface PlusMenuPrefsState {
pins: Record<PlusMenuItemId, boolean>;
setPin: (id: PlusMenuItemId, value: boolean) => void;
togglePin: (id: PlusMenuItemId) => void;
// Ids of saved prompts the user pinned into the "Saved prompts" submenu.
// Kept client-side (like the menu pins above) since prompts are addressed by
// their stable server id.
pinnedPromptIds: string[];
togglePinnedPrompt: (id: string) => void;
}
export const usePlusMenuPrefsStore = create<PlusMenuPrefsState>()(
persist(
(set) => ({
pins: { ...DEFAULT_PINS },
setPin: (id, value) =>
set((state) => ({ pins: { ...state.pins, [id]: value } })),
togglePin: (id) =>
set((state) => ({ pins: { ...state.pins, [id]: !state.pins[id] } })),
pinnedPromptIds: [],
togglePinnedPrompt: (id) =>
set((state) => ({
pinnedPromptIds: state.pinnedPromptIds.includes(id)
? state.pinnedPromptIds.filter((x) => x !== id)
: [...state.pinnedPromptIds, id],
})),
}),
{
name: "unsloth_plus_menu_pins",
// Backfill any ids added in a later release so persisted state from an
// older version still resolves every menu item.
merge: (persisted, current) => {
const saved = persisted as Partial<PlusMenuPrefsState> | undefined;
return {
...current,
pins: { ...DEFAULT_PINS, ...(saved?.pins ?? {}) },
pinnedPromptIds: saved?.pinnedPromptIds ?? [],
};
},
},
),
);

View file

@ -7,12 +7,14 @@ import type { ReactNode } from "react";
export function SettingsRow({
label,
description,
icon,
children,
destructive,
className,
}: {
label: string;
description?: string;
icon?: ReactNode;
children?: ReactNode;
destructive?: boolean;
className?: string;
@ -25,13 +27,20 @@ export function SettingsRow({
className,
)}
>
<div className="flex min-w-0 flex-col gap-0.5">
<span className="text-sm font-medium text-foreground">{label}</span>
{description ? (
<span className="text-xs text-muted-foreground leading-snug">
{description}
<div className="flex min-w-0 items-center gap-2.5">
{icon ? (
<span className="flex shrink-0 items-center text-foreground">
{icon}
</span>
) : null}
<div className="flex min-w-0 flex-col gap-0.5">
<span className="text-sm font-medium text-foreground">{label}</span>
{description ? (
<span className="text-xs text-muted-foreground leading-snug">
{description}
</span>
) : null}
</div>
</div>
{children ? <div className="flex shrink-0 items-center">{children}</div> : null}
</div>

View file

@ -9,7 +9,7 @@ export function SettingsSection({
children,
}: {
title: string;
description?: string;
description?: ReactNode;
children: ReactNode;
}) {
return (

View file

@ -30,20 +30,109 @@ import {
downloadChatExport,
importConversationsFromFile,
useChatRuntimeStore,
type PlusMenuItemId,
usePlusMenuPrefsStore,
} from "@/features/chat";
import { useT } from "@/i18n";
import {
Bookmark02Icon,
Delete02Icon,
Download01Icon,
FileDatabaseIcon,
Folder01Icon,
McpServerIcon,
PencilRulerIcon,
Upload01Icon,
} from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
import { Columns2Icon, PlusIcon } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import type { ReactNode } from "react";
import { SettingsRow } from "../components/settings-row";
import { SettingsSection } from "../components/settings-section";
// Adjustable "+" menu items shown in settings, in display order. Icons mirror
// the ones used in the composer + menu itself.
const PLUS_MENU_ICON_CLASS = "size-[18px]";
const PLUS_MENU_SETTINGS: { id: PlusMenuItemId; label: string; icon: ReactNode }[] =
[
{
id: "chatWithFiles",
label: "Chat with Files",
icon: (
<HugeiconsIcon
icon={FileDatabaseIcon}
strokeWidth={2}
className={PLUS_MENU_ICON_CLASS}
/>
),
},
{
id: "mcp",
label: "MCP",
icon: (
<HugeiconsIcon
icon={McpServerIcon}
strokeWidth={2}
className={PLUS_MENU_ICON_CLASS}
/>
),
},
{
id: "savedPrompts",
label: "Saved prompts",
icon: (
<HugeiconsIcon
icon={Bookmark02Icon}
strokeWidth={2}
className={PLUS_MENU_ICON_CLASS}
/>
),
},
{
id: "compareChat",
label: "Compare chat",
icon: <Columns2Icon className={PLUS_MENU_ICON_CLASS} />,
},
{
id: "exportChat",
label: "Export chat",
icon: (
<HugeiconsIcon
icon={Download01Icon}
strokeWidth={2}
className={PLUS_MENU_ICON_CLASS}
/>
),
},
{
id: "canvas",
label: "Canvas",
icon: (
<HugeiconsIcon
icon={PencilRulerIcon}
strokeWidth={2}
className={PLUS_MENU_ICON_CLASS}
/>
),
},
{
id: "projects",
label: "Projects",
icon: (
<HugeiconsIcon
icon={Folder01Icon}
strokeWidth={2}
className={PLUS_MENU_ICON_CLASS}
/>
),
},
];
export function ChatTab() {
const t = useT();
const plusPins = usePlusMenuPrefsStore((state) => state.pins);
const togglePlusPin = usePlusMenuPrefsStore((state) => state.togglePin);
const [confirmOpen, setConfirmOpen] = useState(false);
const [count, setCount] = useState<number | null>(null);
const [exporting, setExporting] = useState(false);
@ -157,7 +246,7 @@ export function ChatTab() {
return (
<div className="flex flex-col gap-6">
<header className="flex flex-col gap-1">
<h1 className="text-lg font-semibold font-heading">
<h1 className="text-xl font-semibold font-heading">
{t("settings.chat.title")}
</h1>
<p className="text-xs text-muted-foreground">
@ -165,6 +254,29 @@ export function ChatTab() {
</p>
</header>
<SettingsSection
title="Chat menu"
description={
<>
Choose which items are pinned in chat's{" "}
<PlusIcon
aria-label="+"
className="inline size-3.5 align-[-2px] stroke-[2px]"
/>{" "}
side menu. Unpinned items move into More.
</>
}
>
{PLUS_MENU_SETTINGS.map((item) => (
<SettingsRow key={item.id} label={item.label} icon={item.icon}>
<Switch
checked={plusPins[item.id]}
onCheckedChange={() => togglePlusPin(item.id)}
/>
</SettingsRow>
))}
</SettingsSection>
<SettingsSection title={t("settings.chat.artifacts.title")}>
<SettingsRow
label={t("settings.chat.artifacts.collapseHtmlBlocks")}