mirror of
https://github.com/unslothai/unsloth.git
synced 2026-07-09 15:58:41 +00:00
Studio: new-chat shortcut, composer draft autosave, archive threads (#5771)
* new-chat shortcut, composer draft autosave, archive threads * fix * Studio: harden chat UX additions for legacy threads and unavailable storage Two robustness fixes on top of the new chat UX features: - groupThreads: coerce archived to a boolean before comparing (Boolean(t.archived) !== archived). Threads from the older browser-only Studio, or any record predating the archived field, can carry archived === undefined/null. The raw `!== archived` comparison dropped those from BOTH the Recents and Archived sidebar groups, hiding existing chats. Treat missing as not-archived so legacy chats still appear in Recents. - composer draft autosave: wrap the localStorage read and write in try/catch. When storage is unavailable (private mode, disabled cookies, blocked storage) or full (quota exceeded), getItem/setItem throw; the throw in the restore effect would surface to React and break the chat page. Draft persistence is best-effort, so degrade quietly. Verified with bun unit tests on the real groupThreads (legacy undefined no longer vanishes) and Playwright across chromium, firefox and webkit (draft save/restore/isolation/clear, new-chat shortcut + crypto.randomUUID, and localStorage blocked/quota throw handling). tsc clean; no new eslint findings. * Fix composer draft bleed and orphan cleanup for PR #5771 Centralize composer draft storage in a small util and tighten two edge cases: - New chat draft bleed: every new chat shared the chat-draft:__new__ slot, so starting a fresh chat could restore the previous one's half-typed text. Clear that slot at every new chat entry point (sidebar buttons and Cmd/Ctrl+Shift+O). - Orphan drafts: deleting a thread left its chat-draft:<id> key behind. Clear the draft for every deleted thread id. New util utils/composer-draft.ts owns the key format and wraps localStorage in try/catch (private mode, blocked storage, quota), replacing the inline copy in thread.tsx so reads and writes stay best effort everywhere. * address review * fix: remove unused chat sidebar binding --------- Co-authored-by: Daniel Han <michaelhan2050@gmail.com> Co-authored-by: Lee Jackson <130007945+Imagineer99@users.noreply.github.com> Co-authored-by: imagineer99 <samleejackson0@gmail.com>
This commit is contained in:
parent
3427e3fd62
commit
911ceba7fa
6 changed files with 289 additions and 13 deletions
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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() {
|
|||
</DropdownMenuItem>
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuSub>
|
||||
<DropdownMenuItem onSelect={() => void handleArchiveThread(item)}>
|
||||
<HugeiconsIcon icon={Archive01Icon} strokeWidth={1.75} className="size-icon" />
|
||||
<span>Archive</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
variant="destructive"
|
||||
onSelect={() => setConfirmingDelete({ kind: "chat", item })}
|
||||
|
|
@ -961,6 +999,85 @@ export function AppSidebar() {
|
|||
</Collapsible>
|
||||
)}
|
||||
|
||||
{/* Archived chats — hidden on Studio + when nothing is archived */}
|
||||
{!isStudioRoute && archivedChatItems.length > 0 && (
|
||||
<Collapsible open={archivedOpen} onOpenChange={setArchivedOpen} asChild>
|
||||
<SidebarGroup className="group-data-[collapsible=icon]:hidden px-0 py-0">
|
||||
<SidebarGroupLabel className={cn("sidebar-sticky-label sidebar-sticky-label-following", scrolled && "is-scrolled")} asChild>
|
||||
<CollapsibleTrigger className="cursor-pointer flex w-full items-center gap-1 group/sb-collap">
|
||||
Archived
|
||||
<ChevronDown className="size-3.5 opacity-0 transition-[transform,opacity] duration-200 group-hover/sb-collap:opacity-100 group-focus-visible/sb-collap:opacity-100 data-[state=open]:rotate-0 [[data-state=closed]_&]:rotate-[-90deg] [[data-state=closed]_&]:opacity-100" />
|
||||
</CollapsibleTrigger>
|
||||
</SidebarGroupLabel>
|
||||
<CollapsibleContent>
|
||||
<SidebarGroupContent className="px-1.5">
|
||||
<SidebarMenu>
|
||||
{archivedChatItems.map((item) => (
|
||||
<SidebarMenuItem key={item.id} className="group/archived-item relative">
|
||||
<SidebarMenuButton
|
||||
data-testid="archived-thread"
|
||||
data-thread-type={item.type}
|
||||
data-thread-id={item.id}
|
||||
isActive={activeThreadId === item.id}
|
||||
className="sidebar-nav-btn h-[33px] cursor-pointer rounded-full pl-3.5 pr-4 group-hover/archived-item:pr-8 group-has-[.sidebar-row-action[data-state=open]]/archived-item:pr-8 text-[14.5px] leading-[19px] tracking-nav font-medium text-muted-foreground"
|
||||
onClick={() => {
|
||||
navigate({
|
||||
to: "/chat",
|
||||
search:
|
||||
item.type === "single"
|
||||
? { thread: item.id }
|
||||
: { compare: item.id },
|
||||
});
|
||||
closeMobileIfOpen();
|
||||
}}
|
||||
>
|
||||
<span className="truncate">{item.title}</span>
|
||||
</SidebarMenuButton>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
aria-label="Archived chat options"
|
||||
className="sidebar-row-action group-hover/archived-item:opacity-100 group-hover/archived-item:pointer-events-auto focus-visible:opacity-100 focus-visible:pointer-events-auto"
|
||||
>
|
||||
<span className="sidebar-row-action-glyph">
|
||||
<HugeiconsIcon icon={MoreVerticalIcon} strokeWidth={1.75} className="size-icon" />
|
||||
</span>
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
side="bottom"
|
||||
align="start"
|
||||
sideOffset={0}
|
||||
className="unsloth-plus-menu menu-flat-destructive w-52"
|
||||
>
|
||||
<DropdownMenuItem onSelect={() => openRenameChat(item)}>
|
||||
<HugeiconsIcon icon={Edit03Icon} strokeWidth={1.75} className="size-icon" />
|
||||
<span>Rename</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={() => void handleUnarchiveThread(item)}>
|
||||
<HugeiconsIcon icon={ArchiveRestoreIcon} strokeWidth={1.75} className="size-icon" />
|
||||
<span>Unarchive</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
variant="destructive"
|
||||
onSelect={() => setConfirmingDelete({ kind: "chat", item })}
|
||||
>
|
||||
<HugeiconsIcon icon={Delete02Icon} strokeWidth={1.75} className="size-icon" />
|
||||
<span>Delete</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</SidebarMenuItem>
|
||||
))}
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
</CollapsibleContent>
|
||||
</SidebarGroup>
|
||||
</Collapsible>
|
||||
)}
|
||||
|
||||
{isStudioRoute && runItems.length > 0 && !chatOnly && (
|
||||
<Collapsible open={runsOpen} onOpenChange={setRunsOpen} asChild>
|
||||
<SidebarGroup className="group-data-[collapsible=icon]:hidden px-0 py-0">
|
||||
|
|
|
|||
|
|
@ -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 =
|
||||
|
|
|
|||
|
|
@ -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<string>();
|
||||
|
||||
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<void> {
|
||||
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<void> {
|
||||
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();
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
44
studio/frontend/src/features/chat/utils/composer-draft.ts
Normal file
44
studio/frontend/src/features/chat/utils/composer-draft.ts
Normal file
|
|
@ -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);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue