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:
Nilay 2026-06-12 18:06:31 +05:30 committed by GitHub
commit 911ceba7fa
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 289 additions and 13 deletions

View file

@ -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();

View file

@ -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";

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