═══════════════════════════════════════════════════════════════════════
@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)
235 lines
6.2 KiB
TypeScript
235 lines
6.2 KiB
TypeScript
import { useState, type CSSProperties, type ReactNode } from 'react';
|
|
import type { ToolCallStatus, ToolInvocation } from './types.js';
|
|
|
|
export interface ToolCallCardProps {
|
|
invocation: ToolInvocation;
|
|
/** Initially expanded? Default: false. */
|
|
defaultExpanded?: boolean;
|
|
/** Override the title rendered to the right of the status pill. */
|
|
title?: ReactNode;
|
|
/** Render a custom output preview (overrides default JSON pretty-print). */
|
|
renderOutput?: (output: unknown) => ReactNode;
|
|
className?: string;
|
|
style?: CSSProperties;
|
|
}
|
|
|
|
/**
|
|
* Disclosure card showing a single LLM tool call. Collapsed by default,
|
|
* expands to show the input + output JSON. Status pill + name are always
|
|
* visible.
|
|
*
|
|
* Visual states:
|
|
* pending — gray pill, dotted spinner
|
|
* streaming — accent pill, animated dots
|
|
* success — success-token pill, ✓
|
|
* error — danger-token pill, ✕ + error string in body
|
|
*/
|
|
export function ToolCallCard({
|
|
invocation,
|
|
defaultExpanded = false,
|
|
title,
|
|
renderOutput,
|
|
className,
|
|
style,
|
|
}: ToolCallCardProps) {
|
|
const [open, setOpen] = useState(defaultExpanded);
|
|
const { name, status, input, output, error, durationMs } = invocation;
|
|
|
|
return (
|
|
<article
|
|
data-testid={`bl-tool-call-${invocation.id}`}
|
|
data-status={status}
|
|
className={className}
|
|
style={{
|
|
border: '1px solid var(--bl-border, rgba(0,0,0,0.08))',
|
|
borderRadius: 'var(--bl-radius-card, 10px)',
|
|
background: 'var(--bl-surface-card, #fff)',
|
|
overflow: 'hidden',
|
|
fontSize: '0.85rem',
|
|
...style,
|
|
}}
|
|
>
|
|
<button
|
|
type="button"
|
|
data-testid="bl-tool-call-toggle"
|
|
onClick={() => setOpen(o => !o)}
|
|
aria-expanded={open}
|
|
style={{
|
|
width: '100%',
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
gap: 'var(--bl-space-2, 8px)',
|
|
padding: 'var(--bl-space-2, 8px) var(--bl-space-3, 12px)',
|
|
background: 'transparent',
|
|
border: 'none',
|
|
textAlign: 'left',
|
|
cursor: 'pointer',
|
|
color: 'inherit',
|
|
}}
|
|
>
|
|
<StatusPill status={status} />
|
|
<span style={{ fontFamily: 'var(--bl-font-mono, ui-monospace)', fontWeight: 600 }}>
|
|
{title ?? name}
|
|
</span>
|
|
{typeof durationMs === 'number' && (
|
|
<span
|
|
style={{
|
|
marginLeft: 'auto',
|
|
fontSize: '0.75rem',
|
|
color: 'var(--bl-text-tertiary, #888)',
|
|
}}
|
|
>
|
|
{durationMs}ms
|
|
</span>
|
|
)}
|
|
<span
|
|
aria-hidden
|
|
style={{
|
|
transition: 'transform 120ms ease',
|
|
transform: open ? 'rotate(90deg)' : 'rotate(0deg)',
|
|
color: 'var(--bl-text-tertiary, #888)',
|
|
}}
|
|
>
|
|
▸
|
|
</span>
|
|
</button>
|
|
|
|
{open && (
|
|
<div
|
|
data-testid="bl-tool-call-body"
|
|
style={{
|
|
borderTop: '1px solid var(--bl-border-subtle, rgba(0,0,0,0.06))',
|
|
padding: 'var(--bl-space-3, 12px)',
|
|
display: 'flex',
|
|
flexDirection: 'column',
|
|
gap: 'var(--bl-space-2, 8px)',
|
|
fontFamily: 'var(--bl-font-mono, ui-monospace, monospace)',
|
|
fontSize: '0.78rem',
|
|
background: 'var(--bl-surface-muted, #fafafa)',
|
|
}}
|
|
>
|
|
{input !== undefined && (
|
|
<Section label="input">
|
|
<Pre>{stringifyShort(input)}</Pre>
|
|
</Section>
|
|
)}
|
|
{status === 'error' ? (
|
|
<Section label="error" tone="danger">
|
|
<Pre>{error ?? 'Tool call failed.'}</Pre>
|
|
</Section>
|
|
) : output !== undefined ? (
|
|
<Section label="output">
|
|
{renderOutput ? renderOutput(output) : <Pre>{stringifyShort(output)}</Pre>}
|
|
</Section>
|
|
) : null}
|
|
</div>
|
|
)}
|
|
</article>
|
|
);
|
|
}
|
|
|
|
function StatusPill({ status }: { status: ToolCallStatus }) {
|
|
const map: Record<ToolCallStatus, { bg: string; fg: string; label: string }> = {
|
|
pending: {
|
|
bg: 'var(--bl-surface-muted, rgba(0,0,0,0.04))',
|
|
fg: 'var(--bl-text-tertiary, #888)',
|
|
label: '…',
|
|
},
|
|
streaming: {
|
|
bg: 'var(--bl-accent-muted, rgba(99,102,241,0.12))',
|
|
fg: 'var(--bl-accent, #6366f1)',
|
|
label: '⋯',
|
|
},
|
|
success: {
|
|
bg: 'var(--bl-success-muted, rgba(34,197,94,0.12))',
|
|
fg: 'var(--bl-success, #22c55e)',
|
|
label: '✓',
|
|
},
|
|
error: {
|
|
bg: 'var(--bl-danger-muted, rgba(239,68,68,0.12))',
|
|
fg: 'var(--bl-danger, #ef4444)',
|
|
label: '✕',
|
|
},
|
|
};
|
|
const v = map[status];
|
|
return (
|
|
<span
|
|
data-testid="bl-tool-call-status"
|
|
aria-label={`Status: ${status}`}
|
|
style={{
|
|
display: 'inline-flex',
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
width: 22,
|
|
height: 22,
|
|
borderRadius: 'var(--bl-radius-pill, 999px)',
|
|
background: v.bg,
|
|
color: v.fg,
|
|
fontSize: 12,
|
|
fontWeight: 700,
|
|
flexShrink: 0,
|
|
}}
|
|
>
|
|
{v.label}
|
|
</span>
|
|
);
|
|
}
|
|
|
|
function Section({
|
|
label,
|
|
tone,
|
|
children,
|
|
}: {
|
|
label: string;
|
|
tone?: 'danger';
|
|
children: ReactNode;
|
|
}) {
|
|
return (
|
|
<div>
|
|
<div
|
|
style={{
|
|
fontSize: '0.7rem',
|
|
fontWeight: 700,
|
|
textTransform: 'uppercase',
|
|
letterSpacing: '0.06em',
|
|
color:
|
|
tone === 'danger'
|
|
? 'var(--bl-danger, #ef4444)'
|
|
: 'var(--bl-text-tertiary, #888)',
|
|
marginBottom: 'var(--bl-space-1, 4px)',
|
|
}}
|
|
>
|
|
{label}
|
|
</div>
|
|
{children}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function Pre({ children }: { children: ReactNode }) {
|
|
return (
|
|
<pre
|
|
style={{
|
|
margin: 0,
|
|
whiteSpace: 'pre-wrap',
|
|
wordBreak: 'break-word',
|
|
background: 'var(--bl-surface-card, #fff)',
|
|
padding: 'var(--bl-space-2, 8px)',
|
|
borderRadius: 'var(--bl-radius-control, 6px)',
|
|
border: '1px solid var(--bl-border-subtle, rgba(0,0,0,0.06))',
|
|
}}
|
|
>
|
|
{children}
|
|
</pre>
|
|
);
|
|
}
|
|
|
|
function stringifyShort(value: unknown): string {
|
|
if (typeof value === 'string') return value;
|
|
try {
|
|
return JSON.stringify(value, null, 2);
|
|
} catch {
|
|
return String(value);
|
|
}
|
|
}
|