diff --git a/studio/frontend/src/app/routes/__root.tsx b/studio/frontend/src/app/routes/__root.tsx index f5179e68f7..4792e78c4d 100644 --- a/studio/frontend/src/app/routes/__root.tsx +++ b/studio/frontend/src/app/routes/__root.tsx @@ -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 ( diff --git a/studio/frontend/src/components/app-sidebar.tsx b/studio/frontend/src/components/app-sidebar.tsx index d3ba923e69..8087b30b91 100644 --- a/studio/frontend/src/components/app-sidebar.tsx +++ b/studio/frontend/src/components/app-sidebar.tsx @@ -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(); } diff --git a/studio/frontend/src/components/assistant-ui/thread.tsx b/studio/frontend/src/components/assistant-ui/thread.tsx index 9ae9f3c165..839c44d940 100644 --- a/studio/frontend/src/components/assistant-ui/thread.tsx +++ b/studio/frontend/src/components/assistant-ui/thread.tsx @@ -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(DEFAULT_WELCOME); @@ -805,9 +806,15 @@ const ThreadWelcome: FC<{ className="size-[44px] -translate-y-[2px]" />

- {welcome.text} + {incognito ? "Temporary chat" : welcome.text}

+ {incognito && ( +

+ This chat won't appear in your history and isn't saved. It + disappears when you leave. +

+ )} {!hideComposer && } @@ -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: ( - + Export chat diff --git a/studio/frontend/src/features/chat/chat-page.tsx b/studio/frontend/src/features/chat/chat-page.tsx index 0a2809c9d4..8262a52eaa 100644 --- a/studio/frontend/src/features/chat/chat-page.tsx +++ b/studio/frontend/src/features/chat/chat-page.tsx @@ -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" && ( +
+ + Temporary +
+ )} {view.mode !== "compare" && currentProjectId && (