learning_ai_common_plat/packages/command-palette/src/CommandPalette.tsx
saravanakumardb1 e2eea086dc feat(packages): Wave 2 v0.4 + Wave 3 v0.1 — ai-ui expanded, command-palette new
═══════════════════════════════════════════════════════════════════════
@bytelyst/ai-ui  bump 0.1.0 → 0.4.0
═══════════════════════════════════════════════════════════════════════
Folds three more roadmap milestones into the flagship package.

  0.2: <ToolCallCard>   — disclosure card; status pill, JSON preview
       <CitationChip>   — inline citation marker + hover preview
       useToolCalls()   — per-turn tool-invocation state machine
                          (begin/update/settle/clear); preserves insertion
                          order across updates; auto-computes durationMs
  0.3: <AgentTimeline>  — vertical think→act→observe→respond trace;
                          embeds ToolCallCard for kind='tool_call' steps
       <ModelPicker>    — model dropdown with capability chips, cost,
                          latency, context window, disabled gating
  0.4: <ToolPalette>    — searchable tool list with MCP-style discovery
                          (source can be ToolDescriptor[] OR an
                          'mcp://...' URL resolved via a discover
                          adapter; default adapter is fetch+JSON)

Types extended:
  - ToolInvocation, ToolCallStatus, Citation added
  - Message gains optional toolInvocations + citations

Tests: 53/53 (27 old + 26 new) · typecheck clean · 7.65 KB / 35 KB

═══════════════════════════════════════════════════════════════════════
NEW PACKAGE: @bytelyst/command-palette@0.1.0
═══════════════════════════════════════════════════════════════════════
Wave 3 deliverable — Cmd-K dialog with three modes and pluggable command
registration. Roadmap §Wave 3 of ROADMAP_2026.md.

What's exported:
  <CommandRegistryProvider>  — wrap your app once
  <CommandPalette>           — the dialog (Cmd-K / Ctrl-K)
  useRegisterCommands()      — contribute commands for component lifetime
  useCommands()              — read snapshot
  useCommandRegistry()       — imperative access
  useCommandPalette()        — open/close state + global hotkey
  fuzzyScore / scoreCommand  — exposed for tests + custom UIs

Three modes:
  actions   — invoke a registered run()
  navigate  — jump to href via onNavigate or window.location
  ask-ai    — host-supplied askAiPanel; default renders an 'Ask AI: <q>'
              suggestion that products can wire to <ChatStream>

Keyboard:
  ↑ ↓     navigate selection
  Enter   activate
  Tab     cycle mode tabs (Shift+Tab reverses)
  Esc     close

Niceties:
  - Fuzzy matcher (substring + subsequence with light scoring)
  - localStorage-backed recents float to top of actions mode
  - requires() gate hides commands wholesale (auth / feature-flag)
  - aria-haspopup, role=dialog, role=listbox, role=option, aria-selected
  - Backdrop click closes; Esc handler at document level
  - Hotkey suppressed by Cmd-K / Ctrl-K default; configurable

Tests: 26/26 · typecheck clean · 3.91 KB / 15 KB

═══════════════════════════════════════════════════════════════════════
CI plumbing
═══════════════════════════════════════════════════════════════════════
  - .size-limit.cjs gains @bytelyst/command-palette entry
  - .gitea/workflows/size-limit.yml build filter expanded
  - All 8 measured packages comfortably under budget

Refs:
  learning_ai_uxui_web/docs/ROADMAP_2026.md §Wave 2 (0.2/0.3/0.4)
  learning_ai_uxui_web/docs/ROADMAP_2026.md §Wave 3 (Command palette)
  docs/ROADMAP_2026_DECISIONS.md §10 (Vercel AI SDK shape continues)
2026-05-27 12:43:23 -07:00

579 lines
17 KiB
TypeScript

import {
useEffect,
useMemo,
useRef,
useState,
type CSSProperties,
type KeyboardEvent,
type ReactNode,
} from 'react';
import { useCommands } from './registry.js';
import { scoreCommand } from './fuzzy.js';
import type { Command, CommandMode } from './types.js';
export interface CommandPaletteProps {
open: boolean;
onClose: () => void;
/** Initial mode tab. Default: 'actions'. */
initialMode?: CommandMode;
/** Hide the mode tabs (only render the active mode). */
hideModeTabs?: boolean;
/** Render a custom Ask-AI panel (replaces the default suggestion). */
askAiPanel?: (query: string) => ReactNode;
/** Called when the user picks a navigate-mode command. */
onNavigate?: (href: string) => void;
/** Force-merge external commands without registering them. */
extraCommands?: Command[];
/** localStorage key for recents. Default: 'bl-cmdk-recents'. */
recentsKey?: string;
/** How many recents to remember. Default: 8. */
recentsLimit?: number;
/** ARIA label for the dialog. */
ariaLabel?: string;
className?: string;
style?: CSSProperties;
}
/**
* `<CommandPalette>` — Cmd-K dialog with three modes:
* - **actions** — invoke a registered command (calls its `run`)
* - **navigate** — jump to a registered route (uses `onNavigate` or
* `window.location.assign` as fallback)
* - **ask-ai** — delegate the query to an AI panel (host-supplied)
*
* Commands are sourced from `useCommands()` (provider context) plus any
* `extraCommands` prop. Recents persist to `localStorage` under
* `recentsKey` and float to the top of the actions list when the query
* is empty.
*
* Keyboard:
* ↑ ↓ — move selection
* Enter — run selection
* Tab — cycle mode tabs
* Esc — close
*/
export function CommandPalette({
open,
onClose,
initialMode = 'actions',
hideModeTabs = false,
askAiPanel,
onNavigate,
extraCommands = [],
recentsKey = 'bl-cmdk-recents',
recentsLimit = 8,
ariaLabel = 'Command palette',
className,
style,
}: CommandPaletteProps) {
const registryCommands = useCommands();
const allCommands = useMemo(
() => [...registryCommands, ...extraCommands],
[registryCommands, extraCommands],
);
const [mode, setMode] = useState<CommandMode>(initialMode);
const [query, setQuery] = useState('');
const [selected, setSelected] = useState(0);
const inputRef = useRef<HTMLInputElement>(null);
// Reset state every time the palette opens.
useEffect(() => {
if (open) {
setQuery('');
setSelected(0);
setMode(initialMode);
// Defer focus until after the dialog mounts.
queueMicrotask(() => inputRef.current?.focus());
}
}, [open, initialMode]);
// Esc to close — handled at the document level so clicks outside the
// dialog don't need to bubble.
useEffect(() => {
if (!open) return;
const handler = (e: globalThis.KeyboardEvent) => {
if (e.key === 'Escape') {
e.preventDefault();
onClose();
}
};
document.addEventListener('keydown', handler);
return () => document.removeEventListener('keydown', handler);
}, [open, onClose]);
// ── Filter + score ──────────────────────────────────────────────────────
const recents = useRecents(recentsKey, recentsLimit);
const filtered = useMemo(() => {
const bucketed = allCommands.filter(c => {
if (c.requires && !c.requires()) return false;
// ask-ai bucket is virtual — no commands live in it.
if (mode === 'navigate') return c.mode === 'navigate';
if (mode === 'actions') return c.mode !== 'navigate';
return false;
});
if (!query) {
// No query: float recents to the top in actions mode.
if (mode === 'actions' && recents.length) {
const recentSet = new Set(recents);
const recentItems = bucketed.filter(c => recentSet.has(c.id));
const rest = bucketed.filter(c => !recentSet.has(c.id));
return recentItems.concat(rest).map(command => ({ command, score: 0 }));
}
return bucketed.map(command => ({ command, score: 0 }));
}
return bucketed
.map(command => ({ command, score: scoreCommand(command, query) }))
.filter((row): row is { command: Command; score: number } => row.score !== null)
.sort((a, b) => b.score - a.score);
}, [allCommands, mode, query, recents]);
useEffect(() => {
if (selected >= filtered.length) setSelected(0);
}, [filtered.length, selected]);
// ── Activation ──────────────────────────────────────────────────────────
const activate = (cmd: Command) => {
if (cmd.enabled === false) return;
recents.push(cmd.id);
onClose();
if (cmd.run) {
void cmd.run();
return;
}
if (cmd.href) {
if (onNavigate) onNavigate(cmd.href);
else if (typeof window !== 'undefined') window.location.assign(cmd.href);
}
};
const handleKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {
if (e.key === 'ArrowDown') {
e.preventDefault();
setSelected(s => Math.min(s + 1, Math.max(filtered.length - 1, 0)));
} else if (e.key === 'ArrowUp') {
e.preventDefault();
setSelected(s => Math.max(s - 1, 0));
} else if (e.key === 'Enter') {
e.preventDefault();
const row = filtered[selected];
if (row) activate(row.command);
} else if (e.key === 'Tab') {
e.preventDefault();
setMode(m => nextMode(m, e.shiftKey ? -1 : 1));
}
};
if (!open) return null;
return (
<div
role="dialog"
aria-modal="true"
aria-label={ariaLabel}
data-testid="bl-cmdk"
className={className}
onKeyDown={handleKeyDown}
style={{
position: 'fixed',
inset: 0,
zIndex: 1000,
background: 'var(--bl-overlay-scrim, rgba(0,0,0,0.55))',
display: 'flex',
alignItems: 'flex-start',
justifyContent: 'center',
paddingTop: '12vh',
...style,
}}
onMouseDown={e => {
// Close on backdrop click — but never when the inner panel is clicked.
if (e.target === e.currentTarget) onClose();
}}
>
<div
data-testid="bl-cmdk-panel"
style={{
width: 'min(640px, 92vw)',
maxHeight: '70vh',
display: 'flex',
flexDirection: 'column',
background: 'var(--bl-surface-card, #fff)',
color: 'var(--bl-text-primary, inherit)',
border: '1px solid var(--bl-border, rgba(0,0,0,0.12))',
borderRadius: 'var(--bl-radius-card, 12px)',
boxShadow: '0 20px 60px rgba(0,0,0,0.30)',
overflow: 'hidden',
}}
>
{/* Search input */}
<div
style={{
display: 'flex',
alignItems: 'center',
gap: 'var(--bl-space-2, 8px)',
padding: 'var(--bl-space-3, 12px) var(--bl-space-4, 16px)',
borderBottom: '1px solid var(--bl-border-subtle, rgba(0,0,0,0.06))',
}}
>
<span
aria-hidden
style={{ fontSize: 14, color: 'var(--bl-text-tertiary, #888)' }}
>
</span>
<input
ref={inputRef}
data-testid="bl-cmdk-input"
type="text"
value={query}
onChange={e => {
setQuery(e.target.value);
setSelected(0);
}}
placeholder={
mode === 'navigate'
? 'Jump to…'
: mode === 'ask-ai'
? 'Ask AI anything…'
: 'Type a command, or search…'
}
aria-label="Search commands"
style={{
flex: 1,
border: 'none',
outline: 'none',
background: 'transparent',
color: 'inherit',
fontSize: '1rem',
}}
/>
{!hideModeTabs && <ModeTabs mode={mode} onChange={setMode} />}
</div>
{/* Body */}
<div
data-testid="bl-cmdk-body"
style={{
flex: 1,
overflowY: 'auto',
padding: 'var(--bl-space-1, 4px)',
}}
>
{mode === 'ask-ai' ? (
askAiPanel ? (
askAiPanel(query)
) : (
<DefaultAskAiPanel query={query} />
)
) : filtered.length === 0 ? (
<EmptyState query={query} mode={mode} />
) : (
<ul
role="listbox"
aria-label={mode === 'navigate' ? 'Navigate targets' : 'Actions'}
style={{ listStyle: 'none', margin: 0, padding: 0 }}
>
{filtered.map((row, i) => (
<CommandRow
key={row.command.id}
command={row.command}
selected={i === selected}
onSelect={() => activate(row.command)}
onHover={() => setSelected(i)}
/>
))}
</ul>
)}
</div>
<Footer mode={mode} hits={filtered.length} />
</div>
</div>
);
}
// ─── Sub-components ──────────────────────────────────────────────────────
function ModeTabs({
mode,
onChange,
}: {
mode: CommandMode;
onChange: (m: CommandMode) => void;
}) {
const tabs: Array<{ id: CommandMode; label: string }> = [
{ id: 'actions', label: 'Actions' },
{ id: 'navigate', label: 'Navigate' },
{ id: 'ask-ai', label: 'Ask AI' },
];
return (
<div
role="tablist"
style={{
display: 'inline-flex',
gap: 2,
padding: 2,
background: 'var(--bl-surface-muted, rgba(0,0,0,0.04))',
borderRadius: 'var(--bl-radius-pill, 999px)',
}}
>
{tabs.map(t => {
const active = t.id === mode;
return (
<button
key={t.id}
type="button"
role="tab"
aria-selected={active}
data-testid={`bl-cmdk-tab-${t.id}`}
onClick={() => onChange(t.id)}
style={{
padding: '4px 10px',
fontSize: '0.75rem',
border: 'none',
borderRadius: 'var(--bl-radius-pill, 999px)',
background: active ? 'var(--bl-surface-card, #fff)' : 'transparent',
color: active
? 'var(--bl-text-primary, inherit)'
: 'var(--bl-text-tertiary, #888)',
fontWeight: active ? 600 : 500,
cursor: 'pointer',
}}
>
{t.label}
</button>
);
})}
</div>
);
}
function CommandRow({
command,
selected,
onSelect,
onHover,
}: {
command: Command;
selected: boolean;
onSelect: () => void;
onHover: () => void;
}) {
const disabled = command.enabled === false;
return (
<li
role="option"
aria-selected={selected}
aria-disabled={disabled}
data-testid={`bl-cmdk-item-${command.id}`}
onMouseMove={onHover}
onClick={() => {
if (!disabled) onSelect();
}}
style={{
display: 'flex',
alignItems: 'center',
gap: 'var(--bl-space-3, 12px)',
padding: 'var(--bl-space-2, 8px) var(--bl-space-3, 12px)',
background: selected
? 'var(--bl-accent-muted, rgba(99,102,241,0.12))'
: 'transparent',
borderRadius: 'var(--bl-radius-control, 6px)',
cursor: disabled ? 'not-allowed' : 'pointer',
opacity: disabled ? 0.5 : 1,
}}
>
{command.icon && (
<span
aria-hidden
style={{
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
width: 22,
color: 'var(--bl-accent, #6366f1)',
}}
>
{command.icon}
</span>
)}
<span style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: '0.9rem', fontWeight: 600 }}>{command.label}</div>
{command.description && (
<div
style={{
fontSize: '0.75rem',
color: 'var(--bl-text-tertiary, #888)',
marginTop: 1,
}}
>
{command.description}
</div>
)}
</span>
{command.shortcut && (
<span style={{ display: 'inline-flex', gap: 2 }}>
{command.shortcut.map(k => (
<kbd
key={k}
style={{
fontFamily: 'inherit',
fontSize: '0.7rem',
padding: '1px 5px',
background: 'var(--bl-surface-muted, rgba(0,0,0,0.06))',
color: 'var(--bl-text-secondary, #555)',
border: '1px solid var(--bl-border-subtle, rgba(0,0,0,0.08))',
borderRadius: 4,
}}
>
{k}
</kbd>
))}
</span>
)}
</li>
);
}
function EmptyState({ query, mode }: { query: string; mode: CommandMode }) {
return (
<div
data-testid="bl-cmdk-empty"
style={{
padding: 'var(--bl-space-4, 16px)',
textAlign: 'center',
color: 'var(--bl-text-tertiary, #888)',
fontSize: '0.85rem',
}}
>
{query
? `No ${mode === 'navigate' ? 'navigation targets' : 'commands'} match "${query}".`
: `No ${mode === 'navigate' ? 'navigation targets' : 'commands'} registered.`}
</div>
);
}
function DefaultAskAiPanel({ query }: { query: string }) {
return (
<div
data-testid="bl-cmdk-ask-ai"
style={{
padding: 'var(--bl-space-4, 16px)',
textAlign: 'center',
color: 'var(--bl-text-secondary, #444)',
}}
>
<div style={{ fontSize: 28, marginBottom: 8 }} aria-hidden>
</div>
<div style={{ fontSize: '0.9rem', fontWeight: 600 }}>
{query ? `Ask AI: "${query}"` : 'Type a question to ask AI'}
</div>
<div
style={{
fontSize: '0.75rem',
color: 'var(--bl-text-tertiary, #888)',
marginTop: 4,
}}
>
Provide an <code>askAiPanel</code> prop to wire this to{' '}
<code>@bytelyst/ai-ui</code>.
</div>
</div>
);
}
function Footer({ mode, hits }: { mode: CommandMode; hits: number }) {
return (
<div
style={{
display: 'flex',
alignItems: 'center',
gap: 'var(--bl-space-3, 12px)',
padding: 'var(--bl-space-2, 8px) var(--bl-space-3, 12px)',
borderTop: '1px solid var(--bl-border-subtle, rgba(0,0,0,0.06))',
fontSize: '0.7rem',
color: 'var(--bl-text-tertiary, #888)',
}}
>
<span>
<kbd style={kbdStyle}></kbd> navigate
</span>
<span>
<kbd style={kbdStyle}></kbd> activate
</span>
<span>
<kbd style={kbdStyle}>Tab</kbd> switch mode
</span>
<span>
<kbd style={kbdStyle}>Esc</kbd> close
</span>
{mode !== 'ask-ai' && (
<span style={{ marginLeft: 'auto' }}>{hits} result{hits === 1 ? '' : 's'}</span>
)}
</div>
);
}
const kbdStyle: CSSProperties = {
fontFamily: 'inherit',
fontSize: '0.7rem',
padding: '0 4px',
background: 'var(--bl-surface-muted, rgba(0,0,0,0.06))',
border: '1px solid var(--bl-border-subtle, rgba(0,0,0,0.08))',
borderRadius: 3,
};
function nextMode(current: CommandMode, direction: 1 | -1): CommandMode {
const order: CommandMode[] = ['actions', 'navigate', 'ask-ai'];
const idx = order.indexOf(current);
const next = (idx + direction + order.length) % order.length;
return order[next]!;
}
// ─── localStorage-backed recents ─────────────────────────────────────────
function useRecents(key: string, limit: number) {
const [recents, setRecents] = useState<string[]>(() => {
if (typeof window === 'undefined') return [];
try {
const raw = window.localStorage.getItem(key);
if (!raw) return [];
const parsed = JSON.parse(raw);
return Array.isArray(parsed) ? parsed.slice(0, limit) : [];
} catch {
return [];
}
});
return useMemo(
() => ({
get list() {
return recents;
},
// Iterable convenience.
[Symbol.iterator]: () => recents[Symbol.iterator](),
get length() {
return recents.length;
},
includes: (id: string) => recents.includes(id),
filter: (predicate: (id: string) => boolean) => recents.filter(predicate),
push: (id: string) => {
setRecents(prev => {
const next = [id, ...prev.filter(x => x !== id)].slice(0, limit);
try {
window.localStorage.setItem(key, JSON.stringify(next));
} catch {
/* quota / private mode — best-effort */
}
return next;
});
},
}),
[recents, key, limit],
);
}