diff --git a/docs/changelog.json b/docs/changelog.json
index 770ede9..96f29f0 100644
--- a/docs/changelog.json
+++ b/docs/changelog.json
@@ -1,4 +1,10 @@
[
+ {
+ "date": "2026-10-13",
+ "area": "Chat",
+ "title": "Going incognito from a chat makes an incognito copy and leaves the chat alone",
+ "summary": "Typing /incognito in an open chat, or the new eye button beside Fork in the chat header, now continues in an incognito copy of the chat; the chat you were in stays a normal chat. On a blank chat, /incognito still starts a new incognito chat. A chat started from Notes → Brainstorm, or after opening another chat, is no longer made incognito by an incognito draft left over from earlier."
+ },
{
"date": "2026-10-11",
"area": "Community",
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 && (