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

141 lines
4.2 KiB
TypeScript

import { useState, type CSSProperties, type ReactNode } from 'react';
import type { Citation } from './types.js';
export interface CitationChipProps {
citation: Citation;
/** Override the chip body — useful for icons. Defaults to `[n]`. */
children?: ReactNode;
/** Suppress the popover preview (e.g. when used inline in dense lists). */
noPreview?: boolean;
className?: string;
style?: CSSProperties;
}
/**
* Inline citation marker. Renders a small `[n]` chip; on hover or focus
* a preview pops up with the title, snippet, and an outbound link.
*
* The preview is a CSS-only popover (no portal, no JS layout) so it
* stays cheap to embed dozens of times in a single message body.
*/
export function CitationChip({
citation,
children,
noPreview = false,
className,
style,
}: CitationChipProps) {
const [hover, setHover] = useState(false);
const [focus, setFocus] = useState(false);
const open = !noPreview && (hover || focus);
const Wrapper = citation.url ? 'a' : 'span';
const wrapperProps = citation.url
? {
href: citation.url,
target: '_blank',
rel: 'noopener noreferrer',
}
: {};
return (
<span
style={{ position: 'relative', display: 'inline-block', ...style }}
className={className}
onMouseEnter={() => setHover(true)}
onMouseLeave={() => setHover(false)}
>
<Wrapper
data-testid={`bl-citation-${citation.id}`}
data-citation-index={citation.index}
{...wrapperProps}
onFocus={() => setFocus(true)}
onBlur={() => setFocus(false)}
style={{
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
minWidth: 18,
height: 18,
padding: '0 4px',
marginInline: 2,
fontSize: 11,
fontWeight: 700,
lineHeight: 1,
color: 'var(--bl-accent, #6366f1)',
background: 'var(--bl-accent-muted, rgba(99,102,241,0.12))',
borderRadius: 'var(--bl-radius-pill, 999px)',
textDecoration: 'none',
cursor: citation.url ? 'pointer' : 'help',
verticalAlign: 'super',
}}
>
{children ?? citation.index}
</Wrapper>
{open && (
<span
role="tooltip"
data-testid={`bl-citation-preview-${citation.id}`}
style={{
position: 'absolute',
bottom: 'calc(100% + 6px)',
left: 0,
zIndex: 50,
display: 'block',
width: 280,
padding: 'var(--bl-space-3, 12px)',
background: 'var(--bl-surface-card, #fff)',
color: 'var(--bl-text-primary, inherit)',
border: '1px solid var(--bl-border, rgba(0,0,0,0.08))',
borderRadius: 'var(--bl-radius-card, 10px)',
boxShadow: '0 10px 30px rgba(0,0,0,0.18)',
fontSize: '0.8rem',
lineHeight: 1.45,
fontWeight: 400,
textAlign: 'left',
whiteSpace: 'normal',
pointerEvents: 'none', // hover-only preview; avoids flicker
}}
>
{citation.source && (
<div
style={{
fontSize: '0.65rem',
textTransform: 'uppercase',
letterSpacing: '0.06em',
color: 'var(--bl-text-tertiary, #888)',
marginBottom: 2,
}}
>
{citation.source}
</div>
)}
<div style={{ fontWeight: 600, marginBottom: 4 }}>{citation.title}</div>
{citation.snippet && (
<div style={{ color: 'var(--bl-text-secondary, #444)' }}>
{citation.snippet}
</div>
)}
{citation.url && (
<div
style={{
marginTop: 6,
fontSize: '0.7rem',
color: 'var(--bl-text-tertiary, #888)',
wordBreak: 'break-all',
}}
>
{truncateUrl(citation.url)}
</div>
)}
</span>
)}
</span>
);
}
function truncateUrl(url: string, max = 56): string {
return url.length <= max ? url : `${url.slice(0, max - 1)}`;
}