Skip to content

Commit 7a32f5d

Browse files
Merge pull request #264 from codextde/feat/private-chat
Add private chats that keep nothing
2 parents 1f49e43 + f46cdf3 commit 7a32f5d

21 files changed

Lines changed: 422 additions & 84 deletions

File tree

‎apps/desktop/src/components/chat/conversation-header.tsx‎

Lines changed: 57 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { Link, useNavigate } from "react-router";
33
import { useMutation, useQueryClient } from "@tanstack/react-query";
44
import type { Agent, Conversation, ConversationWithMessages } from "@godmode/shared";
55
import { EFFORT_LABELS } from "@godmode/shared";
6-
import { Archive, ArchiveRestore, AudioLines, ChevronRight, Cpu, Ellipsis, HeartPulse, Laptop, Moon, Pencil, Pin, PinOff, Share2, SquareKanban, Trash2, Plug, Workflow } from "lucide-react";
6+
import { Archive, ArchiveRestore, AudioLines, ChevronRight, Cpu, Ellipsis, HeartPulse, Laptop, Moon, Pencil, Pin, PinOff, Share2, SquareKanban, Trash2, Plug, Workflow, X } from "lucide-react";
77
import { toast } from "sonner";
88
import { Button } from "@/components/ui/button";
99
import {
@@ -20,6 +20,7 @@ import type { AgentMood } from "@/components/chat/conversation-mood";
2020
import { PLATFORMS } from "@/components/messaging/platform";
2121
import { THIS_COMPUTER_INLINE } from "@/components/runners/runner-parts";
2222
import { DeleteChatDialog, useArchiveChat } from "@/components/chat/chat-actions";
23+
import { PrivateChatBadge } from "@/components/chat/private-chat";
2324
import { useEffectiveModel } from "@/components/chat/model-picker";
2425
import { useModelLabel } from "@/components/runs/run-status";
2526
import { api, errorMessage } from "@/lib/api";
@@ -183,6 +184,7 @@ export function ConversationHeader({
183184
<TooltipContent>Model, effort and Ultracode set for this chat — in the model picker or with /model and /effort</TooltipContent>
184185
</Tooltip>
185186
)}
187+
{conversation.private && <PrivateChatBadge />}
186188
{conversation.archived && (
187189
<Link
188190
to="/archived"
@@ -195,21 +197,23 @@ export function ConversationHeader({
195197

196198
<div className="flex shrink-0 items-center gap-0.5">
197199
{browserToggle}
198-
<Tooltip>
199-
<TooltipTrigger asChild>
200-
<Button
201-
variant="ghost"
202-
size="icon-sm"
203-
aria-label={conversation.pinned ? "Unpin chat" : "Pin chat"}
204-
aria-pressed={conversation.pinned}
205-
onClick={() => update.mutate({ pinned: !conversation.pinned })}
206-
className={cn("text-muted-foreground", conversation.pinned && "text-foreground")}
207-
>
208-
<Pin className={cn(conversation.pinned && "fill-current")} />
209-
</Button>
210-
</TooltipTrigger>
211-
<TooltipContent>{conversation.pinned ? "Unpin" : "Pin to sidebar"}</TooltipContent>
212-
</Tooltip>
200+
{!conversation.private && (
201+
<Tooltip>
202+
<TooltipTrigger asChild>
203+
<Button
204+
variant="ghost"
205+
size="icon-sm"
206+
aria-label={conversation.pinned ? "Unpin chat" : "Pin chat"}
207+
aria-pressed={conversation.pinned}
208+
onClick={() => update.mutate({ pinned: !conversation.pinned })}
209+
className={cn("text-muted-foreground", conversation.pinned && "text-foreground")}
210+
>
211+
<Pin className={cn(conversation.pinned && "fill-current")} />
212+
</Button>
213+
</TooltipTrigger>
214+
<TooltipContent>{conversation.pinned ? "Unpin" : "Pin to sidebar"}</TooltipContent>
215+
</Tooltip>
216+
)}
213217
{onVoiceMode && (
214218
<Tooltip>
215219
<TooltipTrigger asChild>
@@ -220,33 +224,44 @@ export function ConversationHeader({
220224
<TooltipContent>Voice mode</TooltipContent>
221225
</Tooltip>
222226
)}
223-
<DropdownMenu>
224-
<DropdownMenuTrigger asChild>
225-
<Button variant="ghost" size="icon-sm" aria-label="More actions" className="text-muted-foreground">
226-
<Ellipsis />
227-
</Button>
228-
</DropdownMenuTrigger>
229-
<DropdownMenuContent align="end" className="w-48">
230-
<DropdownMenuItem onClick={() => setEditing(true)}>
231-
<Pencil /> Rename
232-
</DropdownMenuItem>
233-
<DropdownMenuItem onClick={() => update.mutate({ pinned: !conversation.pinned })}>
234-
{conversation.pinned ? <PinOff /> : <Pin />} {conversation.pinned ? "Unpin" : "Pin"}
235-
</DropdownMenuItem>
236-
<DropdownMenuItem onClick={() => setArchived(conversation, !conversation.archived)}>
237-
{conversation.archived ? <ArchiveRestore /> : <Archive />} {conversation.archived ? "Unarchive" : "Archive"}
238-
</DropdownMenuItem>
239-
{move && (
240-
<DropdownMenuItem disabled={!!move.blocked} title={move.blocked ?? undefined} onClick={move.onSelect}>
241-
<Laptop /> {move.label ?? `Move to ${THIS_COMPUTER_INLINE}`}
227+
{conversation.private ? (
228+
<Tooltip>
229+
<TooltipTrigger asChild>
230+
<Button variant="ghost" size="sm" aria-label="End private chat" className="gap-1 text-muted-foreground" onClick={() => navigate("/")}>
231+
<X /> End
232+
</Button>
233+
</TooltipTrigger>
234+
<TooltipContent>End and wipe this chat</TooltipContent>
235+
</Tooltip>
236+
) : (
237+
<DropdownMenu>
238+
<DropdownMenuTrigger asChild>
239+
<Button variant="ghost" size="icon-sm" aria-label="More actions" className="text-muted-foreground">
240+
<Ellipsis />
241+
</Button>
242+
</DropdownMenuTrigger>
243+
<DropdownMenuContent align="end" className="w-48">
244+
<DropdownMenuItem onClick={() => setEditing(true)}>
245+
<Pencil /> Rename
246+
</DropdownMenuItem>
247+
<DropdownMenuItem onClick={() => update.mutate({ pinned: !conversation.pinned })}>
248+
{conversation.pinned ? <PinOff /> : <Pin />} {conversation.pinned ? "Unpin" : "Pin"}
242249
</DropdownMenuItem>
243-
)}
244-
<DropdownMenuSeparator />
245-
<DropdownMenuItem variant="destructive" onClick={() => setConfirmDelete(true)}>
246-
<Trash2 /> Delete
247-
</DropdownMenuItem>
248-
</DropdownMenuContent>
249-
</DropdownMenu>
250+
<DropdownMenuItem onClick={() => setArchived(conversation, !conversation.archived)}>
251+
{conversation.archived ? <ArchiveRestore /> : <Archive />} {conversation.archived ? "Unarchive" : "Archive"}
252+
</DropdownMenuItem>
253+
{move && (
254+
<DropdownMenuItem disabled={!!move.blocked} title={move.blocked ?? undefined} onClick={move.onSelect}>
255+
<Laptop /> {move.label ?? `Move to ${THIS_COMPUTER_INLINE}`}
256+
</DropdownMenuItem>
257+
)}
258+
<DropdownMenuSeparator />
259+
<DropdownMenuItem variant="destructive" onClick={() => setConfirmDelete(true)}>
260+
<Trash2 /> Delete
261+
</DropdownMenuItem>
262+
</DropdownMenuContent>
263+
</DropdownMenu>
264+
)}
250265
</div>
251266

252267
<DeleteChatDialog
Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
import { useEffect } from "react";
2+
import { useQueryClient } from "@tanstack/react-query";
3+
import { Ghost } from "lucide-react";
4+
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
5+
import { Kbd } from "@/components/common";
6+
import { api } from "@/lib/api";
7+
import { isMac, modKey } from "@/lib/desktop";
8+
import { qk } from "@/lib/queryKeys";
9+
import { cn } from "@/lib/utils";
10+
11+
export const PRIVATE_CHAT_HINT = "Nothing is saved: no history, no memory. It's gone when you leave.";
12+
13+
/** Switches the new chat to private (Home, top right). */
14+
export function PrivateChatToggle({ value, onChange, className }: { value: boolean; onChange: (on: boolean) => void; className?: string }) {
15+
return (
16+
<Tooltip>
17+
<TooltipTrigger asChild>
18+
<button
19+
type="button"
20+
aria-pressed={value}
21+
aria-keyshortcuts={isMac ? "Meta+Shift+N" : "Control+Shift+N"}
22+
onClick={() => onChange(!value)}
23+
className={cn(
24+
"inline-flex h-8 items-center gap-1.5 rounded-full border px-3 text-[12.5px] font-medium shadow-card transition focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-none",
25+
value
26+
? "border-foreground bg-foreground text-background hover:bg-foreground/90"
27+
: "bg-card text-muted-foreground hover:border-foreground/20 hover:text-foreground",
28+
className,
29+
)}
30+
>
31+
<Ghost className="size-3.5" />
32+
{value ? "Private on" : "Private"}
33+
</button>
34+
</TooltipTrigger>
35+
<TooltipContent className="max-w-60 text-center">
36+
{value ? "Back to a normal chat" : "Start a private chat. Nothing is saved."}
37+
<span className="ml-1.5 inline-flex gap-0.5 align-middle opacity-80">
38+
<Kbd>⇧</Kbd>
39+
<Kbd>{modKey}N</Kbd>
40+
</span>
41+
</TooltipContent>
42+
</Tooltip>
43+
);
44+
}
45+
46+
/** Next to the title of a private chat. */
47+
export function PrivateChatBadge() {
48+
return (
49+
<Tooltip>
50+
<TooltipTrigger asChild>
51+
<span className="inline-flex shrink-0 items-center gap-1 rounded-full border border-dashed border-foreground/30 px-2 py-0.5 text-[11px] font-medium text-foreground/80">
52+
<Ghost className="size-3" /> Private
53+
</span>
54+
</TooltipTrigger>
55+
<TooltipContent className="max-w-60 text-center">{PRIVATE_CHAT_HINT}</TooltipContent>
56+
</Tooltip>
57+
);
58+
}
59+
60+
/** Above the thread of a private chat. */
61+
export function PrivateChatNote() {
62+
return (
63+
<div className="flex shrink-0 items-center justify-center gap-1.5 border-b border-dashed bg-muted/30 px-4 py-1.5 text-center text-[12px] text-muted-foreground">
64+
<Ghost className="size-3.5 shrink-0" />
65+
<span>
66+
<span className="font-medium text-foreground/80">Private chat.</span> {PRIVATE_CHAT_HINT}
67+
</span>
68+
</div>
69+
);
70+
}
71+
72+
const pending = new Map<string, ReturnType<typeof setTimeout>>();
73+
74+
function discard(id: string) {
75+
void api.conversations.discard(id).catch(() => {
76+
// Godmode wipes what is left of it when it starts again.
77+
});
78+
}
79+
80+
/**
81+
* A private chat lives while it is open: leaving it (another chat, Home, closing the window) wipes it. A remount of the
82+
* same chat right after (React's strict mode, a layout switch) keeps it.
83+
*/
84+
export function usePrivateChatLifetime(conversationId: string, isPrivate: boolean) {
85+
const qc = useQueryClient();
86+
useEffect(() => {
87+
if (!isPrivate) return;
88+
clearTimeout(pending.get(conversationId));
89+
pending.delete(conversationId);
90+
const onHide = () => discard(conversationId);
91+
window.addEventListener("pagehide", onHide);
92+
return () => {
93+
window.removeEventListener("pagehide", onHide);
94+
pending.set(
95+
conversationId,
96+
setTimeout(() => {
97+
pending.delete(conversationId);
98+
discard(conversationId);
99+
qc.removeQueries({ queryKey: qk.conversation(conversationId) });
100+
}, 400),
101+
);
102+
};
103+
}, [conversationId, isPrivate, qc]);
104+
}

‎apps/desktop/src/components/layout/app-shell.tsx‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -169,6 +169,9 @@ export function AppShell({ children }: { children: ReactNode }) {
169169
} else if (mod && e.key.toLowerCase() === "n" && !e.shiftKey) {
170170
e.preventDefault();
171171
navigate("/");
172+
} else if (mod && e.shiftKey && !e.altKey && letterOf(e) === "n") {
173+
e.preventDefault();
174+
navigate("/?private=1");
172175
} else if (mod && e.key === ",") {
173176
e.preventDefault();
174177
navigate("/settings/general");

‎apps/desktop/src/components/layout/command-palette.tsx‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import {
1717
DatabaseBackup,
1818
Fingerprint,
1919
Gauge,
20+
Ghost,
2021
Globe,
2122
HeartPulse,
2223
Inbox,
@@ -263,6 +264,9 @@ export function CommandPalette() {
263264
<CommandItem onSelect={() => go("/")}>
264265
<MessageSquarePlus /> New chat <CommandShortcut>{modKey}N</CommandShortcut>
265266
</CommandItem>
267+
<CommandItem onSelect={() => go("/?private=1")}>
268+
<Ghost /> New private chat <CommandShortcut>⇧{modKey}N</CommandShortcut>
269+
</CommandItem>
266270
<CommandItem onSelect={() => go("/agents/new")}>
267271
<Plus /> Create agent
268272
</CommandItem>

‎apps/desktop/src/components/layout/shortcuts-dialog.tsx‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ const GROUPS: { title: string; rows: { keys: ReactNode[]; what: string }[] }[] =
2424
rows: [
2525
{ keys: [`${modKey}K`], what: "Search chats, tickets, automations, settings — and what waits for you" },
2626
{ keys: [`${modKey}N`], what: "New chat" },
27+
{ keys: ["⇧", `${modKey}N`], what: "New private chat (nothing is saved)" },
2728
{ keys: [`${modKey}B`], what: "Show or hide the sidebar" },
2829
{ keys: [`${modKey}1–9`], what: "Switch to a workspace (in A–Z order)" },
2930
{ keys: [`${modKey}0`], what: "All workspaces" },

‎apps/desktop/src/lib/api.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -663,6 +663,8 @@ export const api = {
663663
create: (input: { agentId: string; title?: string; workspaceId?: string | null; projectId?: string | null }) => post<Conversation>("/api/conversations", input),
664664
update: (id: string, input: ConversationPatch) => patch<Conversation>(`/api/conversations/${id}`, input),
665665
delete: (id: string) => del<{ ok: true }>(`/api/conversations/${id}`),
666+
/** Wipe a private chat; goes out even while the page closes. */
667+
discard: (id: string) => request<{ ok: true }>("DELETE", `/api/conversations/${id}`, undefined, { keepalive: true }),
666668
/** A chat on a runner moves to this computer, with its Claude session; the runner drops its copy. */
667669
moveHere: (id: string) => post<Conversation>(`/api/conversations/${id}/move-here`),
668670
/** To another computer of the group or a runner (`to`; null = the primary / this computer). `force`: also while it works. */

‎apps/desktop/src/pages/chat/chat-conversation.tsx‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import { Composer, type ComposerHandle } from "@/components/chat/composer";
1919
import { QueueTray, type QueueTrayHandle } from "@/components/chat/queue-tray";
2020
import { useArchiveChat } from "@/components/chat/chat-actions";
2121
import { ConversationHeader } from "@/components/chat/conversation-header";
22+
import { PrivateChatNote, usePrivateChatLifetime } from "@/components/chat/private-chat";
2223
import { useConversationMood } from "@/components/chat/conversation-mood";
2324
import { FollowupBar } from "@/components/chat/followup";
2425
import { HumanTaskBar } from "@/components/human-tasks/human-task-chat";
@@ -81,6 +82,7 @@ function ConversationView({ conversationId }: { conversationId: string }) {
8182
const markVoiceRun = useVoiceSession((s) => s.markVoiceRun);
8283
const composerRef = useRef<ComposerHandle>(null);
8384
useViewing(conversationId);
85+
usePrivateChatLifetime(conversationId, !!conv?.private);
8486
const queueRef = useRef<QueueTrayHandle>(null);
8587
const mountedAt = useRef(Date.now());
8688
// A profile picked mid-run applies from the next message: keep showing the browser the running agent drives.
@@ -529,6 +531,7 @@ function ConversationView({ conversationId }: { conversationId: string }) {
529531
</>
530532
}
531533
/>
534+
{conv.private && <PrivateChatNote />}
532535

533536
<MoveChatDialog
534537
conversation={conv}
@@ -673,7 +676,7 @@ function ConversationView({ conversationId }: { conversationId: string }) {
673676
/>
674677
<Composer
675678
ref={composerRef}
676-
draftKey={conversationId}
679+
draftKey={conv.private ? undefined : conversationId}
677680
agentId={conv.agentId}
678681
autoFocus
679682
running={!!activeRunId && !paused}

0 commit comments

Comments
 (0)