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
99 changes: 57 additions & 42 deletions apps/desktop/src/components/chat/conversation-header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { Link, useNavigate } from "react-router";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import type { Agent, Conversation, ConversationWithMessages } from "@godmode/shared";
import { EFFORT_LABELS } from "@godmode/shared";
import { Archive, ArchiveRestore, AudioLines, ChevronRight, Cpu, Ellipsis, HeartPulse, Laptop, Moon, Pencil, Pin, PinOff, Share2, SquareKanban, Trash2, Plug, Workflow } from "lucide-react";
import { Archive, ArchiveRestore, AudioLines, ChevronRight, Cpu, Ellipsis, HeartPulse, Laptop, Moon, Pencil, Pin, PinOff, Share2, SquareKanban, Trash2, Plug, Workflow, X } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import {
Expand All @@ -20,6 +20,7 @@ import type { AgentMood } from "@/components/chat/conversation-mood";
import { PLATFORMS } from "@/components/messaging/platform";
import { THIS_COMPUTER_INLINE } from "@/components/runners/runner-parts";
import { DeleteChatDialog, useArchiveChat } from "@/components/chat/chat-actions";
import { PrivateChatBadge } from "@/components/chat/private-chat";
import { useEffectiveModel } from "@/components/chat/model-picker";
import { useModelLabel } from "@/components/runs/run-status";
import { api, errorMessage } from "@/lib/api";
Expand Down Expand Up @@ -183,6 +184,7 @@ export function ConversationHeader({
<TooltipContent>Model, effort and Ultracode set for this chat — in the model picker or with /model and /effort</TooltipContent>
</Tooltip>
)}
{conversation.private && <PrivateChatBadge />}
{conversation.archived && (
<Link
to="/archived"
Expand All @@ -195,21 +197,23 @@ export function ConversationHeader({

<div className="flex shrink-0 items-center gap-0.5">
{browserToggle}
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon-sm"
aria-label={conversation.pinned ? "Unpin chat" : "Pin chat"}
aria-pressed={conversation.pinned}
onClick={() => update.mutate({ pinned: !conversation.pinned })}
className={cn("text-muted-foreground", conversation.pinned && "text-foreground")}
>
<Pin className={cn(conversation.pinned && "fill-current")} />
</Button>
</TooltipTrigger>
<TooltipContent>{conversation.pinned ? "Unpin" : "Pin to sidebar"}</TooltipContent>
</Tooltip>
{!conversation.private && (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon-sm"
aria-label={conversation.pinned ? "Unpin chat" : "Pin chat"}
aria-pressed={conversation.pinned}
onClick={() => update.mutate({ pinned: !conversation.pinned })}
className={cn("text-muted-foreground", conversation.pinned && "text-foreground")}
>
<Pin className={cn(conversation.pinned && "fill-current")} />
</Button>
</TooltipTrigger>
<TooltipContent>{conversation.pinned ? "Unpin" : "Pin to sidebar"}</TooltipContent>
</Tooltip>
)}
{onVoiceMode && (
<Tooltip>
<TooltipTrigger asChild>
Expand All @@ -220,33 +224,44 @@ export function ConversationHeader({
<TooltipContent>Voice mode</TooltipContent>
</Tooltip>
)}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon-sm" aria-label="More actions" className="text-muted-foreground">
<Ellipsis />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-48">
<DropdownMenuItem onClick={() => setEditing(true)}>
<Pencil /> Rename
</DropdownMenuItem>
<DropdownMenuItem onClick={() => update.mutate({ pinned: !conversation.pinned })}>
{conversation.pinned ? <PinOff /> : <Pin />} {conversation.pinned ? "Unpin" : "Pin"}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setArchived(conversation, !conversation.archived)}>
{conversation.archived ? <ArchiveRestore /> : <Archive />} {conversation.archived ? "Unarchive" : "Archive"}
</DropdownMenuItem>
{move && (
<DropdownMenuItem disabled={!!move.blocked} title={move.blocked ?? undefined} onClick={move.onSelect}>
<Laptop /> {move.label ?? `Move to ${THIS_COMPUTER_INLINE}`}
{conversation.private ? (
<Tooltip>
<TooltipTrigger asChild>
<Button variant="ghost" size="sm" aria-label="End private chat" className="gap-1 text-muted-foreground" onClick={() => navigate("/")}>
<X /> End
</Button>
</TooltipTrigger>
<TooltipContent>End and wipe this chat</TooltipContent>
</Tooltip>
) : (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon-sm" aria-label="More actions" className="text-muted-foreground">
<Ellipsis />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-48">
<DropdownMenuItem onClick={() => setEditing(true)}>
<Pencil /> Rename
</DropdownMenuItem>
<DropdownMenuItem onClick={() => update.mutate({ pinned: !conversation.pinned })}>
{conversation.pinned ? <PinOff /> : <Pin />} {conversation.pinned ? "Unpin" : "Pin"}
</DropdownMenuItem>
)}
<DropdownMenuSeparator />
<DropdownMenuItem variant="destructive" onClick={() => setConfirmDelete(true)}>
<Trash2 /> Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<DropdownMenuItem onClick={() => setArchived(conversation, !conversation.archived)}>
{conversation.archived ? <ArchiveRestore /> : <Archive />} {conversation.archived ? "Unarchive" : "Archive"}
</DropdownMenuItem>
{move && (
<DropdownMenuItem disabled={!!move.blocked} title={move.blocked ?? undefined} onClick={move.onSelect}>
<Laptop /> {move.label ?? `Move to ${THIS_COMPUTER_INLINE}`}
</DropdownMenuItem>
)}
<DropdownMenuSeparator />
<DropdownMenuItem variant="destructive" onClick={() => setConfirmDelete(true)}>
<Trash2 /> Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
</div>

<DeleteChatDialog
Expand Down
104 changes: 104 additions & 0 deletions apps/desktop/src/components/chat/private-chat.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
import { useEffect } from "react";
import { useQueryClient } from "@tanstack/react-query";
import { Ghost } from "lucide-react";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { Kbd } from "@/components/common";
import { api } from "@/lib/api";
import { isMac, modKey } from "@/lib/desktop";
import { qk } from "@/lib/queryKeys";
import { cn } from "@/lib/utils";

export const PRIVATE_CHAT_HINT = "Nothing is saved: no history, no memory. It's gone when you leave.";

/** Switches the new chat to private (Home, top right). */
export function PrivateChatToggle({ value, onChange, className }: { value: boolean; onChange: (on: boolean) => void; className?: string }) {
return (
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
aria-pressed={value}
aria-keyshortcuts={isMac ? "Meta+Shift+N" : "Control+Shift+N"}
onClick={() => onChange(!value)}
className={cn(
"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",
value
? "border-foreground bg-foreground text-background hover:bg-foreground/90"
: "bg-card text-muted-foreground hover:border-foreground/20 hover:text-foreground",
className,
)}
>
<Ghost className="size-3.5" />
{value ? "Private on" : "Private"}
</button>
</TooltipTrigger>
<TooltipContent className="max-w-60 text-center">
{value ? "Back to a normal chat" : "Start a private chat. Nothing is saved."}
<span className="ml-1.5 inline-flex gap-0.5 align-middle opacity-80">
<Kbd>⇧</Kbd>
<Kbd>{modKey}N</Kbd>
</span>
</TooltipContent>
</Tooltip>
);
}

/** Next to the title of a private chat. */
export function PrivateChatBadge() {
return (
<Tooltip>
<TooltipTrigger asChild>
<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">
<Ghost className="size-3" /> Private
</span>
</TooltipTrigger>
<TooltipContent className="max-w-60 text-center">{PRIVATE_CHAT_HINT}</TooltipContent>
</Tooltip>
);
}

/** Above the thread of a private chat. */
export function PrivateChatNote() {
return (
<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">
<Ghost className="size-3.5 shrink-0" />
<span>
<span className="font-medium text-foreground/80">Private chat.</span> {PRIVATE_CHAT_HINT}
</span>
</div>
);
}

const pending = new Map<string, ReturnType<typeof setTimeout>>();

function discard(id: string) {
void api.conversations.discard(id).catch(() => {
// Godmode wipes what is left of it when it starts again.
});
}

/**
* A private chat lives while it is open: leaving it (another chat, Home, closing the window) wipes it. A remount of the
* same chat right after (React's strict mode, a layout switch) keeps it.
*/
export function usePrivateChatLifetime(conversationId: string, isPrivate: boolean) {
const qc = useQueryClient();
useEffect(() => {
if (!isPrivate) return;
clearTimeout(pending.get(conversationId));
pending.delete(conversationId);
const onHide = () => discard(conversationId);
window.addEventListener("pagehide", onHide);
return () => {
window.removeEventListener("pagehide", onHide);
pending.set(
conversationId,
setTimeout(() => {
pending.delete(conversationId);
discard(conversationId);
qc.removeQueries({ queryKey: qk.conversation(conversationId) });
}, 400),
);
};
}, [conversationId, isPrivate, qc]);
}
3 changes: 3 additions & 0 deletions apps/desktop/src/components/layout/app-shell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,9 @@ export function AppShell({ children }: { children: ReactNode }) {
} else if (mod && e.key.toLowerCase() === "n" && !e.shiftKey) {
e.preventDefault();
navigate("/");
} else if (mod && e.shiftKey && !e.altKey && letterOf(e) === "n") {
e.preventDefault();
navigate("/?private=1");
} else if (mod && e.key === ",") {
e.preventDefault();
navigate("/settings/general");
Expand Down
4 changes: 4 additions & 0 deletions apps/desktop/src/components/layout/command-palette.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {
DatabaseBackup,
Fingerprint,
Gauge,
Ghost,
Globe,
HeartPulse,
Inbox,
Expand Down Expand Up @@ -263,6 +264,9 @@ export function CommandPalette() {
<CommandItem onSelect={() => go("/")}>
<MessageSquarePlus /> New chat <CommandShortcut>{modKey}N</CommandShortcut>
</CommandItem>
<CommandItem onSelect={() => go("/?private=1")}>
<Ghost /> New private chat <CommandShortcut>⇧{modKey}N</CommandShortcut>
</CommandItem>
<CommandItem onSelect={() => go("/agents/new")}>
<Plus /> Create agent
</CommandItem>
Expand Down
1 change: 1 addition & 0 deletions apps/desktop/src/components/layout/shortcuts-dialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ const GROUPS: { title: string; rows: { keys: ReactNode[]; what: string }[] }[] =
rows: [
{ keys: [`${modKey}K`], what: "Search chats, tickets, automations, settings — and what waits for you" },
{ keys: [`${modKey}N`], what: "New chat" },
{ keys: ["⇧", `${modKey}N`], what: "New private chat (nothing is saved)" },
{ keys: [`${modKey}B`], what: "Show or hide the sidebar" },
{ keys: [`${modKey}1–9`], what: "Switch to a workspace (in A–Z order)" },
{ keys: [`${modKey}0`], what: "All workspaces" },
Expand Down
2 changes: 2 additions & 0 deletions apps/desktop/src/lib/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -663,6 +663,8 @@ export const api = {
create: (input: { agentId: string; title?: string; workspaceId?: string | null; projectId?: string | null }) => post<Conversation>("/api/conversations", input),
update: (id: string, input: ConversationPatch) => patch<Conversation>(`/api/conversations/${id}`, input),
delete: (id: string) => del<{ ok: true }>(`/api/conversations/${id}`),
/** Wipe a private chat; goes out even while the page closes. */
discard: (id: string) => request<{ ok: true }>("DELETE", `/api/conversations/${id}`, undefined, { keepalive: true }),
/** A chat on a runner moves to this computer, with its Claude session; the runner drops its copy. */
moveHere: (id: string) => post<Conversation>(`/api/conversations/${id}/move-here`),
/** To another computer of the group or a runner (`to`; null = the primary / this computer). `force`: also while it works. */
Expand Down
5 changes: 4 additions & 1 deletion apps/desktop/src/pages/chat/chat-conversation.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import { Composer, type ComposerHandle } from "@/components/chat/composer";
import { QueueTray, type QueueTrayHandle } from "@/components/chat/queue-tray";
import { useArchiveChat } from "@/components/chat/chat-actions";
import { ConversationHeader } from "@/components/chat/conversation-header";
import { PrivateChatNote, usePrivateChatLifetime } from "@/components/chat/private-chat";
import { useConversationMood } from "@/components/chat/conversation-mood";
import { FollowupBar } from "@/components/chat/followup";
import { HumanTaskBar } from "@/components/human-tasks/human-task-chat";
Expand Down Expand Up @@ -81,6 +82,7 @@ function ConversationView({ conversationId }: { conversationId: string }) {
const markVoiceRun = useVoiceSession((s) => s.markVoiceRun);
const composerRef = useRef<ComposerHandle>(null);
useViewing(conversationId);
usePrivateChatLifetime(conversationId, !!conv?.private);
const queueRef = useRef<QueueTrayHandle>(null);
const mountedAt = useRef(Date.now());
// A profile picked mid-run applies from the next message: keep showing the browser the running agent drives.
Expand Down Expand Up @@ -529,6 +531,7 @@ function ConversationView({ conversationId }: { conversationId: string }) {
</>
}
/>
{conv.private && <PrivateChatNote />}

<MoveChatDialog
conversation={conv}
Expand Down Expand Up @@ -673,7 +676,7 @@ function ConversationView({ conversationId }: { conversationId: string }) {
/>
<Composer
ref={composerRef}
draftKey={conversationId}
draftKey={conv.private ? undefined : conversationId}
agentId={conv.agentId}
autoFocus
running={!!activeRunId && !paused}
Expand Down
Loading
Loading