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:
parent
b3ffa266da
commit
c836228de8
8 changed files with 609 additions and 349 deletions
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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)}
|
||||
|
|
|
|||
|
|
@ -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. */}
|
||||
|
|
|
|||
|
|
@ -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 ?? [],
|
||||
};
|
||||
},
|
||||
},
|
||||
),
|
||||
);
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ export function SettingsSection({
|
|||
children,
|
||||
}: {
|
||||
title: string;
|
||||
description?: string;
|
||||
description?: ReactNode;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -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")}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue