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

138 lines
4.7 KiB
TypeScript

import { useCallback, useMemo, useState } from 'react';
import type { ToolCallStatus, ToolInvocation } from './types.js';
export interface UseToolCallsHelpers {
/** Map of id → ToolInvocation, stable across renders. */
invocations: Record<string, ToolInvocation>;
/** Ordered list of invocations (insertion order). */
ordered: ToolInvocation[];
/** Begin a new tool call. Idempotent — repeated calls update in place. */
begin: (
init: Pick<ToolInvocation, 'id' | 'name'> & Partial<Omit<ToolInvocation, 'id' | 'name'>>,
) => void;
/** Merge a partial update into an existing invocation. */
update: (id: string, patch: Partial<ToolInvocation>) => void;
/** Transition an invocation to its terminal state (success / error). */
settle: (
id: string,
result:
| { status: 'success'; output?: unknown }
| { status: 'error'; error: string },
) => void;
/** Remove a specific invocation (e.g. on reload). */
clear: (id: string) => void;
/** Remove all invocations. */
clearAll: () => void;
}
/**
* `useToolCalls` — per-turn tool-invocation state machine.
*
* Stores tool calls in two parallel shapes — a Record for O(1) lookup
* by id and an ordered array for rendering. Insertion order is preserved
* across `update` calls so vertical scroll position stays stable while
* a stream is in flight.
*
* Typical wiring (Wave 2 0.2 — usage with raw streams):
*
* ```ts
* const tools = useToolCalls();
* for await (const event of parseToolStream(response.body)) {
* if (event.type === 'tool-call') tools.begin(event);
* if (event.type === 'tool-delta') tools.update(event.id, event);
* if (event.type === 'tool-result') tools.settle(event.id, event.result);
* }
* ```
*
* 0.4 (`<ToolPalette>` with MCP discovery) will layer auto-wiring on top
* of this hook.
*/
export function useToolCalls(initial: ToolInvocation[] = []): UseToolCallsHelpers {
const [invocations, setInvocations] = useState<Record<string, ToolInvocation>>(
() => Object.fromEntries(initial.map(t => [t.id, t])),
);
const [order, setOrder] = useState<string[]>(() => initial.map(t => t.id));
const begin = useCallback<UseToolCallsHelpers['begin']>(init => {
const id = init.id;
const status: ToolCallStatus = init.status ?? 'pending';
const startedAt = performance.now();
setInvocations(prev => {
const existing = prev[id];
const next: ToolInvocation = {
...existing,
status,
...init,
// Carry the start timestamp into a private field for later
// settle() to compute durationMs. We tuck it under a symbol-
// free convention so consumers can still read everything.
};
// Stash startedAt on a hidden field. We can't use Symbol because
// we want this hook to survive JSON serialization in tests.
(next as { __startedAt?: number }).__startedAt = startedAt;
return { ...prev, [id]: next };
});
setOrder(prev => (prev.includes(id) ? prev : [...prev, id]));
}, []);
const update = useCallback<UseToolCallsHelpers['update']>((id, patch) => {
setInvocations(prev => {
const existing = prev[id];
if (!existing) return prev;
return { ...prev, [id]: { ...existing, ...patch } };
});
}, []);
const settle = useCallback<UseToolCallsHelpers['settle']>((id, result) => {
setInvocations(prev => {
const existing = prev[id];
if (!existing) return prev;
const startedAt = (existing as { __startedAt?: number }).__startedAt;
const durationMs =
typeof startedAt === 'number'
? Math.max(0, Math.round(performance.now() - startedAt))
: existing.durationMs;
const next: ToolInvocation =
result.status === 'success'
? {
...existing,
status: 'success',
output: result.output ?? existing.output,
error: undefined,
durationMs,
}
: {
...existing,
status: 'error',
error: result.error,
durationMs,
};
return { ...prev, [id]: next };
});
}, []);
const clear = useCallback<UseToolCallsHelpers['clear']>(id => {
setInvocations(prev => {
if (!(id in prev)) return prev;
const { [id]: _drop, ...rest } = prev;
return rest;
});
setOrder(prev => prev.filter(x => x !== id));
}, []);
const clearAll = useCallback(() => {
setInvocations({});
setOrder([]);
}, []);
const ordered = useMemo(
() =>
order
.map(id => invocations[id])
.filter((x): x is ToolInvocation => x !== undefined),
[order, invocations],
);
return { invocations, ordered, begin, update, settle, clear, clearAll };
}