From a08ace669ca084082a6362ddee9b45faf8f9f06d Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 13 Oct 2026 01:47:09 +0000 Subject: [PATCH 1/2] Going incognito from an open chat forks it; the original is never flagged MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The fork endpoint takes `incognito: true` and flags only the copy. /incognito and a new header button fork the open chat into an incognito copy (desktop and kiosk); on a blank chat they still start an incognito draft. A leftover incognito draft no longer makes a later chat incognito: opening a chat clears it, creating a chat clears it, and Notes → "Brainstorm in a new chat" always starts a normal chat (desktop, mobile, kiosk). Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01SoiLLmrxcq1QYVAqdPqcsC --- server/app.py | 4 ++- tests/incognito_fork_test.py | 44 +++++++++++++++++++++++++ ui/src/App.tsx | 30 +++++++++++++---- ui/src/api.ts | 2 +- ui/src/components/ComposerShortcuts.tsx | 2 +- ui/src/i18n/fr.json | 1 + ui/src/kiosk/KioskApp.tsx | 23 ++++++++++--- ui/src/mobile/MobileApp.tsx | 9 +++-- 8 files changed, 98 insertions(+), 17 deletions(-) create mode 100644 tests/incognito_fork_test.py diff --git a/server/app.py b/server/app.py index 2388d76..2ce18d8 100644 --- a/server/app.py +++ b/server/app.py @@ -593,7 +593,9 @@ async def fork_conversation(conv_id: str, req: Request): group_id=conv.get("groupId") ) new_cid = new_conv["id"] - if conv.get("incognito"): # a fork of an incognito chat is incognito too + # a fork of an incognito chat is incognito too; `incognito` forks a normal chat into an incognito copy (the + # "Incognito" toggle on an open chat): only the copy is flagged, the original stays as it was + if conv.get("incognito") or body.get("incognito"): import privacy privacy.mark_incognito(new_cid) if conv.get("adult"): # and of an Aphrodite chat, hers diff --git a/tests/incognito_fork_test.py b/tests/incognito_fork_test.py new file mode 100644 index 0000000..00bfcde --- /dev/null +++ b/tests/incognito_fork_test.py @@ -0,0 +1,44 @@ +"""Going incognito from an open chat (privacy.py): the fork endpoint makes an incognito copy and never flags the original.""" +import unittest + +from smoke_test import client, shellb # noqa: F401 + +import db + + +class IncognitoFork(unittest.TestCase): + def setUp(self): + self.conv = db.create_conversation("shellb", title="Personal details") + db.add_message(self.conv["id"], "user", "Here is something about me") + db.add_message(self.conv["id"], "assistant", "Noted", {"agentId": "shellb"}) + self.made = [self.conv["id"]] + + def tearDown(self): + for cid in self.made: + db.delete_conversation(cid) + + def fork(self, cid, **body): + r = client.post(f"/api/conversations/{cid}/fork", json=body) + self.assertEqual(r.status_code, 200, r.text) + self.made.append(r.json()["id"]) + return r.json() + + def test_incognito_fork_leaves_the_original_alone(self): + fork = self.fork(self.conv["id"], incognito=True) + self.assertNotEqual(fork["id"], self.conv["id"]) + self.assertTrue(fork["incognito"]) + self.assertTrue(db.get_conversation(fork["id"])["incognito"]) + self.assertFalse(db.get_conversation(self.conv["id"])["incognito"]) + self.assertEqual([m["content"] for m in db.list_messages(fork["id"])], ["Here is something about me", "Noted"]) + self.assertEqual(len(db.list_messages(self.conv["id"])), 2) + + def test_plain_fork_stays_normal(self): + self.assertFalse(self.fork(self.conv["id"])["incognito"]) + + def test_fork_of_incognito_chat_stays_incognito(self): + inc = self.fork(self.conv["id"], incognito=True) + self.assertTrue(self.fork(inc["id"])["incognito"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/ui/src/App.tsx b/ui/src/App.tsx index 366486b..1b6e18a 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -1,7 +1,7 @@ import { t } from './i18n'; import { Suspense, lazy, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import clsx from 'clsx'; -import { Archive, ArrowDown, Download, GitFork, Moon, PanelLeft, PanelRight, Printer, Radio, Search, Shapes, Smartphone, Sun, Telescope, Workflow } from 'lucide-react'; +import { Archive, ArrowDown, Download, EyeOff, GitFork, Moon, PanelLeft, PanelRight, Printer, Radio, Search, Shapes, Smartphone, Sun, Telescope, Workflow } from 'lucide-react'; import { FormFactorSwitcher } from './mobile/FormFactorSwitcher'; import { api } from './api'; import type { @@ -274,6 +274,7 @@ export function App() { try { const c = await api.conversation(id); setConv(c); + setIncognitoDraft(false); // the chat's own flag decides now; a leftover draft must not make the next new chat incognito setGroupRoute(null); setLibrary(false); setScheduled(false); @@ -378,10 +379,12 @@ export function App() { }, [settings]); useInstalledApp(status, newChat); - const ensureConversation = useCallback(async () => { + // `incognito` defaults to the blank chat on screen; paths that start a chat elsewhere (Notes) pass false + const ensureConversation = useCallback(async (incognito = incognitoDraft) => { if (convRef.current) return convRef.current.id; - const c = await api.createConversation(agentId, pendingGroup.current ?? undefined, !pendingGroup.current && incognitoDraft); + const c = await api.createConversation(agentId, pendingGroup.current ?? undefined, !pendingGroup.current && incognito); pendingGroup.current = null; + setIncognitoDraft(false); const full: Conversation = { ...c, messages: [], artifacts: [] }; convRef.current = full; setConv(full); @@ -426,7 +429,7 @@ export function App() { setCodexRoute(null); pendingGroup.current = null; history.replaceState(null, '', '#/'); - const id = await ensureConversation(); + const id = await ensureConversation(false); const att = await api.upload(id, note); await send(text, [att]); }, [ensureConversation, send]); @@ -463,9 +466,10 @@ export function App() { } }, [conv]); - const forkChat = useCallback(async (convId: string, messageId?: string) => { + /** `incognito`: an incognito copy of the chat (server/privacy.py); the original is left as it was */ + const forkChat = useCallback(async (convId: string, messageId?: string, incognito = false) => { try { - const newConv = await api.forkConversation(convId, { messageId }); + const newConv = await api.forkConversation(convId, { messageId, incognito: incognito || undefined }); await loadConvs(); location.hash = `#/c/${newConv.id}`; setConv(null); @@ -473,6 +477,13 @@ export function App() { alert(`Failed to fork conversation: ${err instanceof Error ? err.message : String(err)}`); } }, [loadConvs]); + // Going incognito from an open chat forks it: the copy is incognito, the chat itself never changes. A blank chat + // just becomes an incognito draft. + const goIncognito = useCallback(() => { + const c = convRef.current; + if (c && !c.incognito && c.messages.length) forkChat(c.id, undefined, true); + else newChat(undefined, true); + }, [convRef, forkChat, newChat]); const compactChat = useCallback(async (convId: string) => { try { @@ -716,6 +727,11 @@ export function App() { + {!incognito && messages.length > 0 && ( + + )} {messages.length > 4 && (