From bf24fb85bb34cdf53fe906de1f81aa6e6c6e810d Mon Sep 17 00:00:00 2001 From: Gagan Date: Fri, 10 Jul 2026 01:10:22 +0530 Subject: [PATCH] feat(x): rename, pin, and delete chats from the sidebar - Hover kebab menu on sidebar chat rows with Pin / Rename / Delete - Rename: inline edit persisted via the existing sessions:setTitle; custom titles survive since auto-titling only fills empty titles - Pin: up to 3 chats float to the top of the list (localStorage) - Delete: confirm dialog, sessions:delete, closes any open tab --- apps/x/apps/renderer/src/App.tsx | 14 ++ .../src/components/sidebar-content.tsx | 161 +++++++++++++++++- 2 files changed, 166 insertions(+), 9 deletions(-) diff --git a/apps/x/apps/renderer/src/App.tsx b/apps/x/apps/renderer/src/App.tsx index f401fb0f..14b2ba2f 100644 --- a/apps/x/apps/renderer/src/App.tsx +++ b/apps/x/apps/renderer/src/App.tsx @@ -6163,6 +6163,20 @@ function App() { onOpenApps={openAppsView} recentRuns={runs} onOpenRun={(rid) => void navigateToView({ type: 'chat', runId: rid })} + onRenameRun={(rid, title) => { + void window.ipc.invoke('sessions:setTitle', { sessionId: rid, title }) + .then(() => setRuns((prev) => prev.map((r) => (r.id === rid ? { ...r, title } : r)))) + .catch((err) => console.error('Failed to rename chat:', err)) + }} + onDeleteRun={(rid) => { + void window.ipc.invoke('sessions:delete', { sessionId: rid }) + .then(() => { + setRuns((prev) => prev.filter((r) => r.id !== rid)) + const openTab = chatTabs.find((t) => t.runId === rid) + if (openTab) closeChatTab(openTab.id) + }) + .catch((err) => console.error('Failed to delete chat:', err)) + }} onOpenChatHistory={() => void navigateToView({ type: 'chat-history' })} onOpenEmail={(threadId) => openEmailView(threadId)} onOpenHome={() => void navigateToView({ type: 'home' })} diff --git a/apps/x/apps/renderer/src/components/sidebar-content.tsx b/apps/x/apps/renderer/src/components/sidebar-content.tsx index 569f9de6..2d17d957 100644 --- a/apps/x/apps/renderer/src/components/sidebar-content.tsx +++ b/apps/x/apps/renderer/src/components/sidebar-content.tsx @@ -15,7 +15,11 @@ import { Home, LayoutGrid, Mic, + MoreVertical, + Pencil, + Pin, SquarePen, + Trash2, Plug, LoaderIcon, Mail, @@ -61,6 +65,12 @@ import { TooltipContent, TooltipTrigger, } from "@/components/ui/tooltip" +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu" import { cn } from "@/lib/utils" import { isOutOfCredits, CREDIT_EXHAUSTED_EVENT, CREDIT_REPLENISHED_EVENT } from "@/lib/credit-status" import { SettingsDialog } from "@/components/settings-dialog" @@ -127,6 +137,8 @@ type ServiceEventType = z.infer const MAX_SYNC_EVENTS = 1000 const RUN_STALE_MS = 2 * 60 * 60 * 1000 +const PINNED_CHATS_STORAGE_KEY = 'x:pinned-chats' +const MAX_PINNED_CHATS = 3 const SERVICE_LABELS: Record = { gmail: "Syncing Gmail", @@ -171,6 +183,10 @@ type SidebarContentPanelProps = { onOpenAgent?: (slug: string) => void recentRuns?: { id: string; title?: string; createdAt: string; modifiedAt?: string }[] onOpenRun?: (runId: string) => void + /** Persist a custom chat title (sessions:setTitle) and refresh the runs list. */ + onRenameRun?: (runId: string, title: string) => void + /** Delete the chat's session (sessions:delete) and refresh the runs list. */ + onDeleteRun?: (runId: string) => void onOpenChatHistory?: () => void onOpenEmail?: (threadId?: string) => void onOpenHome?: () => void @@ -422,6 +438,8 @@ export function SidebarContentPanel({ onOpenApps, recentRuns = [], onOpenRun, + onRenameRun, + onDeleteRun, onOpenChatHistory, onOpenEmail, onOpenHome, @@ -548,16 +566,54 @@ export function SidebarContentPanel({ .slice(0, 10) }, [tree]) - // Chats: the 5 most recently modified chats, newest first. + // Pinned chats: a per-machine UI preference, persisted in localStorage. + const [pinnedChatIds, setPinnedChatIds] = useState(() => { + try { + const raw = window.localStorage.getItem(PINNED_CHATS_STORAGE_KEY) + const parsed: unknown = raw ? JSON.parse(raw) : [] + return Array.isArray(parsed) ? parsed.filter((x): x is string => typeof x === 'string') : [] + } catch { + return [] + } + }) + const toggleChatPin = useCallback((chatId: string) => { + const isPinned = pinnedChatIds.includes(chatId) + if (!isPinned && pinnedChatIds.length >= MAX_PINNED_CHATS) { + toast(`You can pin up to ${MAX_PINNED_CHATS} chats`, 'error') + return + } + const next = isPinned ? pinnedChatIds.filter((id) => id !== chatId) : [...pinnedChatIds, chatId] + try { + window.localStorage.setItem(PINNED_CHATS_STORAGE_KEY, JSON.stringify(next)) + } catch { /* ignore */ } + setPinnedChatIds(next) + }, [pinnedChatIds]) + + // Chats: pinned first, then the most recently modified, 10 rows total. const recentChats = React.useMemo(() => { const chatRecency = (r: { createdAt: string; modifiedAt?: string }) => { const ms = new Date(r.modifiedAt ?? r.createdAt).getTime() return Number.isFinite(ms) ? ms : 0 } - return [...recentRuns] - .sort((a, b) => chatRecency(b) - chatRecency(a)) - .slice(0, 10) - }, [recentRuns]) + const sorted = [...recentRuns].sort((a, b) => chatRecency(b) - chatRecency(a)) + const pinned = sorted.filter((r) => pinnedChatIds.includes(r.id)) + const rest = sorted.filter((r) => !pinnedChatIds.includes(r.id)) + return [...pinned, ...rest.slice(0, Math.max(0, 10 - pinned.length))] + }, [recentRuns, pinnedChatIds]) + + // Chat pending delete confirmation, if any. + const [deleteChatTarget, setDeleteChatTarget] = useState<{ id: string; title: string } | null>(null) + + // Inline chat rename: which row is editing and its draft text. + const [renamingChatId, setRenamingChatId] = useState(null) + const [renameDraft, setRenameDraft] = useState('') + const commitChatRename = useCallback((chatId: string) => { + const title = renameDraft.trim() + const current = recentChats.find((c) => c.id === chatId) + setRenamingChatId(null) + if (!title || title === (current?.title ?? '')) return + onRenameRun?.(chatId, title) + }, [renameDraft, recentChats, onRenameRun]) // Workspace count for the Workspaces sublabel — top-level dir children of // knowledge/Workspace (matches WorkspaceView's root listing). @@ -971,10 +1027,75 @@ export function SidebarContentPanel({ {recentChats.map((chat) => ( - onOpenRun?.(chat.id)}> - - {chat.title || '(Untitled chat)'} - + {renamingChatId === chat.id ? ( +
+ + setRenameDraft(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') { + e.preventDefault() + commitChatRename(chat.id) + } else if (e.key === 'Escape') { + e.preventDefault() + setRenamingChatId(null) + } + }} + onBlur={() => commitChatRename(chat.id)} + className="h-6 min-w-0 flex-1 rounded-sm border border-border bg-background px-1.5 text-sm outline-none focus:ring-1 focus:ring-ring" + /> +
+ ) : ( + <> + onOpenRun?.(chat.id)} className={onRenameRun ? 'pr-7' : undefined}> + + {chat.title || '(Untitled chat)'} + {pinnedChatIds.includes(chat.id) && ( + + )} + + {onRenameRun && ( + + + + + + toggleChatPin(chat.id)}> + + {pinnedChatIds.includes(chat.id) ? 'Unpin' : 'Pin'} + + { + setRenameDraft(chat.title || '') + setRenamingChatId(chat.id) + }} + > + + Rename + + {onDeleteRun && ( + setDeleteChatTarget({ id: chat.id, title: chat.title || '(Untitled chat)' })} + > + + Delete + + )} + + + )} + + )}
))} {onOpenChatHistory && ( @@ -993,6 +1114,28 @@ export function SidebarContentPanel({ )} + { if (!open) setDeleteChatTarget(null) }}> + + + Delete chat? + + “{deleteChatTarget?.title}” and its full history will be permanently deleted. + + + + Cancel + { + if (deleteChatTarget) onDeleteRun?.(deleteChatTarget.id) + setDeleteChatTarget(null) + }} + > + Delete + + + + {/* Billing / upgrade CTA or Log in CTA */} {isRowboatConnected && billing ? (() => {