═══════════════════════════════════════════════════════════════════════
@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)
129 lines
3.6 KiB
TypeScript
129 lines
3.6 KiB
TypeScript
import {
|
|
createContext,
|
|
useCallback,
|
|
useContext,
|
|
useEffect,
|
|
useMemo,
|
|
useState,
|
|
type ReactNode,
|
|
} from 'react';
|
|
import type { Command } from './types.js';
|
|
|
|
interface RegistryState {
|
|
commands: Map<string, Command>;
|
|
register: (commands: Command[]) => () => void;
|
|
unregister: (ids: string[]) => void;
|
|
clear: () => void;
|
|
}
|
|
|
|
const RegistryContext = createContext<RegistryState | null>(null);
|
|
|
|
export interface CommandRegistryProviderProps {
|
|
/** Optional seed of commands always available (app-level chrome). */
|
|
initial?: Command[];
|
|
children: ReactNode;
|
|
}
|
|
|
|
/**
|
|
* Provides a shared command registry to descendants. Wrap your app once
|
|
* (typically next to `<DashboardShell>`) and any nested component can
|
|
* call `useRegisterCommands(...)` to contribute commands for as long as
|
|
* it stays mounted.
|
|
*/
|
|
export function CommandRegistryProvider({
|
|
initial = [],
|
|
children,
|
|
}: CommandRegistryProviderProps) {
|
|
const [commands, setCommands] = useState<Map<string, Command>>(() => {
|
|
const m = new Map<string, Command>();
|
|
for (const c of initial) m.set(c.id, c);
|
|
return m;
|
|
});
|
|
|
|
const register = useCallback<RegistryState['register']>(next => {
|
|
setCommands(prev => {
|
|
const m = new Map(prev);
|
|
for (const c of next) m.set(c.id, c);
|
|
return m;
|
|
});
|
|
return () => {
|
|
setCommands(prev => {
|
|
const m = new Map(prev);
|
|
for (const c of next) m.delete(c.id);
|
|
return m;
|
|
});
|
|
};
|
|
}, []);
|
|
|
|
const unregister = useCallback<RegistryState['unregister']>(ids => {
|
|
setCommands(prev => {
|
|
const m = new Map(prev);
|
|
for (const id of ids) m.delete(id);
|
|
return m;
|
|
});
|
|
}, []);
|
|
|
|
const clear = useCallback(() => setCommands(new Map()), []);
|
|
|
|
const value = useMemo<RegistryState>(
|
|
() => ({ commands, register, unregister, clear }),
|
|
[commands, register, unregister, clear],
|
|
);
|
|
|
|
return <RegistryContext.Provider value={value}>{children}</RegistryContext.Provider>;
|
|
}
|
|
|
|
function useRegistry(): RegistryState {
|
|
const ctx = useContext(RegistryContext);
|
|
if (!ctx) {
|
|
throw new Error(
|
|
'@bytelyst/command-palette: useRegisterCommands / useCommands must be used within <CommandRegistryProvider>',
|
|
);
|
|
}
|
|
return ctx;
|
|
}
|
|
|
|
/**
|
|
* Register commands for the lifetime of the calling component. Returns a
|
|
* stable function callers can ignore — registration unwinds automatically
|
|
* on unmount via the returned effect cleanup.
|
|
*
|
|
* @example
|
|
* ```tsx
|
|
* useRegisterCommands([
|
|
* { id: 'new-task', label: 'New task', icon: '+', run: openNewTask },
|
|
* { id: 'goto-billing', label: 'Billing', mode: 'navigate', href: '/billing' },
|
|
* ]);
|
|
* ```
|
|
*/
|
|
export function useRegisterCommands(commands: Command[]): void {
|
|
const { register } = useRegistry();
|
|
// Stable ref to the most-recent commands list. We intentionally avoid
|
|
// re-registering on every keystroke / state tick — callers usually
|
|
// pass an inline array. The serialization key below keeps it cheap.
|
|
const key = useMemo(
|
|
() =>
|
|
commands
|
|
.map(c => `${c.id}|${c.label}|${c.mode ?? ''}|${c.section ?? ''}`)
|
|
.join('\n'),
|
|
[commands],
|
|
);
|
|
|
|
useEffect(() => {
|
|
const off = register(commands);
|
|
return off;
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps -- key is the canonical identity
|
|
}, [key, register]);
|
|
}
|
|
|
|
/** Read the current registry snapshot. */
|
|
export function useCommands(): Command[] {
|
|
const { commands } = useRegistry();
|
|
return useMemo(() => Array.from(commands.values()), [commands]);
|
|
}
|
|
|
|
/** Imperative access (e.g. for tests or shell-level wiring). */
|
|
export function useCommandRegistry(): RegistryState {
|
|
return useRegistry();
|
|
}
|