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:
parent
f372da407b
commit
da3f7ac085
8 changed files with 178 additions and 14 deletions
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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}>
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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> {
|
||||
|
|
|
|||
10
studio/frontend/src/features/chat/utils/thread-ids.ts
Normal file
10
studio/frontend/src/features/chat/utils/thread-ids.ts
Normal 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));
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue