learning_ai_common_plat/packages/ai-ui/src/ToolCallCard.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

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);
}
}