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);
+}