mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-31 19:16:29 +00:00
feat(desktop): projects in the command palette
⌘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 <project>' 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.
This commit is contained in:
parent
5affcd6bf4
commit
a11611af39
4 changed files with 120 additions and 7 deletions
|
|
@ -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<string, string[]>
|
||||
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 (
|
||||
<CommandItem
|
||||
|
|
@ -256,12 +268,16 @@ const PaletteRow = memo(function PaletteRow({
|
|||
>
|
||||
<Icon className="size-3.5 shrink-0 text-muted-foreground" />
|
||||
<span className="truncate">
|
||||
{/* Same per-term split as scoreItem's AND matcher, so the emphasis
|
||||
shows exactly which words earned the row its rank. */}
|
||||
<HighlightMatches query={search.split(/\s+/)} text={item.label} />
|
||||
{modPreview ? (
|
||||
item.modLabel
|
||||
) : (
|
||||
/* Same per-term split as scoreItem's AND matcher, so the emphasis
|
||||
shows exactly which words earned the row its rank. */
|
||||
<HighlightMatches query={search.split(/\s+/)} text={item.label} />
|
||||
)}
|
||||
</span>
|
||||
{item.detail && <span className="truncate text-muted-foreground/80">{item.detail}</span>}
|
||||
{combo && <KbdCombo className="ml-auto opacity-55" combo={combo} size="sm" />}
|
||||
{combo && <KbdCombo className={cn('ml-auto', modPreview ? 'opacity-90' : 'opacity-55')} combo={combo} size="sm" />}
|
||||
{item.to && <ChevronRight className={cn('size-3.5 shrink-0 text-muted-foreground/70', !combo && 'ml-auto')} />}
|
||||
{item.active && <Check className={cn('size-3.5 shrink-0 text-primary', !combo && !item.to && 'ml-auto')} />}
|
||||
</CommandItem>
|
||||
|
|
@ -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<ReturnType<typeof listAllProfileSessions>>['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 <branch>". This is the
|
||||
// ⌘K-typed "I want to work on <branch>" 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}
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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: '命令中心',
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue