Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions docs/changelog.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
4 changes: 3 additions & 1 deletion server/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
44 changes: 44 additions & 0 deletions tests/incognito_fork_test.py
Original file line number Diff line number Diff line change
@@ -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()
30 changes: 23 additions & 7 deletions ui/src/App.tsx
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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]);
Expand Down Expand Up @@ -463,16 +466,24 @@ 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);
} catch (err: unknown) {
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 {
Expand Down Expand Up @@ -716,6 +727,11 @@ export function App() {
<button className={btn.icon} onClick={() => forkChat(conv.id)} title={t('Fork this conversation')}>
<GitFork className="w-3.5 h-3.5 text-[var(--text-muted)] hover:text-[var(--text-primary)]" />
</button>
{!incognito && messages.length > 0 && (
<button className={btn.icon} onClick={goIncognito} title={t('Continue in an incognito copy (this chat stays as it is)')}>
<EyeOff className="w-3.5 h-3.5 text-[var(--text-muted)] hover:text-[var(--text-primary)]" />
</button>
)}
{messages.length > 4 && (
<button className={btn.icon} onClick={() => compactChat(conv.id)} title={t('Compact conversation (summarize older history to save context)')}>
<Archive className="w-3.5 h-3.5 text-[var(--text-muted)] hover:text-[var(--text-primary)]" />
Expand Down Expand Up @@ -930,7 +946,7 @@ export function App() {
onEffort={e => setEfforts(prev => ({ ...prev, [agent.id]: e }))}
modelChoices={modelChoices} onModel={m => setModelPicks(prev => ({ ...prev, [agent.id]: m }))}
onAgent={selectAgent} busy={busy || !!conv?.streaming} onSend={(t, a, o) => send(t, a, undefined, o?.agentId, o?.mode)} onStop={stop}
convId={conv?.id} onCommand={c => newChat(undefined, c === 'incognito')} planMode={planMode} onPlanMode={setPlanMode}
convId={conv?.id} onCommand={c => (c === 'incognito' ? goIncognito() : newChat())} planMode={planMode} onPlanMode={setPlanMode}
ensureConversation={ensureConversation} voiceOk={voiceOk} focusKey={`${conv?.id ?? 'new'}:${focusTick}`}
onVoiceMode={voiceMode ? undefined : () => setVoiceMode(true)} inbox guest={isCell()}
/>
Expand Down
2 changes: 1 addition & 1 deletion ui/src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -155,7 +155,7 @@ export const api = {
patchConversation: (id: string, patch: Partial<ConversationSummary>) =>
req<ConversationSummary>(`/api/conversations/${id}`, { method: 'PATCH', body: json(patch) }),
deleteConversation: (id: string) => req(`/api/conversations/${id}`, { method: 'DELETE' }),
forkConversation: (id: string, body?: { messageId?: string; title?: string }) =>
forkConversation: (id: string, body?: { messageId?: string; title?: string; incognito?: boolean }) =>
req<ConversationSummary>(`/api/conversations/${id}/fork`, { method: 'POST', body: json(body || {}) }),
compactConversation: (id: string, body?: { keepRecent?: number }) =>
req<{ ok: boolean; compacted: boolean; summary?: string; compactedCount?: number; remainingCount?: number; message?: string }>(
Expand Down
2 changes: 1 addition & 1 deletion ui/src/components/ComposerShortcuts.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -181,7 +181,7 @@ export function useShortcuts(o: Opts) {
run: () => { replaceToken(''); o.onCommand?.('new'); } });
}
if (o.onCommand && match(q, 'incognito', 'private')) {
out.push({ key: 'incognito', group: 'Actions', label: '/incognito', hint: 'New chat that isn’t remembered and is deleted after 24 h',
out.push({ key: 'incognito', group: 'Actions', label: '/incognito', hint: 'Incognito copy of this chat (it stays as it is), or a new incognito chat',
icon: <EyeOff className="w-3.5 h-3.5" />, run: () => { replaceToken(''); o.onCommand?.('incognito'); } });
}
if (o.onPlanMode && match(q, 'plan')) {
Expand Down
1 change: 1 addition & 0 deletions ui/src/i18n/fr.json
Original file line number Diff line number Diff line change
Expand Up @@ -1252,6 +1252,7 @@
"Fork from here": "Dupliquer à partir d’ici",
"fork of": "copie de",
"Fork this conversation": "Dupliquer cette conversation",
"Continue in an incognito copy (this chat stays as it is)": "Continuer dans une copie incognito (cette conversation reste telle quelle)",
"Forward 10 seconds": "Avancer de 10 secondes",
"Found on disk": "Trouvé sur le disque",
"Foundation": "Fondations",
Expand Down
23 changes: 19 additions & 4 deletions ui/src/kiosk/KioskApp.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -520,6 +520,7 @@ export function KioskApp() {
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
setAgentId(c.agentId);
// Ensure chat window is open and focused
const chatWin = windows.find(w => w.appId === 'chat');
Expand Down Expand Up @@ -565,6 +566,18 @@ export function KioskApp() {
},
[settings, windows, focusWindow, openApp, setConv]
);
// /incognito in an open chat forks it into an incognito copy (server/privacy.py); the chat itself never changes
const goIncognito = useCallback(async () => {
const c = convRef.current;
if (!c || c.incognito || !c.messages.length) return newChat(undefined, true);
try {
const fork = await api.forkConversation(c.id, { incognito: true });
loadConvs();
openConversation(fork.id);
} catch (err: unknown) {
alert(`Failed to fork conversation: ${err instanceof Error ? err.message : String(err)}`);
}
}, [convRef, newChat, loadConvs, openConversation]);

useEffect(() => {
const onKey = (e: KeyboardEvent) => {
Expand All @@ -582,10 +595,12 @@ export function KioskApp() {
}, [kioskLocked, 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);
Expand Down Expand Up @@ -890,7 +905,7 @@ export function KioskApp() {
onSend={(t, a, o) => send(t, a, undefined, o?.agentId, o?.mode)}
onStop={stop}
convId={conv?.id}
onCommand={c => newChat(undefined, c === 'incognito')}
onCommand={c => (c === 'incognito' ? goIncognito() : newChat())}
planMode={planMode}
onPlanMode={setPlanMode}
ensureConversation={ensureConversation}
Expand Down Expand Up @@ -919,7 +934,7 @@ export function KioskApp() {
onDiscuss={async (text, note) => {
convRef.current = null; // "in a new chat": never the one that happens to be open
setConv(null);
const id = await ensureConversation();
const id = await ensureConversation(false);
const att = await api.upload(id, note);
send(text, [att]);
openApp('chat');
Expand Down
9 changes: 6 additions & 3 deletions ui/src/mobile/MobileApp.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -285,6 +285,7 @@ export function MobileApp() {
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);
Expand Down Expand Up @@ -375,10 +376,12 @@ export function MobileApp() {
}, [settings, setConv]);
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);
Expand Down Expand Up @@ -775,7 +778,7 @@ export function MobileApp() {
onDiscuss={async (text, note) => {
convRef.current = null; // "in a new chat": never the one that happens to be open
setConv(null);
const id = await ensureConversation();
const id = await ensureConversation(false);
const att = await api.upload(id, note);
send(text, [att]);
}}
Expand Down
Loading