═══════════════════════════════════════════════════════════════════════
@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)
248 lines
8.2 KiB
TypeScript
248 lines
8.2 KiB
TypeScript
import { useEffect, useRef, useState, type CSSProperties } from 'react';
|
|
|
|
export interface ModelCapability {
|
|
/** Short id (e.g. 'vision', 'tools', 'streaming'). */
|
|
id: string;
|
|
/** Human-friendly label rendered in the chip. */
|
|
label: string;
|
|
}
|
|
|
|
export interface ModelOption {
|
|
/** Stable model id (e.g. 'gpt-4o', 'claude-3-5-sonnet'). */
|
|
id: string;
|
|
/** Display name. */
|
|
name: string;
|
|
/** Provider label (e.g. 'OpenAI', 'Anthropic', 'Local'). */
|
|
provider: string;
|
|
/** Optional avatar / glyph for the provider. */
|
|
providerGlyph?: string;
|
|
/** Cost per 1k input tokens (USD), used for sort + display. */
|
|
inputCostPer1k?: number;
|
|
/** Cost per 1k output tokens (USD). */
|
|
outputCostPer1k?: number;
|
|
/** Typical latency ms (P50). */
|
|
latencyMs?: number;
|
|
/** Context window size in tokens. */
|
|
contextTokens?: number;
|
|
/** Capability chips rendered next to the model name. */
|
|
capabilities?: ModelCapability[];
|
|
/** Mark the model as unavailable (greyed out, unselectable). */
|
|
disabled?: boolean;
|
|
/** Optional short description. */
|
|
description?: string;
|
|
}
|
|
|
|
export interface ModelPickerProps {
|
|
models: ModelOption[];
|
|
value: string;
|
|
onChange: (modelId: string) => void;
|
|
/** Show cost cells. Default: true. */
|
|
showCost?: boolean;
|
|
/** Show latency cells. Default: true. */
|
|
showLatency?: boolean;
|
|
/** Trigger label override (default: the active model's name). */
|
|
triggerLabel?: string;
|
|
className?: string;
|
|
style?: CSSProperties;
|
|
}
|
|
|
|
/**
|
|
* `<ModelPicker>` — dropdown that exposes the full capability profile
|
|
* of each available LLM. Designed for the top of a chat surface where
|
|
* power-users want to swap models mid-conversation.
|
|
*
|
|
* Shows: provider · name · capability chips · ctx window · cost · latency.
|
|
* Closes on outside-click and Escape.
|
|
*/
|
|
export function ModelPicker({
|
|
models,
|
|
value,
|
|
onChange,
|
|
showCost = true,
|
|
showLatency = true,
|
|
triggerLabel,
|
|
className,
|
|
style,
|
|
}: ModelPickerProps) {
|
|
const [open, setOpen] = useState(false);
|
|
const rootRef = useRef<HTMLDivElement>(null);
|
|
|
|
useEffect(() => {
|
|
if (!open) return;
|
|
const handler = (e: MouseEvent) => {
|
|
if (!rootRef.current?.contains(e.target as Node)) setOpen(false);
|
|
};
|
|
const keys = (e: KeyboardEvent) => {
|
|
if (e.key === 'Escape') setOpen(false);
|
|
};
|
|
document.addEventListener('mousedown', handler);
|
|
document.addEventListener('keydown', keys);
|
|
return () => {
|
|
document.removeEventListener('mousedown', handler);
|
|
document.removeEventListener('keydown', keys);
|
|
};
|
|
}, [open]);
|
|
|
|
const active = models.find(m => m.id === value);
|
|
|
|
return (
|
|
<div
|
|
ref={rootRef}
|
|
data-testid="bl-model-picker"
|
|
className={className}
|
|
style={{ position: 'relative', display: 'inline-block', ...style }}
|
|
>
|
|
<button
|
|
type="button"
|
|
data-testid="bl-model-picker-trigger"
|
|
aria-haspopup="listbox"
|
|
aria-expanded={open}
|
|
onClick={() => setOpen(o => !o)}
|
|
style={{
|
|
display: 'inline-flex',
|
|
alignItems: 'center',
|
|
gap: 'var(--bl-space-2, 8px)',
|
|
padding: 'var(--bl-space-1, 4px) var(--bl-space-2, 8px)',
|
|
background: 'var(--bl-surface-card, #fff)',
|
|
border: '1px solid var(--bl-border, rgba(0,0,0,0.12))',
|
|
borderRadius: 'var(--bl-radius-pill, 999px)',
|
|
color: 'var(--bl-text-primary, inherit)',
|
|
fontSize: '0.85rem',
|
|
cursor: 'pointer',
|
|
minWidth: 0,
|
|
}}
|
|
>
|
|
{active?.providerGlyph && <span aria-hidden>{active.providerGlyph}</span>}
|
|
<span style={{ fontWeight: 600, whiteSpace: 'nowrap' }}>
|
|
{triggerLabel ?? active?.name ?? 'Select model'}
|
|
</span>
|
|
<span aria-hidden style={{ color: 'var(--bl-text-tertiary, #888)' }}>
|
|
▾
|
|
</span>
|
|
</button>
|
|
|
|
{open && (
|
|
<div
|
|
role="listbox"
|
|
data-testid="bl-model-picker-listbox"
|
|
aria-label="Model selector"
|
|
style={{
|
|
position: 'absolute',
|
|
top: 'calc(100% + 6px)',
|
|
right: 0,
|
|
zIndex: 50,
|
|
minWidth: 340,
|
|
maxWidth: 480,
|
|
maxHeight: 420,
|
|
overflowY: 'auto',
|
|
background: 'var(--bl-surface-card, #fff)',
|
|
border: '1px solid var(--bl-border, rgba(0,0,0,0.12))',
|
|
borderRadius: 'var(--bl-radius-card, 10px)',
|
|
boxShadow: '0 16px 40px rgba(0,0,0,0.20)',
|
|
padding: 'var(--bl-space-1, 4px)',
|
|
}}
|
|
>
|
|
{models.map(m => (
|
|
<button
|
|
key={m.id}
|
|
type="button"
|
|
role="option"
|
|
aria-selected={m.id === value}
|
|
data-testid={`bl-model-picker-option-${m.id}`}
|
|
disabled={m.disabled}
|
|
onClick={() => {
|
|
onChange(m.id);
|
|
setOpen(false);
|
|
}}
|
|
style={{
|
|
display: 'block',
|
|
width: '100%',
|
|
textAlign: 'left',
|
|
padding: 'var(--bl-space-2, 8px) var(--bl-space-3, 12px)',
|
|
background:
|
|
m.id === value
|
|
? 'var(--bl-accent-muted, rgba(99,102,241,0.12))'
|
|
: 'transparent',
|
|
border: 'none',
|
|
borderRadius: 'var(--bl-radius-control, 6px)',
|
|
color: m.disabled
|
|
? 'var(--bl-text-tertiary, #888)'
|
|
: 'var(--bl-text-primary, inherit)',
|
|
cursor: m.disabled ? 'not-allowed' : 'pointer',
|
|
opacity: m.disabled ? 0.5 : 1,
|
|
}}
|
|
>
|
|
<div style={{ display: 'flex', alignItems: 'baseline', gap: 6 }}>
|
|
<span style={{ fontWeight: 600, fontSize: '0.9rem' }}>{m.name}</span>
|
|
<span
|
|
style={{
|
|
fontSize: '0.7rem',
|
|
color: 'var(--bl-text-tertiary, #888)',
|
|
}}
|
|
>
|
|
{m.provider}
|
|
</span>
|
|
{m.capabilities && m.capabilities.length > 0 && (
|
|
<span style={{ marginLeft: 'auto', display: 'flex', gap: 4 }}>
|
|
{m.capabilities.map(c => (
|
|
<span
|
|
key={c.id}
|
|
style={{
|
|
fontSize: '0.65rem',
|
|
padding: '1px 6px',
|
|
borderRadius: 'var(--bl-radius-pill, 999px)',
|
|
background: 'var(--bl-surface-muted, rgba(0,0,0,0.05))',
|
|
color: 'var(--bl-text-secondary, #555)',
|
|
}}
|
|
>
|
|
{c.label}
|
|
</span>
|
|
))}
|
|
</span>
|
|
)}
|
|
</div>
|
|
{m.description && (
|
|
<div
|
|
style={{
|
|
fontSize: '0.75rem',
|
|
color: 'var(--bl-text-secondary, #555)',
|
|
marginTop: 2,
|
|
}}
|
|
>
|
|
{m.description}
|
|
</div>
|
|
)}
|
|
{(showCost || showLatency || m.contextTokens) && (
|
|
<div
|
|
style={{
|
|
display: 'flex',
|
|
gap: 12,
|
|
marginTop: 6,
|
|
fontSize: '0.7rem',
|
|
color: 'var(--bl-text-tertiary, #888)',
|
|
}}
|
|
>
|
|
{showCost && typeof m.inputCostPer1k === 'number' && (
|
|
<span>
|
|
${m.inputCostPer1k.toFixed(3)} in
|
|
{typeof m.outputCostPer1k === 'number' &&
|
|
` · $${m.outputCostPer1k.toFixed(3)} out`}
|
|
/1k
|
|
</span>
|
|
)}
|
|
{showLatency && typeof m.latencyMs === 'number' && (
|
|
<span>~{m.latencyMs}ms</span>
|
|
)}
|
|
{m.contextTokens && (
|
|
<span>{(m.contextTokens / 1000).toFixed(0)}k ctx</span>
|
|
)}
|
|
</div>
|
|
)}
|
|
</button>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|