Skip to content

Commit 05b034f

Browse files
SchenLongclaude
andcommitted
feat(ux): add command palette with Cmd+K global hotkey (PR-4c.3)
Train 2 PR-4c.3 — global command palette for quick module navigation. Built on cmdk (pacocoursey) per adversarial finding M5 ("use an existing accessible primitive, do NOT build from scratch"). Accessible via the topbar search button or Cmd/Ctrl+K keyboard shortcut. New infrastructure: - cmdk@1.1.1 added to dependencies - NEW: src/components/layout/CommandPalette.tsx — Dialog-based palette with fuzzy search over NAV_ITEMS. Searches label, functionalLabel, description, and codename. Items grouped by NavGroup (Test | Protect | Intel & Evidence) + ungrouped (Dashboard, Admin). Selecting an item calls setActiveTab(item.id) and closes the palette. Keyboard hints in footer (arrows navigate, Enter select, Esc close). TopBar integration: - New search trigger button between spacer and Activity button. Renders "Search... ⌘K" on desktop, icon-only on mobile. Click opens palette. - Global Cmd+K / Ctrl+K keydown listener toggles palette open/close. - CommandPalette rendered in portal position (z-modal layer) to overlay both sidebar and content area. Conflict resolution: - PageToolbar.tsx: removed the per-page Cmd+K → focus search handler. Global Cmd+K is now owned by CommandPalette in TopBar. Per-page search remains accessible via the inline toolbar search input UI. Tests: - topbar.test.tsx: added CommandPalette mock (cmdk not available in jsdom). All 10 existing TopBar tests pass. - main-page.test.tsx: 12/12 pass (no changes needed). - Visual verified in dev preview: Cmd+K opens palette, fuzzy search for "scan" shows Haiku Scanner, clicking navigates to Scanner module, palette closes. 15 NAV_ITEMS render in grouped sections. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 66f0f97 commit 05b034f

6 files changed

Lines changed: 290 additions & 15 deletions

File tree

package-lock.json

Lines changed: 17 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@
5454
"@clack/core": "^1.0.0",
5555
"@clack/prompts": "^1.0.0",
5656
"chalk": "^5.6.2",
57+
"cmdk": "^1.1.1",
5758
"commander": "^11.0.0",
5859
"fs-extra": "^11.3.3",
5960
"glob": "^10.5.0",

packages/dojolm-web/src/components/__tests__/topbar.test.tsx

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,13 @@ vi.mock('../layout/NotificationsPanel', () => ({
3939
),
4040
}))
4141

42+
// Train 2 PR-4c.3: Mock CommandPalette (cmdk dependency not available in test env)
43+
vi.mock('../layout/CommandPalette', () => ({
44+
CommandPalette: ({ open }: { open: boolean }) => (
45+
open ? <div data-testid="command-palette">CommandPalette</div> : null
46+
),
47+
}))
48+
4249
import { TopBar } from '../layout/TopBar'
4350

4451
// ---------------------------------------------------------------------------
Lines changed: 227 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,227 @@
1+
/**
2+
* File: CommandPalette.tsx
3+
* Purpose: Global Cmd+K command palette for navigating DojoLM modules.
4+
* Story: Train 2 PR-4c.3
5+
*
6+
* Built on `cmdk` (pacocoursey) — accessible dialog with keyboard nav,
7+
* focus traps, and fuzzy search. Per adversarial finding M5: uses an
8+
* existing primitive instead of building from scratch.
9+
*
10+
* Fuzzy-searches over NAV_ITEMS by label, functionalLabel, description,
11+
* and codename. Selecting an item calls setActiveTab(item.id) and closes.
12+
*/
13+
14+
'use client'
15+
16+
import { useEffect, useState, useCallback, useRef } from 'react'
17+
import { Command } from 'cmdk'
18+
import { NAV_ITEMS, NAV_GROUPS } from '@/lib/constants'
19+
import { useNavigation } from '@/lib/NavigationContext'
20+
import { Search, CornerDownLeft, ArrowUp, ArrowDown } from 'lucide-react'
21+
import { cn } from '@/lib/utils'
22+
23+
type NavGroup = typeof NAV_GROUPS[number]['id']
24+
25+
/** Visible (non-hidden) NAV_ITEMS for the palette. */
26+
const PALETTE_ITEMS = NAV_ITEMS.filter(
27+
item => !('hidden' in item && item.hidden === true)
28+
)
29+
30+
/** Group items by their nav group, plus ungrouped (dashboard, admin). */
31+
const GROUPED_ITEMS = (() => {
32+
const groups: Record<string, typeof PALETTE_ITEMS> = {}
33+
const ungrouped: typeof PALETTE_ITEMS = []
34+
35+
for (const item of PALETTE_ITEMS) {
36+
const group = 'group' in item ? (item.group as string) : undefined
37+
if (group) {
38+
groups[group] ??= []
39+
groups[group].push(item)
40+
} else {
41+
ungrouped.push(item)
42+
}
43+
}
44+
return { groups, ungrouped }
45+
})()
46+
47+
export interface CommandPaletteProps {
48+
readonly open: boolean
49+
readonly onOpenChange: (open: boolean) => void
50+
}
51+
52+
export function CommandPalette({ open, onOpenChange }: CommandPaletteProps) {
53+
const { setActiveTab } = useNavigation()
54+
const [search, setSearch] = useState('')
55+
const inputRef = useRef<HTMLInputElement>(null)
56+
57+
// Focus the search input when the palette opens.
58+
useEffect(() => {
59+
if (open) {
60+
setSearch('')
61+
// Small delay to ensure the dialog is mounted and visible.
62+
const id = requestAnimationFrame(() => {
63+
inputRef.current?.focus()
64+
})
65+
return () => cancelAnimationFrame(id)
66+
}
67+
}, [open])
68+
69+
const handleSelect = useCallback(
70+
(navId: string) => {
71+
setActiveTab(navId)
72+
onOpenChange(false)
73+
},
74+
[setActiveTab, onOpenChange],
75+
)
76+
77+
if (!open) return null
78+
79+
return (
80+
<>
81+
{/* Backdrop */}
82+
<div
83+
className="fixed inset-0 z-[var(--z-modal)] bg-black/50 backdrop-blur-sm"
84+
onClick={() => onOpenChange(false)}
85+
aria-hidden="true"
86+
/>
87+
88+
{/* Palette dialog */}
89+
<div
90+
className="fixed inset-0 z-[calc(var(--z-modal)+1)] flex items-start justify-center pt-[min(20vh,120px)]"
91+
role="dialog"
92+
aria-modal="true"
93+
aria-label="Command palette"
94+
>
95+
<Command
96+
className={cn(
97+
'w-full max-w-lg rounded-xl border border-[var(--border-subtle)]',
98+
'bg-[var(--background)] shadow-2xl',
99+
'motion-safe:animate-fade-in',
100+
)}
101+
shouldFilter={true}
102+
onKeyDown={(e) => {
103+
if (e.key === 'Escape') {
104+
e.preventDefault()
105+
onOpenChange(false)
106+
}
107+
}}
108+
>
109+
{/* Search input */}
110+
<div className="flex items-center gap-2 border-b border-[var(--border-subtle)] px-4">
111+
<Search className="h-4 w-4 shrink-0 text-[var(--text-tertiary)]" aria-hidden="true" />
112+
<Command.Input
113+
ref={inputRef}
114+
value={search}
115+
onValueChange={setSearch}
116+
placeholder="Search modules..."
117+
className={cn(
118+
'h-12 w-full bg-transparent text-sm text-[var(--foreground)]',
119+
'placeholder:text-[var(--text-tertiary)]',
120+
'outline-none',
121+
)}
122+
/>
123+
<kbd className="hidden shrink-0 rounded border border-[var(--border-subtle)] bg-[var(--bg-secondary)] px-1.5 py-0.5 text-[10px] font-medium text-[var(--text-tertiary)] sm:inline-block">
124+
ESC
125+
</kbd>
126+
</div>
127+
128+
{/* Results list */}
129+
<Command.List className="max-h-[min(50vh,400px)] overflow-y-auto overscroll-contain p-2">
130+
<Command.Empty className="py-8 text-center text-sm text-[var(--text-tertiary)]">
131+
No modules found.
132+
</Command.Empty>
133+
134+
{/* Ungrouped items (Dashboard, Admin) */}
135+
{GROUPED_ITEMS.ungrouped.length > 0 && (
136+
<Command.Group>
137+
{GROUPED_ITEMS.ungrouped.map(item => (
138+
<PaletteItem
139+
key={item.id}
140+
item={item}
141+
onSelect={handleSelect}
142+
/>
143+
))}
144+
</Command.Group>
145+
)}
146+
147+
{/* Grouped items by NavGroup */}
148+
{NAV_GROUPS.map(group => {
149+
const items = GROUPED_ITEMS.groups[group.id]
150+
if (!items?.length) return null
151+
return (
152+
<Command.Group
153+
key={group.id}
154+
heading={group.label}
155+
className="[&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-semibold [&_[cmdk-group-heading]]:uppercase [&_[cmdk-group-heading]]:tracking-wider [&_[cmdk-group-heading]]:text-[var(--text-tertiary)]"
156+
>
157+
{items.map(item => (
158+
<PaletteItem
159+
key={item.id}
160+
item={item}
161+
onSelect={handleSelect}
162+
/>
163+
))}
164+
</Command.Group>
165+
)
166+
})}
167+
</Command.List>
168+
169+
{/* Footer with keyboard hints */}
170+
<div className="flex items-center gap-4 border-t border-[var(--border-subtle)] px-4 py-2 text-[10px] text-[var(--text-tertiary)]">
171+
<span className="inline-flex items-center gap-1">
172+
<ArrowUp className="h-3 w-3" aria-hidden="true" />
173+
<ArrowDown className="h-3 w-3" aria-hidden="true" />
174+
navigate
175+
</span>
176+
<span className="inline-flex items-center gap-1">
177+
<CornerDownLeft className="h-3 w-3" aria-hidden="true" />
178+
select
179+
</span>
180+
<span className="inline-flex items-center gap-1">
181+
<kbd className="rounded border border-[var(--border-subtle)] bg-[var(--bg-secondary)] px-1 py-0.5 text-[9px]">esc</kbd>
182+
close
183+
</span>
184+
</div>
185+
</Command>
186+
</div>
187+
</>
188+
)
189+
}
190+
191+
/** Individual nav item in the palette. */
192+
function PaletteItem({
193+
item,
194+
onSelect,
195+
}: {
196+
readonly item: typeof PALETTE_ITEMS[number]
197+
readonly onSelect: (id: string) => void
198+
}) {
199+
const Icon = item.icon
200+
const label = item.label
201+
const sublabel = 'functionalLabel' in item ? item.functionalLabel : undefined
202+
203+
return (
204+
<Command.Item
205+
value={`${item.id} ${label} ${sublabel ?? ''} ${item.description}`}
206+
onSelect={() => onSelect(item.id)}
207+
className={cn(
208+
'flex cursor-pointer items-center gap-3 rounded-lg px-3 py-2.5 text-sm',
209+
'text-[var(--foreground)] transition-colors',
210+
'aria-selected:bg-[var(--dojo-primary)]/10 aria-selected:text-[var(--dojo-primary)]',
211+
'hover:bg-[var(--bg-tertiary)]',
212+
)}
213+
>
214+
<Icon className="h-4 w-4 shrink-0 text-[var(--text-tertiary)] aria-selected:text-[var(--dojo-primary)]" aria-hidden="true" />
215+
<div className="flex-1 min-w-0">
216+
<div className="truncate font-medium">{label}</div>
217+
{sublabel && sublabel !== label && (
218+
<div className="truncate text-xs text-[var(--text-tertiary)]">{sublabel}</div>
219+
)}
220+
</div>
221+
<span className="shrink-0 text-[10px] text-[var(--text-tertiary)] opacity-60">
222+
{item.description.slice(0, 40)}
223+
{item.description.length > 40 ? '...' : ''}
224+
</span>
225+
</Command.Item>
226+
)
227+
}

packages/dojolm-web/src/components/layout/PageToolbar.tsx

Lines changed: 3 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -54,19 +54,9 @@ export function PageToolbar({
5454
setIsMac(/Mac|iPhone|iPad/.test(navigator.userAgent))
5555
}, [])
5656

57-
// Cmd/Ctrl+K keyboard shortcut to focus search
58-
useEffect(() => {
59-
if (!searchEnabled) return
60-
61-
const handleKeyDown = (e: KeyboardEvent) => {
62-
if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
63-
e.preventDefault()
64-
searchRef.current?.focus()
65-
}
66-
}
67-
document.addEventListener('keydown', handleKeyDown)
68-
return () => document.removeEventListener('keydown', handleKeyDown)
69-
}, [searchEnabled])
57+
// Train 2 PR-4c.3: Cmd+K now owned by the global CommandPalette in TopBar.
58+
// Per-page search focus is no longer hotkey-bound. Users reach the palette
59+
// via Cmd+K (global), then the per-page search via the toolbar UI itself.
7060

7161
// Breadcrumb truncation for mobile: show first, ellipsis, last when > 2 items
7262
const visibleBreadcrumbs = breadcrumbs && breadcrumbs.length > 2

packages/dojolm-web/src/components/layout/TopBar.tsx

Lines changed: 35 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,12 +13,13 @@
1313

1414
'use client'
1515

16-
import { useState, useCallback } from 'react'
16+
import { useState, useCallback, useEffect } from 'react'
1717
import { cn } from '@/lib/utils'
18-
import { Activity, Bot } from 'lucide-react'
18+
import { Activity, Bot, Search } from 'lucide-react'
1919
import { NotificationsPanel } from './NotificationsPanel'
2020
import { useActivityState } from '@/lib/contexts/ActivityContext'
2121
import { ActivityFeed } from '@/components/ui/ActivityFeed'
22+
import { CommandPalette } from './CommandPalette'
2223

2324
/**
2425
* Isolated component that subscribes to activity state for unread count.
@@ -50,6 +51,8 @@ function ActivityButton({ onToggle }: { readonly onToggle: () => void }) {
5051

5152
export function TopBar() {
5253
const [activityDrawerOpen, setActivityDrawerOpen] = useState(false)
54+
// Train 2 PR-4c.3 — Command Palette (Cmd+K)
55+
const [paletteOpen, setPaletteOpen] = useState(false)
5356

5457
const toggleActivityDrawer = useCallback(() => {
5558
setActivityDrawerOpen(prev => !prev)
@@ -63,6 +66,18 @@ export function TopBar() {
6366
window.dispatchEvent(new CustomEvent('sensei-toggle'))
6467
}, [])
6568

69+
// Global Cmd+K / Ctrl+K hotkey for command palette
70+
useEffect(() => {
71+
function handleKeyDown(e: KeyboardEvent) {
72+
if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
73+
e.preventDefault()
74+
setPaletteOpen(prev => !prev)
75+
}
76+
}
77+
document.addEventListener('keydown', handleKeyDown)
78+
return () => document.removeEventListener('keydown', handleKeyDown)
79+
}, [])
80+
6681
return (
6782
<>
6883
<header
@@ -76,6 +91,21 @@ export function TopBar() {
7691
{/* Spacer — per-page title/breadcrumbs remain rendered by PageToolbar.tsx */}
7792
<div className="flex-1" />
7893

94+
{/* Command Palette trigger (Train 2 PR-4c.3) */}
95+
<button
96+
onClick={() => setPaletteOpen(true)}
97+
className="flex h-9 items-center gap-2 rounded-lg border border-[var(--border-subtle)] bg-[var(--bg-secondary)] px-3 text-xs text-[var(--text-tertiary)] transition-colors hover:border-[var(--text-tertiary)] hover:text-[var(--foreground)]"
98+
aria-label="Open command palette"
99+
title="Search modules (Cmd+K)"
100+
type="button"
101+
>
102+
<Search className="h-3.5 w-3.5" aria-hidden="true" />
103+
<span className="hidden lg:inline">Search...</span>
104+
<kbd className="hidden rounded border border-[var(--border-subtle)] bg-[var(--bg-primary)] px-1 py-0.5 text-[10px] font-medium lg:inline-block">
105+
{typeof navigator !== 'undefined' && /Mac|iPhone|iPad/.test(navigator.userAgent) ? '⌘K' : 'Ctrl+K'}
106+
</kbd>
107+
</button>
108+
79109
{/* Activity drawer trigger (replaces sidebar Activity section) */}
80110
<ActivityButton onToggle={toggleActivityDrawer} />
81111

@@ -125,6 +155,9 @@ export function TopBar() {
125155
</aside>
126156
</>
127157
)}
158+
159+
{/* Command Palette — global Cmd+K navigation (PR-4c.3) */}
160+
<CommandPalette open={paletteOpen} onOpenChange={setPaletteOpen} />
128161
</>
129162
)
130163
}

0 commit comments

Comments
 (0)