From a11611af39d8514ec483a2856701b079b0a1bb58 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Thu, 30 Jul 2026 01:15:01 -0500 Subject: [PATCH] feat(desktop): projects in the command palette MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ⌘K gains a Projects group carrying each project's own sidebar codicon. Selecting one is a pure scope switch; holding ⌘/⌃ previews the variant — the label swaps to 'New session in ' beside a ⌘↵ chip — and ⌘-Enter runs it. A pinned row opens the native picker, and typing an absolute path offers the same upsert inline. modLabel/comboHint live on PaletteItem, so the next modifier-variant row gets both for free. --- .../desktop/src/app/command-palette/index.tsx | 113 ++++++++++++++++-- apps/desktop/src/i18n/en.ts | 5 + apps/desktop/src/i18n/types.ts | 4 + apps/desktop/src/i18n/zh.ts | 5 + 4 files changed, 120 insertions(+), 7 deletions(-) diff --git a/apps/desktop/src/app/command-palette/index.tsx b/apps/desktop/src/app/command-palette/index.tsx index a0279c495ee..0a444d00c37 100644 --- a/apps/desktop/src/app/command-palette/index.tsx +++ b/apps/desktop/src/app/command-palette/index.tsx @@ -6,6 +6,7 @@ import { useNavigate } from 'react-router-dom' import { HUD_HEADING, HUD_ITEM, HUD_POSITION, HUD_SURFACE, HUD_TEXT } from '@/app/floating-hud' import { setTerminalTakeover } from '@/app/right-sidebar/store' +import { codiconIcon } from '@/components/ui/codicon' import { Command, CommandGroup, CommandInput, CommandItem, CommandList } from '@/components/ui/command' import { HighlightMatches } from '@/components/ui/highlight-matches' import { KbdCombo } from '@/components/ui/kbd' @@ -60,7 +61,7 @@ import { } from '@/store/command-palette' import { $bindings } from '@/store/keybinds' import { openPetGenerate } from '@/store/pet-generate' -import { requestStartWorkSession } from '@/store/projects' +import { $projectTree, goToProject, openFolderAsProject, requestStartWorkSession } from '@/store/projects' import { $connection } from '@/store/session' import { runGatewayRestart } from '@/store/system-actions' import { @@ -103,6 +104,8 @@ interface PaletteItem { action?: string /** Renders a trailing check: this row IS the current setting (theme, mode). */ active?: boolean + /** Static trailing combo hint for a modifier-variant select (e.g. `mod+enter`). */ + comboHint?: string /** Muted text beside the label — state the row acts on (a version, a count). */ detail?: string icon: IconComponent @@ -111,6 +114,8 @@ interface PaletteItem { keepOpen?: boolean keywords?: string[] label: string + /** Label shown while ⌘/⌃ is held — previews the modifier-variant action. */ + modLabel?: string /** * When set, ⌘/⌃-select (or ⌘-Enter) opens a new tab and ⇧⌘-select pops a * window — matching sidebar session rows. Plain select stays in-place. @@ -233,18 +238,25 @@ const paletteValue = (item: PaletteItem): string => `${item.label}\u0001${item.i const PaletteRow = memo(function PaletteRow({ bindings, item, + modHeld, onSelectMods, onSelectItem, search }: { bindings: Record item: PaletteItem + modHeld: boolean onSelectMods: (event: { ctrlKey: boolean; metaKey: boolean; shiftKey: boolean }) => void onSelectItem: (item: PaletteItem) => void search: string }) { const Icon = item.icon - const combo = item.action ? bindings[item.action]?.[0] : undefined + // The row's live keybind, else a static modifier-variant hint (⌘↵). One slot, + // so every downstream `ml-auto` fallback below keeps working unchanged. + const combo = (item.action ? bindings[item.action]?.[0] : undefined) ?? item.comboHint + // While ⌘/⌃ is held, a row with a modifier variant previews it: the label + // swaps to the variant's copy so Enter reads as what it will actually do. + const modPreview = modHeld && Boolean(item.modLabel) return ( - {/* Same per-term split as scoreItem's AND matcher, so the emphasis - shows exactly which words earned the row its rank. */} - + {modPreview ? ( + item.modLabel + ) : ( + /* Same per-term split as scoreItem's AND matcher, so the emphasis + shows exactly which words earned the row its rank. */ + + )} {item.detail && {item.detail}} - {combo && } + {combo && } {item.to && } {item.active && } @@ -272,6 +288,12 @@ const PaletteRow = memo(function PaletteRow({ // "Go to session ‹id›" jump for ids that aren't in the recent-200 list. const SESSION_ID_RE = /^\d{8}_\d{6}_[a-f0-9]{6}$/ +// A typed/pasted folder path: absolute (`/…`) or a Windows drive (`C:\…`). +// Deliberately NOT `~/…`: the upsert's membership check (projectIdForCwd) +// compares literal strings against the tree's absolute paths, so an unexpanded +// home path would always miss and double-create. +const FOLDER_PATH_RE = /^(\/|[A-Za-z]:[/\\]).+/ + type SessionRow = Awaited>['sessions'][number] const toSessionEntry = (session: SessionRow): SessionEntry => ({ @@ -359,6 +381,7 @@ export function CommandPalette() { const pendingPage = useStore($commandPalettePage) const bindings = useStore($bindings) const worktrees = useStore($repoWorktrees) + const projectTree = useStore($projectTree) const navigate = useNavigate() const { availableThemes, mode, resolvedMode, setMode, setTheme, themeName } = useTheme() const [search, setSearch] = useState('') @@ -409,6 +432,33 @@ export function CommandPalette() { } } + // Live ⌘/⌃-held state while the palette is open: rows with a modifier + // variant (projects) preview it by swapping their label. Window-level + // listeners because focus sits in the search input; blur clears so a + // ⌘-Tab away doesn't strand the preview on. + const [modHeld, setModHeld] = useState(false) + + useEffect(() => { + if (!open) { + setModHeld(false) + + return + } + + const sync = (event: KeyboardEvent) => setModHeld(event.metaKey || event.ctrlKey) + const clear = () => setModHeld(false) + + window.addEventListener('keydown', sync, { capture: true }) + window.addEventListener('keyup', sync, { capture: true }) + window.addEventListener('blur', clear) + + return () => { + window.removeEventListener('keydown', sync, { capture: true }) + window.removeEventListener('keyup', sync, { capture: true }) + window.removeEventListener('blur', clear) + } + }, [open]) + // Server-backed sources for the type-to-search groups, fetched lazily while // the palette is open. react-query handles caching/dedup/staleness. const configQuery = useQuery({ @@ -495,6 +545,36 @@ export function CommandPalette() { const settingsTab = (tab: string) => `${SETTINGS_ROUTE}?tab=${tab}` const cc = t.commandCenter + // Projects are the primary way the desktop scopes work, so they're jumpable + // from the palette. Plain select is a pure scope switch (sidebar enters the + // project — never spends main); ⌘-Enter / ⌘-click also starts a new session + // at the project root (stacked as a tab when main holds a chat), previewed + // by the label swap while ⌘ is held. Rows carry the project's own codicon, + // matching the sidebar. The pinned "Open folder…" row is the ⌘O upsert. + const projectGroup: PaletteGroup = { + heading: cc.projects, + items: [ + { + action: 'workspace.openFolder', + icon: codiconIcon('folder-opened'), + id: 'project-open-folder', + keywords: ['open', 'folder', 'directory', 'project', 'add', 'import', 'workspace'], + label: cc.openFolder, + run: () => void openFolderAsProject() + }, + ...projectTree.map(project => ({ + comboHint: 'mod+enter', + icon: codiconIcon(project.icon || (project.isNoProject ? 'home' : 'folder-library')), + id: `project-${project.id}`, + keywords: ['project', 'workspace', 'go to', project.label, ...(project.path ? [project.path] : [])], + label: project.label, + modLabel: cc.newSessionInProject(project.label), + runWithEvent: (event?: { ctrlKey?: boolean; metaKey?: boolean; shiftKey?: boolean }) => + goToProject(project.id, { newSession: Boolean(event?.metaKey || event?.ctrlKey) }) + })) + ] + } + // The active repo's worktrees → "new conversation in ". This is the // ⌘K-typed "I want to work on " reflex: each entry seeds a fresh // session anchored to that worktree's checkout (requestStartWorkSession), @@ -599,6 +679,7 @@ export function CommandPalette() { } ] }, + projectGroup, ...branchGroup, { heading: cc.commandCenter, @@ -714,7 +795,7 @@ export function CommandPalette() { ] : []) ] - }, [contributedItems, go, settingsSectionLabel, t, updateVersionLabel, worktrees]) + }, [contributedItems, go, projectTree, settingsSectionLabel, t, updateVersionLabel, worktrees]) // The long, granular lists (settings fields, API keys, MCP servers, archived // chats) only surface once the user types — otherwise they'd bury the @@ -744,6 +825,23 @@ export function CommandPalette() { }) } + // Paste/type an absolute folder path → open it as a project directly (the + // ⌘O upsert without the native picker). Same reflex as the raw-session-id + // row above. + if (FOLDER_PATH_RE.test(directId)) { + result.push({ + items: [ + { + icon: codiconIcon('folder-opened'), + id: `open-folder-${directId}`, + keywords: ['open', 'folder', 'project', directId], + label: t.commandCenter.openFolderAt(directId), + run: () => void openFolderAsProject(directId) + } + ] + }) + } + // Deep-link straight to a Capabilities sub-tab. The root "Go to" entry only // lands on the top-level Skills view; typing "mcp"/"tools"/"skills" should // jump to the exact tab (matches the "not just the top lvl" ask). @@ -1083,6 +1181,7 @@ export function CommandPalette() { bindings={bindings} item={item} key={item.id} + modHeld={modHeld} onSelectItem={handleSelect} onSelectMods={noteSelectMods} search={search} diff --git a/apps/desktop/src/i18n/en.ts b/apps/desktop/src/i18n/en.ts index 6426b61ac5d..49f52386a81 100644 --- a/apps/desktop/src/i18n/en.ts +++ b/apps/desktop/src/i18n/en.ts @@ -251,6 +251,7 @@ export const en: Translations = { 'session.focusSearch': 'Search sessions', 'session.togglePin': 'Pin / unpin current session', 'workspace.newWorktree': 'New worktree', + 'workspace.openFolder': 'Open folder as project', 'composer.focus': 'Focus composer', 'composer.modelPicker': 'Open model picker', 'composer.voice': 'Start / stop voice conversation', @@ -1143,6 +1144,10 @@ export const en: Translations = { goTo: 'Go to', goToSession: 'Go to session', branches: 'Branches', + projects: 'Projects', + openFolder: 'Open folder as project…', + openFolderAt: path => `Open folder as project — ${path}`, + newSessionInProject: project => `New session in ${project}`, commands: 'Commands', startInBranch: branch => `New conversation in ${branch}`, commandCenter: 'Command Center', diff --git a/apps/desktop/src/i18n/types.ts b/apps/desktop/src/i18n/types.ts index 7bd9c8cb1fc..c41cbc343a4 100644 --- a/apps/desktop/src/i18n/types.ts +++ b/apps/desktop/src/i18n/types.ts @@ -1006,6 +1006,10 @@ export interface Translations { goTo: string goToSession: string branches: string + projects: string + openFolder: string + openFolderAt: (path: string) => string + newSessionInProject: (project: string) => string commands: string startInBranch: (branch: string) => string commandCenter: string diff --git a/apps/desktop/src/i18n/zh.ts b/apps/desktop/src/i18n/zh.ts index 171da340086..bd8c7630016 100644 --- a/apps/desktop/src/i18n/zh.ts +++ b/apps/desktop/src/i18n/zh.ts @@ -246,6 +246,7 @@ export const zh: Translations = { 'session.focusSearch': '搜索会话', 'session.togglePin': '固定/取消固定当前会话', 'workspace.newWorktree': '新建工作树', + 'workspace.openFolder': '打开文件夹为项目', 'composer.focus': '聚焦输入框', 'composer.modelPicker': '打开模型选择器', 'composer.voice': '开始 / 停止语音对话', @@ -1340,6 +1341,10 @@ export const zh: Translations = { goTo: '前往', goToSession: '前往会话', branches: '分支', + projects: '项目', + openFolder: '打开文件夹为项目…', + openFolderAt: path => `打开文件夹为项目 — ${path}`, + newSessionInProject: project => `在 ${project} 中新建会话`, commands: '命令', startInBranch: branch => `在 ${branch} 中开始新对话`, commandCenter: '命令中心',