diff --git a/apps/desktop/src/app/command-palette/index.tsx b/apps/desktop/src/app/command-palette/index.tsx index 98a3cf7f39ef..695b1c8975fe 100644 --- a/apps/desktop/src/app/command-palette/index.tsx +++ b/apps/desktop/src/app/command-palette/index.tsx @@ -46,6 +46,7 @@ import { Package, Palette, PawPrint, + Pin, Plus, RefreshCw, Settings, @@ -70,11 +71,13 @@ import { setCommandPaletteOpen } from '@/store/command-palette' import { $bindings, bindingsFor } from '@/store/keybinds' -import { $dismissedAutoProjectIds, filterVisibleProjects } from '@/store/layout' +import { $dismissedAutoProjectIds, $pinnedSessionIds, filterVisibleProjects } from '@/store/layout' import { openPetGenerate } from '@/store/pet-generate' import { openBrowserTab } from '@/store/preview' import { $projectTree, goToProject, openFolderAsProject, requestStartWorkSession } from '@/store/projects' -import { $connection } from '@/store/session' +import { $connection, $cronSessions, $messagingSessions, $sessions } from '@/store/session' +import { $unconfirmedPinWrites } from '@/store/session-pin-sync' +import { $removedSessionIds } from '@/store/session-removal' import { runGatewayRestart } from '@/store/system-actions' import { $backendUpdateApply, @@ -89,6 +92,7 @@ import { luminance } from '@/themes/color' import { type ThemeMode, useTheme } from '@/themes/context' import { isUserTheme, resolveTheme } from '@/themes/user-themes' +import { buildSessionByAnyId, resolvePinnedSessions } from '../chat/sidebar/session-index' import { openSessionFromPicker, openSessionIntentFromModifiers } from '../open-session' import { AGENTS_ROUTE, @@ -393,6 +397,11 @@ const toSessionEntry = (session: SessionRow): SessionEntry => ({ title: sessionTitle(session) }) +// Search terms beyond the label: the preview and branch, so a session is +// findable by what it's about, not only what it's called. +const sessionKeywords = (session: SessionEntry, ...tags: string[]): string[] => + [...tags, 'chat', 'session', session.preview, session.git_branch].filter((word): word is string => !!word) + type NonConfigSettingsLabel = 'about' | 'archivedChats' | 'gateway' | 'keysSettings' | 'keysTools' | 'mcp' | 'providerAccounts' | 'providerApiKeys' @@ -637,21 +646,65 @@ function CommandPaletteBody({ onExited }: { onExited: () => void }) { queryFn: () => getHermesConfigRecord() }) + // staleTime 0 (not the 60s client default): renames, pins, and archives + // happen in the sidebar while this component is unmounted, so nothing can + // invalidate these keys — every open must revalidate. The cached page + // still paints instantly; the live-store overlay below covers the gap. const sessionsQuery = useQuery({ queryKey: ['command-palette', 'sessions'], - queryFn: () => listAllProfileSessions(200, 1, 'exclude') + queryFn: () => listAllProfileSessions(200, 1, 'exclude'), + staleTime: 0 }) const archivedQuery = useQuery({ queryKey: ['command-palette', 'archived'], - queryFn: () => listAllProfileSessions(200, 0, 'only') + queryFn: () => listAllProfileSessions(200, 0, 'only'), + staleTime: 0 }) + const liveSessions = useStore($sessions) + const liveCronSessions = useStore($cronSessions) + const liveMessagingSessions = useStore($messagingSessions) + const pinnedSessionIds = useStore($pinnedSessionIds) + const unconfirmedPinWrites = useStore($unconfirmedPinWrites) + const removedSessionIds = useStore($removedSessionIds) + // getServers is the shared choke point that also drops malformed (null/ // scalar) entries, so the palette never lists a server the MCP tab dropped. const mcpServers = useMemo(() => Object.keys(getServers(configQuery.data ?? null)).sort(), [configQuery.data]) - const sessions = useMemo(() => (sessionsQuery.data?.sessions ?? []).map(toSessionEntry), [sessionsQuery.data]) + // The sidebar's stores are where a rename / pin / archive lands first (the + // server page confirms later). Overlay them on the fetched 200-row page so + // the palette says what the sidebar says: same title, same pin, and no row + // the user just archived or deleted. + const liveRows = useMemo(() => { + const byId = new Map( + [...liveCronSessions, ...liveMessagingSessions, ...liveSessions].map(row => [row.id, row] as const) + ) + + return (sessionsQuery.data?.sessions ?? []) + .filter(session => !removedSessionIds.has(session.id)) + .map(session => { + const live = byId.get(session.id) + + return live ? { ...session, pinned: live.pinned, title: live.title } : session + }) + }, [liveCronSessions, liveMessagingSessions, liveSessions, removedSessionIds, sessionsQuery.data]) + + // Same resolution as the sidebar's Pinned section: local pin order first, + // then server-flagged pins, minus our own in-flight unpins. + const pinnedSessions = useMemo(() => { + const byAnyId = buildSessionByAnyId(liveRows, [], []) + + return resolvePinnedSessions(pinnedSessionIds, byAnyId, liveRows, unconfirmedPinWrites).map(toSessionEntry) + }, [liveRows, pinnedSessionIds, unconfirmedPinWrites]) + + const sessions = useMemo(() => { + const pinned = new Set(pinnedSessions.map(session => session.id)) + + return liveRows.filter(session => !pinned.has(session.id)).map(toSessionEntry) + }, [liveRows, pinnedSessions]) + const archivedSessions = useMemo(() => (archivedQuery.data?.sessions ?? []).map(toSessionEntry), [archivedQuery.data]) // Search/sub-page are local to a mount, and this component remounts per open @@ -1162,18 +1215,28 @@ function CommandPaletteBody({ onExited }: { onExited: () => void }) { })) }) + // Pinned before Sessions: rankGroups' stable sort keeps source order on + // equal scores, so a pin wins a tie with an unpinned row of the same name. + if (pinnedSessions.length > 0) { + result.push({ + heading: t.sidebar.pinned, + items: pinnedSessions.map(session => ({ + icon: Pin, + id: `pinned-${session.id}`, + keywords: sessionKeywords(session, 'pinned'), + label: session.title, + runWithEvent: goSession(session.id) + })) + }) + } + if (sessions.length > 0) { result.push({ heading: t.commandCenter.sections.sessions, items: sessions.map(session => ({ icon: MessageCircle, id: `session-${session.id}`, - keywords: [ - 'chat', - 'session', - ...(session.preview ? [session.preview] : []), - ...(session.git_branch ? [session.git_branch] : []) - ], + keywords: sessionKeywords(session), label: session.title, runWithEvent: goSession(session.id) })) @@ -1230,13 +1293,7 @@ function CommandPaletteBody({ onExited }: { onExited: () => void }) { items: archivedSessions.map(session => ({ icon: Archive, id: `archived-${session.id}`, - keywords: [ - 'archived', - 'chat', - 'session', - ...(session.preview ? [session.preview] : []), - ...(session.git_branch ? [session.git_branch] : []) - ], + keywords: sessionKeywords(session, 'archived'), label: session.title, run: go(`${SETTINGS_ROUTE}?tab=sessions&session=${encodeURIComponent(session.id)}`) })) @@ -1251,6 +1308,7 @@ function CommandPaletteBody({ onExited }: { onExited: () => void }) { goSession, mcpServers, mode, + pinnedSessions, previewTheme, resolvedMode, resolveThemeMode,