Studio: add temporary (incognito) chat (#5956)

* Studio: add temporary (incognito) chat

* Address Gemini's concerns

* Studio: soften the temporary-chat subtitle to a plain fade-in

* Fix temporary chat edge cases

* Studio: drop redundant crypto.randomUUID fallback in temporary chat toggle

* Reset temporary chat on route exit

---------

Co-authored-by: imagineer99 <samleejackson0@gmail.com>
Co-authored-by: Lee Jackson <130007945+Imagineer99@users.noreply.github.com>
This commit is contained in:
oobabooga 2026-06-14 12:41:57 -03:00 committed by GitHub
commit da3f7ac085
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 178 additions and 14 deletions

View file

@ -118,6 +118,7 @@ function RootLayout() {
const chatRuntime = useChatRuntimeStore.getState();
chatRuntime.setActiveThreadId(null);
chatRuntime.setActiveProjectId(null);
chatRuntime.setIncognito(false);
void navigate({
to: "/chat",
search: { new: crypto.randomUUID() },
@ -133,6 +134,7 @@ function RootLayout() {
const chatRuntime = useChatRuntimeStore.getState();
chatRuntime.setActiveProjectId(null);
chatRuntime.setActiveThreadId(null);
chatRuntime.setIncognito(false);
}, [isChatRoute]);
return (

View file

@ -377,6 +377,9 @@ export function AppSidebar() {
clearNewChatDraft();
setActiveThreadId(null);
useChatRuntimeStore.getState().setActiveProjectId(projectId);
// The normal new-chat affordance is always a regular, saved chat --
// only the toolbar toggle starts a temporary one.
useChatRuntimeStore.getState().setIncognito(false);
navigate({ to: "/chat", search: chatSearchForProject(projectId) });
closeMobileIfOpen();
}

View file

@ -782,6 +782,7 @@ const ThreadWelcome: FC<{
hideComposer?: boolean;
threadId?: string | null;
}> = ({ hideComposer, threadId }) => {
const incognito = useChatRuntimeStore((s) => s.incognito);
const displayName = useUserProfileStore((s) => s.displayName);
const nickname = useUserProfileStore((s) => s.nickname);
const [welcome, setWelcome] = useState<Welcome>(DEFAULT_WELCOME);
@ -805,9 +806,15 @@ const ThreadWelcome: FC<{
className="size-[44px] -translate-y-[2px]"
/>
<h1 className="aui-thread-welcome-message-inner unsloth-welcome-title fade-in slide-in-from-bottom-1 animate-in text-3xl tracking-[-0.02em] duration-200">
{welcome.text}
{incognito ? "Temporary chat" : welcome.text}
</h1>
</div>
{incognito && (
<p className="aui-thread-welcome-message-inner fade-in -mt-2 animate-in text-center font-heading font-normal text-muted-foreground text-sm duration-200">
This chat won't appear in your history and isn't saved. It
disappears when you leave.
</p>
)}
{!hideComposer && <ComposerAnimated threadId={threadId} />}
</div>
</div>
@ -2089,6 +2096,7 @@ const ComposerToolsMenu: FC<{ side?: "top" | "bottom" }> = ({
const [newProjectOpen, setNewProjectOpen] = useState(false);
const [promptStorageOpen, setPromptStorageOpen] = useState(false);
const activeThreadId = useChatRuntimeStore((s) => s.activeThreadId);
const incognito = useChatRuntimeStore((s) => s.incognito);
const aui = useAui();
const composerCanAddAttachments = useAuiState(
({ composer }) => composer.isEditing,
@ -2124,8 +2132,9 @@ const ComposerToolsMenu: FC<{ side?: "top" | "bottom" }> = ({
};
input.click();
}, [aui, audioAttachmentsEnabled]);
// Disable Export chat until the thread has content.
// Exports are storage-backed; temporary chats intentionally never write there.
const messageCount = useAuiState(({ thread }) => thread.messages.length);
const exportDisabled = incognito || !activeThreadId || messageCount === 0;
const { startQueue } = useContext(PromptQueueContext);
const plusPins = usePlusMenuPrefsStore((s) => s.pins);
@ -2214,7 +2223,7 @@ const ComposerToolsMenu: FC<{ side?: "top" | "bottom" }> = ({
),
exportChat: (
<DropdownMenuSub>
<DropdownMenuSubTrigger disabled={!activeThreadId || messageCount === 0}>
<DropdownMenuSubTrigger disabled={exportDisabled}>
<HugeiconsIcon icon={Download01Icon} strokeWidth={2} />
Export chat
</DropdownMenuSubTrigger>

View file

@ -31,6 +31,7 @@ import { GuidedTour, useGuidedTourController } from "@/features/tour";
import { isTauri } from "@/lib/api-base";
import { cn } from "@/lib/utils";
import {
BubbleChatTemporaryIcon,
Folder02Icon,
LayoutAlignRightIcon,
} from "@hugeicons/core-free-icons";
@ -111,6 +112,7 @@ import {
listStoredChatMessages,
listStoredChatThreads,
} from "./utils/chat-history-storage";
import { isAssistantLocalThreadId } from "./utils/thread-ids";
type LoraCandidate = {
id: string;
@ -160,12 +162,6 @@ function pickBestLoraForBase(
return partial ?? sorted[0] ?? null;
}
function isAssistantLocalThreadId(
threadId: string | null | undefined,
): boolean {
return Boolean(threadId?.startsWith("__LOCALID_"));
}
function messageHasImage(message: MessageRecord): boolean {
const contentParts = Array.isArray(message.content) ? message.content : [];
if (contentParts.some((part) => part.type === "image")) {
@ -1028,6 +1024,30 @@ export function ChatPage(): ReactElement {
const settingsOpen = useChatRuntimeStore((s) => s.settingsPanelOpen);
const setSettingsOpen = useChatRuntimeStore((s) => s.setSettingsPanelOpen);
const incognito = useChatRuntimeStore((s) => s.incognito);
const setIncognito = useChatRuntimeStore((s) => s.setIncognito);
const incognitoLabel = incognito
? "Turn off temporary chat"
: "Turn on temporary chat";
const toggleIncognito = useCallback(() => {
const store = useChatRuntimeStore.getState();
store.setIncognito(!store.incognito);
// On an empty scratch chat there's nothing to abandon, so flip in
// place: navigating would remount the thread and bounce the composer
// (it docks to the bottom before the welcome state re-centers it).
// Otherwise start a clean chat so the temporary session can't inherit
// or leave behind a persisted thread (matches ChatGPT / Gemini).
const onEmptyScratchChat =
!search.thread &&
!search.compare &&
!search.project &&
store.activeThreadId == null;
if (onEmptyScratchChat) return;
// setActiveThreadId already clears contextUsage.
store.setActiveThreadId(null);
store.setActiveProjectId(null);
navigate({ to: "/chat", search: { new: crypto.randomUUID() } });
}, [navigate, search]);
const hydratePersistedSettings = useChatRuntimeStore(
(s) => s.hydratePersistedSettings,
);
@ -1441,6 +1461,17 @@ export function ChatPage(): ReactElement {
currentProjectId,
]);
// Temporary chat only applies to a fresh single-view chat. Exit incognito
// when we land on anything else (compare, a project, or an existing thread
// via sidebar/deep link/back), so the toggle isn't stranded and the UI
// never implies a saved thread is temporary.
useEffect(() => {
const onFreshSingleChat = view.mode === "single" && !view.threadId;
if (incognito && !onFreshSingleChat) {
setIncognito(false);
}
}, [view, incognito, setIncognito]);
const selectedArtifact = useSelectedChatArtifact();
const artifactSurface = useChatArtifactsStore((state) => state.surface);
const closeArtifactSurface = useChatArtifactsStore(
@ -2121,6 +2152,16 @@ export function ChatPage(): ReactElement {
className="max-w-[62vw] !pr-3 sm:max-w-none !h-[34px]"
/>
)}
{incognito && view.mode === "single" && (
<div className="flex h-[34px] shrink-0 items-center gap-1.5 self-center rounded-full bg-primary/10 px-2.5 font-medium text-[13px] text-primary">
<HugeiconsIcon
icon={BubbleChatTemporaryIcon}
strokeWidth={2}
className="size-3.5"
/>
<span>Temporary</span>
</div>
)}
{view.mode !== "compare" && currentProjectId && (
<nav
aria-label="Project location"
@ -2196,6 +2237,37 @@ export function ChatPage(): ReactElement {
className="h-[34px]"
/>
) : null}
{view.mode === "single" && (
<Tooltip>
<TooltipPrimitive.Trigger asChild={true}>
<button
type="button"
onClick={toggleIncognito}
className={cn(
"flex h-[34px] w-[34px] cursor-pointer items-center justify-center rounded-[12px] transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
incognito
? "bg-primary/10 text-primary hover:bg-primary/15"
: "text-nav-fg hover:bg-nav-surface-hover hover:text-black dark:hover:text-white",
)}
aria-label={incognitoLabel}
aria-pressed={incognito}
>
<HugeiconsIcon
icon={BubbleChatTemporaryIcon}
strokeWidth={1.75}
className="size-icon"
/>
</button>
</TooltipPrimitive.Trigger>
<TooltipContent
side="bottom"
sideOffset={6}
className="tooltip-compact"
>
{incognitoLabel}
</TooltipContent>
</Tooltip>
)}
{!settingsOpen && (
<Tooltip>
<TooltipPrimitive.Trigger asChild={true}>

View file

@ -61,6 +61,7 @@ import {
isExpectedBackgroundChatStorageError,
listStoredChatMessages,
listStoredChatThreads,
markThreadIncognito,
saveStoredChatMessage,
saveStoredChatThread,
updateStoredChatThread,
@ -68,6 +69,7 @@ import {
import { isChatThreadDeleted } from "./utils/chat-thread-tombstones";
import { syncExportedRepositoryToBackend } from "./utils/delete-thread-message";
import { getImageInputUnavailableReason } from "./utils/image-input-support";
import { isAssistantLocalThreadId } from "./utils/thread-ids";
const pendingHistoryAppendByMessageId = new Map<string, Promise<void>>();
const pendingRunStartReadyByMessageId = new Map<string, Promise<void>>();
@ -560,12 +562,31 @@ export async function ensureThreadRecord({
if (isChatThreadDeleted(threadId)) {
return;
}
// Snapshot the toggle SYNCHRONOUSLY, before the await below. This runs in
// the same tick as the user's send, so it reliably captures the toggle's
// state at creation. Reading it after the await would let a toggle-off
// that lands mid-await (the list call is a real network round-trip) flip
// the decision and persist what should have been an incognito thread.
const incognitoAtInit = useChatRuntimeStore.getState().incognito;
// Fresh assistant-ui threads are local ids. Temporary chats can skip the
// history list entirely so a storage outage cannot block the first send.
if (incognitoAtInit && isAssistantLocalThreadId(threadId)) {
markThreadIncognito(threadId);
return;
}
const existing = (await listStoredChatThreads({ includeArchived: true })).find(
(thread) => thread.id === threadId,
);
if (existing) {
return;
}
// For non-local ids, keep the existing check first so an already-persisted
// thread is never tagged -- that's what keeps a real thread saving normally
// even if the toggle flips on while its run is still streaming.
if (incognitoAtInit) {
markThreadIncognito(threadId);
return;
}
const currentModelId = useChatRuntimeStore.getState().params.checkpoint ?? "";
const record: ThreadRecord = {

View file

@ -544,6 +544,14 @@ type ChatRuntimeStore = {
loadedChatTemplateOverride: string | null;
activeThreadId: string | null;
activeProjectId: string | null;
/**
* Temporary / incognito chat toggle. When on, the active conversation
* lives only in assistant-ui's in-memory repository and is never
* persisted to studio.db -- so it stays out of history and vanishes on
* reload. Deliberately ephemeral: NOT mirrored to localStorage or the
* backend settings, so a refresh always exits incognito.
*/
incognito: boolean;
settingsPanelOpen: boolean;
pendingAudioBase64: string | null;
pendingAudioName: string | null;
@ -576,6 +584,7 @@ type ChatRuntimeStore = {
setCheckpoint: (modelId: string, ggufVariant?: string | null) => void;
setActiveThreadId: (threadId: string | null) => void;
setActiveProjectId: (projectId: string | null) => void;
setIncognito: (incognito: boolean) => void;
setSettingsPanelOpen: (open: boolean) => void;
clearCheckpoint: () => void;
setReasoningEnabled: (
@ -911,6 +920,7 @@ export const useChatRuntimeStore = create<ChatRuntimeStore>((set, get) => ({
loadedChatTemplateOverride: null,
activeThreadId: null,
activeProjectId: null,
incognito: false,
settingsPanelOpen: false,
pendingAudioBase64: null,
pendingAudioName: null,
@ -1071,6 +1081,7 @@ export const useChatRuntimeStore = create<ChatRuntimeStore>((set, get) => ({
setActiveThreadId: (activeThreadId) =>
set({ activeThreadId, contextUsage: null }),
setActiveProjectId: (activeProjectId) => set({ activeProjectId }),
setIncognito: (incognito) => set({ incognito }),
setSettingsPanelOpen: (settingsPanelOpen) => set({ settingsPanelOpen }),
clearCheckpoint: () => {
// Mirror setCheckpoint's persistence: dropping the checkpoint must also

View file

@ -35,6 +35,25 @@ import {
markChatThreadsDeleted,
} from "./chat-thread-tombstones";
// Thread ids that belong to a temporary/incognito session. A thread is
// tagged once, at creation (ensureThreadRecord, when the toggle is on), and
// stays tagged for its whole lifetime -- the readers and writers below
// consult this set, never the live toggle. That decoupling is what makes
// mid-stream toggling safe: flipping the toggle can neither leak an
// in-flight incognito run into history nor drop a normal thread's writes.
// Per-thread reads short-circuit too (nothing is stored to fetch); only the
// thread list stays ungated, so real history still loads next to a
// temporary chat.
const incognitoThreadIds = new Set<string>();
export function markThreadIncognito(threadId: string): void {
incognitoThreadIds.add(threadId);
}
function isThreadIncognito(threadId: string): boolean {
return incognitoThreadIds.has(threadId);
}
type ThreadListArgs = {
modelType?: ModelType;
pairId?: string;
@ -424,6 +443,9 @@ async function importLegacyChatsIfNeeded(): Promise<void> {
export async function getStoredChatThread(
threadId: string,
): Promise<ThreadRecord | undefined> {
// Incognito threads are never stored, so the lookup can only come back
// empty -- short-circuit it instead of doing a Dexie read + backend GET.
if (isThreadIncognito(threadId)) return undefined;
if (isChatThreadDeleted(threadId)) return undefined;
const legacyThread = await db.threads.get(threadId);
let backendThread: ThreadRecord | null;
@ -446,6 +468,10 @@ export async function ensureStoredChatThread(
threadId: string,
fallback?: ThreadRecord,
): Promise<ThreadRecord | undefined> {
// An incognito thread is never persisted, so there's genuinely nothing
// to ensure -- skip the backend round-trips this would otherwise make
// on every autosave (runStart/runEnd) and message append.
if (isThreadIncognito(threadId)) return undefined;
if (isChatThreadDeleted(threadId)) return undefined;
const legacyThread = fallback ?? (await db.threads.get(threadId));
let backendThread: ThreadRecord | null;
@ -467,6 +493,7 @@ export async function ensureStoredChatThread(
export async function listStoredChatMessages(
threadId: string,
): Promise<MessageRecord[]> {
if (isThreadIncognito(threadId)) return [];
if (isChatThreadDeleted(threadId)) return [];
const legacyMessages = await db.messages
.where("threadId")
@ -510,6 +537,7 @@ export async function getStoredChatMessage(
threadId: string,
messageId: string,
): Promise<MessageRecord | undefined> {
if (isThreadIncognito(threadId)) return undefined;
if (isChatThreadDeleted(threadId)) return undefined;
const legacyMessage = await db.messages.get(messageId);
const matchingLegacyMessage =
@ -661,6 +689,7 @@ export async function moveStoredChatItemToProject(
export async function saveStoredChatMessage(
message: MessageRecord,
): Promise<MessageRecord> {
if (isThreadIncognito(message.threadId)) return message;
if (isChatThreadDeleted(message.threadId)) {
throw new Error(`Thread ${message.threadId} was deleted`);
}
@ -673,6 +702,7 @@ export async function syncStoredChatMessages(
messages: MessageRecord[],
options: { pruneMissing?: boolean } = {},
): Promise<MessageRecord[]> {
if (isThreadIncognito(threadId)) return messages;
if (isChatThreadDeleted(threadId)) return [];
await ensureStoredChatThread(threadId);
return syncChatMessages(threadId, messages, options);
@ -681,6 +711,7 @@ export async function syncStoredChatMessages(
export async function saveStoredChatThread(
thread: ThreadRecord,
): Promise<ThreadRecord> {
if (isThreadIncognito(thread.id)) return thread;
if (isChatThreadDeleted(thread.id)) {
throw new Error(`Thread ${thread.id} was deleted`);
}
@ -691,6 +722,7 @@ export async function updateStoredChatThread(
threadId: string,
patch: Partial<ThreadRecord>,
): Promise<ThreadRecord | undefined> {
if (isThreadIncognito(threadId)) return undefined;
const thread = await ensureStoredChatThread(threadId);
if (!thread) return undefined;
return updateChatThread(threadId, patch);
@ -699,15 +731,19 @@ export async function updateStoredChatThread(
export async function deleteStoredChatThreads(
idsToDelete: string[],
): Promise<void> {
if (idsToDelete.length === 0) return;
await deleteChatThreads(idsToDelete);
// Incognito threads were never stored, so there's nothing to delete --
// drop them to skip the no-op backend DELETE (and the history-refresh
// event it would fire) when the active temporary chat is closed.
const ids = idsToDelete.filter((id) => !isThreadIncognito(id));
if (ids.length === 0) return;
await deleteChatThreads(ids);
await db
.transaction("rw", db.threads, db.messages, async () => {
await db.messages.where("threadId").anyOf(idsToDelete).delete();
await db.threads.bulkDelete(idsToDelete);
await db.messages.where("threadId").anyOf(ids).delete();
await db.threads.bulkDelete(ids);
})
.catch(() => undefined);
markChatThreadsDeleted(idsToDelete);
markChatThreadsDeleted(ids);
}
export async function countStoredChats(): Promise<number> {

View file

@ -0,0 +1,10 @@
// SPDX-License-Identifier: AGPL-3.0-only
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
const ASSISTANT_LOCAL_THREAD_ID_PREFIX = "__LOCALID_";
export function isAssistantLocalThreadId(
threadId: string | null | undefined,
): boolean {
return Boolean(threadId?.startsWith(ASSISTANT_LOCAL_THREAD_ID_PREFIX));
}