diff --git a/studio/frontend/src/app/routes/__root.tsx b/studio/frontend/src/app/routes/__root.tsx index c05b0e645..f5179e68f 100644 --- a/studio/frontend/src/app/routes/__root.tsx +++ b/studio/frontend/src/app/routes/__root.tsx @@ -6,7 +6,7 @@ import { Navbar } from "@/components/navbar"; import { fetchDeviceType, usePlatformStore } from "@/config/env"; import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar"; import { SettingsDialog, useSettingsDialogStore } from "@/features/settings"; -import { useChatRuntimeStore } from "@/features/chat"; +import { clearNewChatDraft, useChatRuntimeStore } from "@/features/chat"; import { useTrainingUnloadGuard } from "@/features/training"; import { useSidebarPin } from "@/hooks/use-sidebar-pin"; import { useT, type TranslationKey } from "@/i18n"; @@ -15,6 +15,7 @@ import { createRootRoute, redirect, useMatches, + useNavigate, useRouterState, } from "@tanstack/react-router"; import { AnimatePresence, motion } from "motion/react"; @@ -78,6 +79,7 @@ function RootLayout() { const hideNavbar = HIDDEN_NAVBAR_ROUTES.includes(pathname); const isChatRoute = pathname.startsWith("/chat"); const { pinned, setPinned, togglePinned } = useSidebarPin(); + const navigate = useNavigate(); useTrainingUnloadGuard(); @@ -107,11 +109,24 @@ function RootLayout() { if ((e.metaKey || e.ctrlKey) && e.key === ",") { e.preventDefault(); useSettingsDialogStore.getState().openDialog(); + return; + } + // Cmd/Ctrl+Shift+O opens a new chat. + if ((e.metaKey || e.ctrlKey) && e.shiftKey && e.code === "KeyO") { + e.preventDefault(); + clearNewChatDraft(); // fresh chat starts empty, no bleed from the last one + const chatRuntime = useChatRuntimeStore.getState(); + chatRuntime.setActiveThreadId(null); + chatRuntime.setActiveProjectId(null); + void navigate({ + to: "/chat", + search: { new: crypto.randomUUID() }, + }); } }; window.addEventListener("keydown", handler); return () => window.removeEventListener("keydown", handler); - }, []); + }, [navigate]); useEffect(() => { if (isChatRoute) return; diff --git a/studio/frontend/src/components/app-sidebar.tsx b/studio/frontend/src/components/app-sidebar.tsx index 72bb75a84..16ab7932b 100644 --- a/studio/frontend/src/components/app-sidebar.tsx +++ b/studio/frontend/src/components/app-sidebar.tsx @@ -45,6 +45,8 @@ import { Switch } from "@/components/ui/switch"; import { useAnimatedThemeToggle } from "@/components/ui/animated-theme-toggler"; import { cn } from "@/lib/utils"; import { + Archive01Icon, + ArchiveRestoreIcon, ChefHatIcon, CursorInfo02Icon, DashboardCircleIcon, @@ -83,13 +85,16 @@ import { HugeiconsIcon } from "@hugeicons/react"; import { ChevronDown, ChevronsUpDown, MoreHorizontalIcon, Moon, Sun } from "lucide-react"; import { Link, useNavigate, useRouterState } from "@tanstack/react-router"; import { + archiveChatItem, ChatSearchDialog, + clearNewChatDraft, createChatProject, deleteChatProject, deleteChatItem, moveChatItemToProject, renameChatItem, renameChatProject, + unarchiveChatItem, useChatRuntimeStore, useChatProjects, useChatSearchStore, @@ -291,15 +296,16 @@ export function AppSidebar() { const activeProjectId = isChatRoute ? ((search.project as string | undefined) ?? null) : null; - const { items: allChatItems } = useChatSidebarItems({ - enabled: !isStudioRoute, - requireMessages: false, - }); + const { items: allChatItems, archivedItems: archivedChatItems } = + useChatSidebarItems({ + enabled: !isStudioRoute, + requireMessages: false, + }); const recentChatItems = useMemo( () => allChatItems.filter((item) => !item.projectId), [allChatItems], ); - const chatItems = allChatItems; + const [archivedOpen, setArchivedOpen] = useState(false); const storeThreadId = useChatRuntimeStore((s) => s.activeThreadId); const setActiveThreadId = useChatRuntimeStore((s) => s.setActiveThreadId); const activeThreadId = isChatRoute @@ -349,6 +355,7 @@ export function AppSidebar() { function openNewChat(projectId = activeProjectId) { if (chatDisabled) return; + clearNewChatDraft(); setActiveThreadId(null); useChatRuntimeStore.getState().setActiveProjectId(projectId); navigate({ to: "/chat", search: chatSearchForProject(projectId) }); @@ -374,6 +381,33 @@ export function AppSidebar() { }); } + async function handleArchiveThread(item: SidebarItem) { + try { + await archiveChatItem(item, activeThreadId, (view) => { + navigate({ + to: "/chat", + search: item.projectId + ? { project: item.projectId } + : { new: view.newThreadNonce }, + }); + }); + } catch (err) { + toast.error("Failed to archive chat", { + description: err instanceof Error ? err.message : undefined, + }); + } + } + + async function handleUnarchiveThread(item: SidebarItem) { + try { + await unarchiveChatItem(item); + } catch (err) { + toast.error("Failed to unarchive chat", { + description: err instanceof Error ? err.message : undefined, + }); + } + } + type RenameTarget = | { kind: "chat"; item: SidebarItem; current: string } | { kind: "project"; project: ProjectRecord; current: string } @@ -692,6 +726,10 @@ export function AppSidebar() { + void handleArchiveThread(item)}> + + Archive + setConfirmingDelete({ kind: "chat", item })} @@ -961,6 +999,85 @@ export function AppSidebar() { )} + {/* Archived chats — hidden on Studio + when nothing is archived */} + {!isStudioRoute && archivedChatItems.length > 0 && ( + + + + + Archived + + + + + + + {archivedChatItems.map((item) => ( + + { + navigate({ + to: "/chat", + search: + item.type === "single" + ? { thread: item.id } + : { compare: item.id }, + }); + closeMobileIfOpen(); + }} + > + {item.title} + + + + + + + openRenameChat(item)}> + + Rename + + void handleUnarchiveThread(item)}> + + Unarchive + + setConfirmingDelete({ kind: "chat", item })} + > + + Delete + + + + + ))} + + + + + + )} + {isStudioRoute && runItems.length > 0 && !chatOnly && ( diff --git a/studio/frontend/src/components/assistant-ui/thread.tsx b/studio/frontend/src/components/assistant-ui/thread.tsx index 9ff6f440b..ac5ae19aa 100644 --- a/studio/frontend/src/components/assistant-ui/thread.tsx +++ b/studio/frontend/src/components/assistant-ui/thread.tsx @@ -71,8 +71,11 @@ import { useChatRuntimeStore } from "@/features/chat/stores/chat-runtime-store"; import { useExternalProvidersStore } from "@/features/chat/stores/external-providers-store"; import { PLUS_MENU_ORDER, + composerDraftKey, + readComposerDraft, type PlusMenuItemId, usePlusMenuPrefsStore, + writeComposerDraft, } from "@/features/chat"; import { deleteThreadMessage } from "@/features/chat/utils/delete-thread-message"; import { ThreadDocumentsBar } from "@/features/rag/components/thread-documents-bar"; @@ -943,6 +946,31 @@ const Composer: FC<{ const referenceThreadId = threadId ?? activeThreadId ?? null; const hasSendableContent = composerText.trim().length > 0 || hasAttachments || hasPendingAudio; + + // Per-thread draft autosave: restore on mount, then mirror composer text + // into localStorage (debounced) so a half-typed message survives a + // navigation or reload. Cleared once empty (i.e. after a send). Setting the + // text even when no draft exists keeps a thread from inheriting the + // previous thread's composer contents. + const draftKey = composerDraftKey(activeThreadId); + const lastDraftKeyRef = useRef(draftKey); + useEffect(() => { + const draft = readComposerDraft(draftKey) ?? ""; + const composer = aui.composer(); + if (composer.getState().isEditing) { + composer.setText(draft); + } + }, [draftKey, aui]); + useEffect(() => { + // After a thread switch composerText can still hold the previous + // thread's text; skip that cycle so it isn't saved under the new key. + if (lastDraftKeyRef.current !== draftKey) { + lastDraftKeyRef.current = draftKey; + return; + } + const t = setTimeout(() => writeComposerDraft(draftKey, composerText), 300); + return () => clearTimeout(t); + }, [composerText, draftKey]); // Two-row layout shows once the input wraps or a tool is on. Tools can // pre-select before a model loads, so an active toggle expands it either way. const composerExpanded = diff --git a/studio/frontend/src/features/chat/hooks/use-chat-sidebar-items.ts b/studio/frontend/src/features/chat/hooks/use-chat-sidebar-items.ts index c2607d6b7..71d6079fc 100644 --- a/studio/frontend/src/features/chat/hooks/use-chat-sidebar-items.ts +++ b/studio/frontend/src/features/chat/hooks/use-chat-sidebar-items.ts @@ -2,7 +2,10 @@ // Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 import { useEffect, useState } from "react"; -import { CHAT_HISTORY_UPDATED_EVENT } from "../api/chat-api"; +import { + CHAT_HISTORY_UPDATED_EVENT, + notifyChatHistoryUpdated, +} from "../api/chat-api"; import { useChatRuntimeStore } from "../stores/chat-runtime-store"; import { useChatArtifactsStore } from "../artifacts/store"; import type { ThreadRecord } from "../types"; @@ -13,11 +16,11 @@ import { listStoredChatThreadsWithMessages, updateStoredChatThread, } from "../utils/chat-history-storage"; +import { clearComposerDraft } from "../utils/composer-draft"; import { markChatThreadsDeleted, removeChatThreadTombstones, } from "../utils/chat-thread-tombstones"; -import { notifyChatHistoryUpdated } from "../api/chat-api"; export interface SidebarItem { type: "single" | "compare"; @@ -27,12 +30,20 @@ export interface SidebarItem { projectId?: string | null; } -export function groupThreads(threads: ThreadRecord[]): SidebarItem[] { +export function groupThreads( + threads: ThreadRecord[], + archived = false, +): SidebarItem[] { const items: SidebarItem[] = []; const seenPairs = new Set(); for (const t of threads) { - if (t.archived) { + // Coerce archived to a boolean before comparing. Legacy threads (from the + // older browser-only Studio, or any record predating the archived field) + // can have archived === undefined or null; a raw `!== archived` comparison + // would drop those from BOTH the Recents (archived=false) and Archived + // (archived=true) lists, hiding existing chats. Treat missing as false. + if (Boolean(t.archived) !== archived) { continue; } if (t.pairId) { @@ -88,8 +99,10 @@ export function useChatSidebarItems(options?: { const listThreads = requireMessages ? listStoredChatThreadsWithMessages : listStoredChatThreads; + // includeArchived: archived threads are filtered out of Recents by + // groupThreads, but the hook still needs them for archivedItems. const threads = await listThreads({ - includeArchived: false, + includeArchived: true, projectId: options?.projectId, }); // Discard the response if a newer request was scheduled while we @@ -126,9 +139,10 @@ export function useChatSidebarItems(options?: { }, [enabled, options?.projectId, requireMessages]); const items = groupThreads(allThreads ?? []); + const archivedItems = groupThreads(allThreads ?? [], true); const canCompare = useChatRuntimeStore((s) => Boolean(s.params.checkpoint)); - return { items, canCompare }; + return { items, archivedItems, canCompare }; } function cancelIfRunning(threadId: string): void { @@ -160,6 +174,53 @@ export async function renameChatItem( ); } +export async function archiveChatItem( + item: SidebarItem, + activeId: string | undefined, + onSelect: (view: { mode: "single"; newThreadNonce: string }) => void, +): Promise { + const threadIds: string[] = + item.type === "single" + ? [item.id] + : ( + await listStoredChatThreads({ + pairId: item.id, + includeArchived: true, + }) + ).map((t) => t.id); + + for (const id of threadIds) cancelIfRunning(id); + + await Promise.all( + threadIds.map((id) => updateStoredChatThread(id, { archived: true })), + ); + + if (activeId === item.id) { + useChatRuntimeStore.getState().setActiveThreadId(null); + onSelect({ mode: "single", newThreadNonce: crypto.randomUUID() }); + } + + notifyChatHistoryUpdated(); +} + +export async function unarchiveChatItem(item: SidebarItem): Promise { + const threadIds: string[] = + item.type === "single" + ? [item.id] + : ( + await listStoredChatThreads({ + pairId: item.id, + includeArchived: true, + }) + ).map((t) => t.id); + + await Promise.all( + threadIds.map((id) => updateStoredChatThread(id, { archived: false })), + ); + + notifyChatHistoryUpdated(); +} + export async function deleteChatItem( item: SidebarItem, activeId: string | undefined, @@ -174,6 +235,9 @@ export async function deleteChatItem( // generating against a thread that no longer exists. for (const id of threadIds) cancelIfRunning(id); + // Drop saved composer drafts so deleted threads leave no orphan keys. + for (const id of threadIds) clearComposerDraft(id); + const artifactStore = useChatArtifactsStore.getState(); for (const id of threadIds) artifactStore.clearArtifactsForThread(id); artifactStore.clearOrphanedArtifacts(); diff --git a/studio/frontend/src/features/chat/index.ts b/studio/frontend/src/features/chat/index.ts index fcb62b94a..10e4ccd2a 100644 --- a/studio/frontend/src/features/chat/index.ts +++ b/studio/frontend/src/features/chat/index.ts @@ -35,14 +35,22 @@ export { useSelectedChatArtifact, } from "./artifacts/store"; export { downloadChatExport } from "./utils/export-chat-history"; +export { + clearNewChatDraft, + composerDraftKey, + readComposerDraft, + writeComposerDraft, +} from "./utils/composer-draft"; export { EXPORT_FORMATS_LIST, bulkExportConversationsByScope, importConversationsFromFile, } from "./prompt-storage/prompt-storage-dialog"; export { + archiveChatItem, deleteChatItem, renameChatItem, + unarchiveChatItem, useChatSidebarItems, type SidebarItem, } from "./hooks/use-chat-sidebar-items"; diff --git a/studio/frontend/src/features/chat/utils/composer-draft.ts b/studio/frontend/src/features/chat/utils/composer-draft.ts new file mode 100644 index 000000000..10f3b9e8a --- /dev/null +++ b/studio/frontend/src/features/chat/utils/composer-draft.ts @@ -0,0 +1,44 @@ +// SPDX-License-Identifier: AGPL-3.0-only +// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 + +// Per-thread composer drafts persisted in localStorage. New (unsaved) chats +// share the NEW_CHAT_DRAFT_ID slot; callers clear it when a fresh chat starts +// so one new chat's draft never bleeds into the next. +const DRAFT_PREFIX = "chat-draft:"; +const NEW_CHAT_DRAFT_ID = "__new__"; + +export function composerDraftKey(threadId: string | null | undefined): string { + return `${DRAFT_PREFIX}${threadId ?? NEW_CHAT_DRAFT_ID}`; +} + +// All storage access is best-effort: localStorage throws when unavailable +// (private mode, blocked storage) or full (quota), so swallow failures. +export function readComposerDraft(key: string): string | null { + try { + return window.localStorage.getItem(key); + } catch { + return null; + } +} + +export function writeComposerDraft(key: string, text: string): void { + try { + if (text.length > 0) window.localStorage.setItem(key, text); + else window.localStorage.removeItem(key); + } catch { + // ignore write failures + } +} + +export function clearComposerDraft(threadId: string | null | undefined): void { + try { + window.localStorage.removeItem(composerDraftKey(threadId)); + } catch { + // ignore + } +} + +// Drop the shared new-chat draft so a freshly started chat opens empty. +export function clearNewChatDraft(): void { + clearComposerDraft(null); +}