From dfb49fd2cbb349b78ee84da810b2aaba868db7df Mon Sep 17 00:00:00 2001 From: Daniel Han Date: Wed, 10 Jun 2026 05:11:15 -0700 Subject: [PATCH] Studio: bulk export and import in Settings Chat Data, MCP pill off switch (#6141) * Studio: bulk export and import in Settings Chat Data, MCP pill off switch - Settings -> Chat -> Data gains Export Recents and Projects (Recents or Recents + Projects, Raw JSONL / CSV / ShareGPT, combined or per chat) and Import chats, reusing the sidebar Recents menu actions - Extract bulkExportConversationsByScope so the sidebar and Settings share one implementation; expose the export and import helpers via the chat feature index - MCP composer pill icon now swaps to an X on hover like Search, Code and RAG; clicking it turns MCP off without opening the server menu - en and zh-CN locale strings added (parity check passes) * Reveal the pill X on hover for off-switch icons regardless of active look The X was gated on data-active, so an MCP pill with no servers enabled (or a RAG pill without a model) closed on icon click but never showed the affordance. Off-switch pills only render while their feature is on, so hover now always reveals the X. * Replace the Recents hamburger menu with an Export all chats link to Settings Bulk export and import now live in Settings -> Chat -> Data, so the sidebar Recents header menu is gone. Each chat's Export submenu gains Export all chats, which opens Settings on the Chat tab. --- .../frontend/src/components/app-sidebar.tsx | 117 ++--------------- studio/frontend/src/features/chat/index.ts | 5 + .../src/features/chat/mcp-composer-button.tsx | 26 +++- .../prompt-storage/prompt-storage-dialog.tsx | 31 +++++ .../src/features/settings/tabs/chat-tab.tsx | 123 +++++++++++++++++- studio/frontend/src/i18n/locales/en.ts | 16 +++ studio/frontend/src/i18n/locales/zh-CN.ts | 16 +++ studio/frontend/src/index.css | 14 ++ 8 files changed, 233 insertions(+), 115 deletions(-) diff --git a/studio/frontend/src/components/app-sidebar.tsx b/studio/frontend/src/components/app-sidebar.tsx index 6fe3c5c787..14131c3c1c 100644 --- a/studio/frontend/src/components/app-sidebar.tsx +++ b/studio/frontend/src/components/app-sidebar.tsx @@ -51,7 +51,6 @@ import { Delete02Icon, Download01Icon, DownloadSquare01Icon, - Upload01Icon, Edit03Icon, FolderAddIcon, FolderExportIcon, @@ -72,11 +71,6 @@ import { exportConversationRawJsonl, exportConversationCsv, exportConversationShareGPT, - exportBulkConversationsMerged, - exportBulkConversationsSeparate, - importConversationsFromFile, - EXPORT_FORMATS_LIST, - type ConvExportFormat, } from "@/features/chat/prompt-storage/prompt-storage-dialog"; import { listStoredChatThreads } from "@/features/chat/utils/chat-history-storage"; import { @@ -259,40 +253,6 @@ export function AppSidebar() { const isStudioRoute = pathname === "/studio" || pathname.startsWith("/studio/"); const [chatOpen, setChatOpen] = useState(true); - const recentsImportInputRef = useRef(null); - - async function handleImportToRecents(file: File) { - try { - const count = await importConversationsFromFile(file, null); - if (count === 0) { - toast.info("No conversations found in file."); - } else { - toast.success(`Imported ${count} conversation${count === 1 ? "" : "s"} to Recents.`); - } - } catch { - toast.error("Import failed."); - } - } - - async function handleBulkExport(scope: "recents" | "all", fmt: ConvExportFormat, merged: boolean) { - try { - const threads = await listStoredChatThreads({ - includeArchived: false, - ...(scope === "recents" ? { projectId: null } : {}), - }); - const ids = [...new Set(threads.map((t) => t.id))]; - if (ids.length === 0) { toast.info("No conversations to export."); return; } - const ts = new Date().toISOString().slice(0, 10); - const basename = scope === "all" ? `all-chats-${ts}` : `recents-${ts}`; - if (merged) { - await exportBulkConversationsMerged(ids, fmt, basename); - } else { - await exportBulkConversationsSeparate(ids, fmt, basename); - } - } catch { - toast.error("Export failed."); - } - } const [trainOpen, setTrainOpen] = useState(true); const [runsOpen, setRunsOpen] = useState(true); @@ -719,6 +679,15 @@ export function AppSidebar() { {label} ))} + + {/* Bulk export and import live in Settings -> Chat -> Data. */} + + useSettingsDialogStore.getState().openDialog("chat") + } + > + Export all chats… + - {/* Hidden file inputs for chat import */} - { - const file = e.target.files?.[0]; - if (file) void handleImportToRecents(file); - e.target.value = ""; - }} - /> - - - - - - recentsImportInputRef.current?.click()}> - - Import chats - - - - - - Export Recents - - - {EXPORT_FORMATS_LIST.map(({ fmt, label }) => ( - void handleBulkExport("recents", fmt, true)}> - {label} — combined - - ))} - - {EXPORT_FORMATS_LIST.map(({ fmt, label }) => ( - void handleBulkExport("recents", fmt, false)}> - {label} — per chat - - ))} - - - - - - Export Recents + Projects - - - {EXPORT_FORMATS_LIST.map(({ fmt, label }) => ( - void handleBulkExport("all", fmt, true)}> - {label} — combined - - ))} - - {EXPORT_FORMATS_LIST.map(({ fmt, label }) => ( - void handleBulkExport("all", fmt, false)}> - {label} — per chat - - ))} - - - - diff --git a/studio/frontend/src/features/chat/index.ts b/studio/frontend/src/features/chat/index.ts index ac05c8c089..bfaf29f519 100644 --- a/studio/frontend/src/features/chat/index.ts +++ b/studio/frontend/src/features/chat/index.ts @@ -29,6 +29,11 @@ export { useSelectedChatArtifact, } from "./artifacts/store"; export { downloadChatExport } from "./utils/export-chat-history"; +export { + EXPORT_FORMATS_LIST, + bulkExportConversationsByScope, + importConversationsFromFile, +} from "./prompt-storage/prompt-storage-dialog"; export { deleteChatItem, renameChatItem, diff --git a/studio/frontend/src/features/chat/mcp-composer-button.tsx b/studio/frontend/src/features/chat/mcp-composer-button.tsx index ae6c97528e..e0f024a40e 100644 --- a/studio/frontend/src/features/chat/mcp-composer-button.tsx +++ b/studio/frontend/src/features/chat/mcp-composer-button.tsx @@ -3,6 +3,7 @@ import { McpServerIcon, Tick02Icon } from "@hugeicons/core-free-icons"; import { HugeiconsIcon } from "@hugeicons/react"; +import { XIcon } from "lucide-react"; import { type FC, useCallback, useEffect, useState } from "react"; import { toast } from "sonner"; @@ -248,11 +249,26 @@ export function McpComposerButton({ data-active={active ? "true" : "false"} aria-label="MCP servers" > - + {/* Icon doubles as an off switch: hover swaps to an X; clicking + it turns MCP off without opening the menu. */} + e.stopPropagation()} + onClick={(e) => { + e.stopPropagation(); + setMcpEnabledForChat(false); + }} + className="composer-pill-glyph cursor-pointer" + > + + + MCP diff --git a/studio/frontend/src/features/chat/prompt-storage/prompt-storage-dialog.tsx b/studio/frontend/src/features/chat/prompt-storage/prompt-storage-dialog.tsx index 3908c313b6..e62f96720d 100644 --- a/studio/frontend/src/features/chat/prompt-storage/prompt-storage-dialog.tsx +++ b/studio/frontend/src/features/chat/prompt-storage/prompt-storage-dialog.tsx @@ -48,6 +48,7 @@ import { } from "../api/prompts-api"; import { listStoredChatMessages, + listStoredChatThreads, saveStoredChatThread, syncStoredChatMessages, } from "../utils/chat-history-storage"; @@ -493,6 +494,36 @@ export async function exportBulkConversationsSeparate( ); } +// Scope-level bulk export shared by the sidebar Recents menu and +// Settings -> Chat -> Data. "recents" = chats outside projects; "all" adds +// project chats. +export async function bulkExportConversationsByScope( + scope: "recents" | "all", + format: ConvExportFormat, + merged: boolean, +): Promise { + try { + const threads = await listStoredChatThreads({ + includeArchived: false, + ...(scope === "recents" ? { projectId: null } : {}), + }); + const ids = [...new Set(threads.map((t) => t.id))]; + if (ids.length === 0) { + toast.info("No conversations to export."); + return; + } + const ts = new Date().toISOString().slice(0, 10); + const basename = scope === "all" ? `all-chats-${ts}` : `recents-${ts}`; + if (merged) { + await exportBulkConversationsMerged(ids, format, basename); + } else { + await exportBulkConversationsSeparate(ids, format, basename); + } + } catch { + toast.error("Export failed."); + } +} + export async function exportProjectConversations( threadIds: string[], format: ConvExportFormat, diff --git a/studio/frontend/src/features/settings/tabs/chat-tab.tsx b/studio/frontend/src/features/settings/tabs/chat-tab.tsx index e37d56e2f7..9a81bdab0d 100644 --- a/studio/frontend/src/features/settings/tabs/chat-tab.tsx +++ b/studio/frontend/src/features/settings/tabs/chat-tab.tsx @@ -13,15 +13,32 @@ import { DialogTitle, } from "@/components/ui/dialog"; import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { + EXPORT_FORMATS_LIST, + bulkExportConversationsByScope, clearAllChats, countAllChats, downloadChatExport, + importConversationsFromFile, useChatRuntimeStore, } from "@/features/chat"; import { useT } from "@/i18n"; -import { Delete02Icon, Download01Icon } from "@hugeicons/core-free-icons"; +import { + Delete02Icon, + Download01Icon, + Upload01Icon, +} from "@hugeicons/core-free-icons"; import { HugeiconsIcon } from "@hugeicons/react"; -import { useEffect, useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { SettingsRow } from "../components/settings-row"; import { SettingsSection } from "../components/settings-section"; @@ -61,6 +78,25 @@ export function ChatTab() { } }; + const importInputRef = useRef(null); + const handleImport = async (file: File) => { + try { + const imported = await importConversationsFromFile(file, null); + if (imported === 0) { + toast.info(t("settings.chat.importNoConversations")); + } else { + toast.success( + imported === 1 + ? t("settings.chat.importedOneChat") + : t("settings.chat.importedChatCount", { count: imported }), + ); + setCount(await countAllChats().catch(() => count)); + } + } catch { + toast.error(t("settings.chat.importFailed")); + } + }; + const handleClear = async () => { setClearing(true); try { @@ -172,6 +208,89 @@ export function ChatTab() { + + + + + + + {( + [ + { scope: "recents", label: "exportScopeRecents" }, + { scope: "all", label: "exportScopeAll" }, + ] as const + ).map(({ scope, label }) => ( + + + + {t(`settings.chat.${label}`)} + + + {EXPORT_FORMATS_LIST.map(({ fmt, label: fmtLabel }) => ( + + void bulkExportConversationsByScope(scope, fmt, true) + } + > + {fmtLabel} {t("settings.chat.exportCombinedSuffix")} + + ))} + + {EXPORT_FORMATS_LIST.map(({ fmt, label: fmtLabel }) => ( + + void bulkExportConversationsByScope(scope, fmt, false) + } + > + {fmtLabel} {t("settings.chat.exportPerChatSuffix")} + + ))} + + + ))} + + + + + + + { + const file = e.target.files?.[0]; + e.target.value = ""; + if (file) void handleImport(file); + }} + /> + + .composer-pill-glyph[role="button"]):hover + .composer-pill-glyph + > :not(.composer-pill-x) { + @apply opacity-0; + } + + .composer-pill-btn:has(> .composer-pill-glyph[role="button"]):hover + .composer-pill-x { + @apply opacity-100; + } + .composer-input { @apply mt-2 mb-1 mx-3 min-h-12 w-[calc(100%-1.5rem)] resize-none overflow-y-auto bg-transparent pl-2 pr-4 pt-2 pb-3 text-sm font-[450] outline-none placeholder:text-muted-foreground focus-visible:ring-0; }