@medalsocial/meda 0.1.1 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (73) hide show
  1. package/README.md +7 -2
  2. package/dist/chat/index.d.ts +11 -0
  3. package/dist/chat/index.js +5 -0
  4. package/dist/chat/latency-badge.d.ts +7 -0
  5. package/dist/chat/latency-badge.js +19 -0
  6. package/dist/chat/latency-breakdown.d.ts +8 -0
  7. package/dist/chat/latency-breakdown.js +9 -0
  8. package/dist/chat/public.d.ts +1 -0
  9. package/dist/chat/public.js +1 -0
  10. package/dist/chat/tool-call-block.d.ts +6 -0
  11. package/dist/chat/tool-call-block.js +19 -0
  12. package/dist/chat/transcript-stream.d.ts +10 -0
  13. package/dist/chat/transcript-stream.js +40 -0
  14. package/dist/chat/turn-card.d.ts +10 -0
  15. package/dist/chat/turn-card.js +23 -0
  16. package/dist/chat/types.d.ts +36 -0
  17. package/dist/chat/types.js +1 -0
  18. package/dist/index.d.ts +3 -0
  19. package/dist/index.js +3 -0
  20. package/dist/lib/format-time.d.ts +9 -0
  21. package/dist/lib/format-time.js +34 -0
  22. package/dist/panel/index.d.ts +6 -0
  23. package/dist/panel/index.js +3 -0
  24. package/dist/panel/inspector-field.d.ts +9 -0
  25. package/dist/panel/inspector-field.js +4 -0
  26. package/dist/panel/inspector-json.d.ts +7 -0
  27. package/dist/panel/inspector-json.js +73 -0
  28. package/dist/panel/inspector.d.ts +7 -0
  29. package/dist/panel/inspector.js +12 -0
  30. package/dist/panel/public.d.ts +1 -0
  31. package/dist/panel/public.js +1 -0
  32. package/dist/panel/types.d.ts +6 -0
  33. package/dist/panel/types.js +1 -0
  34. package/dist/styles.css +22 -0
  35. package/dist/timeline/date-switcher.d.ts +11 -0
  36. package/dist/timeline/date-switcher.js +62 -0
  37. package/dist/timeline/event-card.d.ts +9 -0
  38. package/dist/timeline/event-card.js +26 -0
  39. package/dist/timeline/index.d.ts +13 -0
  40. package/dist/timeline/index.js +6 -0
  41. package/dist/timeline/live-indicator.d.ts +7 -0
  42. package/dist/timeline/live-indicator.js +5 -0
  43. package/dist/timeline/public.d.ts +1 -0
  44. package/dist/timeline/public.js +1 -0
  45. package/dist/timeline/scrub-bar.d.ts +14 -0
  46. package/dist/timeline/scrub-bar.js +44 -0
  47. package/dist/timeline/timeline-rail.d.ts +19 -0
  48. package/dist/timeline/timeline-rail.js +64 -0
  49. package/dist/timeline/timeline-tape.d.ts +19 -0
  50. package/dist/timeline/timeline-tape.js +66 -0
  51. package/dist/timeline/types.d.ts +40 -0
  52. package/dist/timeline/types.js +1 -0
  53. package/dist/voice/index.d.ts +9 -0
  54. package/dist/voice/index.js +4 -0
  55. package/dist/voice/public.d.ts +1 -0
  56. package/dist/voice/public.js +1 -0
  57. package/dist/voice/types.d.ts +8 -0
  58. package/dist/voice/types.js +1 -0
  59. package/dist/voice/use-mic-capture.d.ts +14 -0
  60. package/dist/voice/use-mic-capture.js +185 -0
  61. package/dist/voice/voice-level.d.ts +8 -0
  62. package/dist/voice/voice-level.js +29 -0
  63. package/dist/voice/voice-orb-scene.d.ts +12 -0
  64. package/dist/voice/voice-orb-scene.js +121 -0
  65. package/dist/voice/voice-orb-shader.d.ts +2 -0
  66. package/dist/voice/voice-orb-shader.js +255 -0
  67. package/dist/voice/voice-orb-volume.d.ts +12 -0
  68. package/dist/voice/voice-orb-volume.js +21 -0
  69. package/dist/voice/voice-orb.d.ts +45 -0
  70. package/dist/voice/voice-orb.js +123 -0
  71. package/dist/voice/voice-status-pill.d.ts +7 -0
  72. package/dist/voice/voice-status-pill.js +26 -0
  73. package/package.json +36 -3
@@ -0,0 +1,6 @@
1
+ export { DateSwitcher } from './date-switcher.js';
2
+ export { EventCard } from './event-card.js';
3
+ export { LiveIndicator } from './live-indicator.js';
4
+ export { ScrubBar } from './scrub-bar.js';
5
+ export { TimelineRail } from './timeline-rail.js';
6
+ export { TimelineTape } from './timeline-tape.js';
@@ -0,0 +1,7 @@
1
+ export interface LiveIndicatorProps {
2
+ now: Date;
3
+ /** Optional IANA timezone for the displayed clock. */
4
+ tz?: string;
5
+ className?: string;
6
+ }
7
+ export declare function LiveIndicator({ now, tz, className }: LiveIndicatorProps): import("react/jsx-runtime").JSX.Element;
@@ -0,0 +1,5 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { formatClock } from '../lib/format-time.js';
3
+ export function LiveIndicator({ now, tz, className }) {
4
+ return (_jsxs("div", { role: "status", "aria-live": "polite", className: ['relative h-0 pointer-events-none', className ?? ''].join(' '), children: [_jsx("span", { className: "pointer-events-auto absolute left-0 top-0 inline-flex items-center rounded-md bg-emerald-500 px-2.5 py-1 text-[11px] font-bold uppercase tracking-wider text-white shadow-[0_6px_18px_rgba(16,185,129,0.35)]", children: "LIVE" }), _jsx("span", { "aria-hidden": "true", className: "absolute right-0 top-1/2 left-[60px] h-px bg-gradient-to-r from-emerald-500 via-emerald-500/30 to-transparent" }), _jsx("span", { className: "absolute right-1.5 top-[-2px] text-[10px] font-semibold tabular-nums text-emerald-500", children: formatClock(now, { tz }) })] }));
5
+ }
@@ -0,0 +1 @@
1
+ export type { ScrubMark, TimelineEvent, TimelineEventKind } from './types.js';
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,14 @@
1
+ import type { ScrubMark } from './types.js';
2
+ export interface ScrubBarProps {
3
+ durationMs: number;
4
+ positionMs: number;
5
+ isLive?: boolean;
6
+ marks: ScrubMark[];
7
+ onSeek: (positionMs: number) => void;
8
+ isPlaying?: boolean;
9
+ onPlayPause?: () => void;
10
+ onSkipBack?: () => void;
11
+ onSkipForward?: () => void;
12
+ className?: string;
13
+ }
14
+ export declare function ScrubBar({ durationMs, positionMs, isLive, marks, onSeek, isPlaying, onPlayPause, onSkipBack, onSkipForward, className, }: ScrubBarProps): import("react/jsx-runtime").JSX.Element;
@@ -0,0 +1,44 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Pause, Play, SkipBack, SkipForward } from 'lucide-react';
3
+ import { useRef } from 'react';
4
+ import { formatDuration } from '../lib/format-time.js';
5
+ const KIND_COLOR = {
6
+ turn: 'bg-primary',
7
+ tool: 'bg-amber-500',
8
+ barge: 'bg-emerald-500',
9
+ error: 'bg-destructive',
10
+ };
11
+ export function ScrubBar({ durationMs, positionMs, isLive = false, marks, onSeek, isPlaying, onPlayPause, onSkipBack, onSkipForward, className, }) {
12
+ const trackRef = useRef(null);
13
+ const showPlayPause = isPlaying !== undefined;
14
+ const isPlayingValue = isPlaying ?? false;
15
+ const fillPct = durationMs > 0 ? Math.min(100, (positionMs / durationMs) * 100) : 0;
16
+ const handleSeek = (e) => {
17
+ const rect = trackRef.current?.getBoundingClientRect();
18
+ if (!rect || rect.width === 0)
19
+ return;
20
+ const x = e.clientX - rect.left;
21
+ const pct = Math.max(0, Math.min(1, x / rect.width));
22
+ onSeek(pct * durationMs);
23
+ };
24
+ const handleKeyDown = (e) => {
25
+ const step = durationMs / 100;
26
+ if (e.key === 'ArrowLeft') {
27
+ e.preventDefault();
28
+ onSeek(Math.max(0, positionMs - step));
29
+ }
30
+ else if (e.key === 'ArrowRight') {
31
+ e.preventDefault();
32
+ onSeek(Math.min(durationMs, positionMs + step));
33
+ }
34
+ };
35
+ return (_jsxs("div", { className: ['rounded-2xl border border-border bg-card p-3.5', className ?? ''].join(' '), children: [_jsxs("div", { className: "mb-2.5 flex items-center justify-between", children: [_jsx("span", { className: "text-[11px] font-semibold uppercase tracking-wider text-muted-foreground", children: "Timeline \u00B7 this call" }), _jsxs("div", { className: "flex items-center gap-1", children: [_jsxs("span", { className: "px-1.5 text-[11px] tabular-nums text-muted-foreground", children: [formatDuration(positionMs), isLive ? ' / live' : ` / ${formatDuration(durationMs)}`] }), onSkipBack && (_jsx(TransportBtn, { label: "Skip back", onClick: onSkipBack, children: _jsx(SkipBack, { className: "size-3.5" }) })), showPlayPause && (_jsx(TransportBtn, { label: isPlayingValue ? 'Pause' : 'Play', variant: "primary", onClick: onPlayPause ?? (() => { }), children: isPlayingValue ? _jsx(Pause, { className: "size-3.5" }) : _jsx(Play, { className: "size-3.5" }) })), onSkipForward && (_jsx(TransportBtn, { label: "Skip forward", onClick: onSkipForward, children: _jsx(SkipForward, { className: "size-3.5" }) }))] })] }), _jsxs("div", { ref: trackRef, role: "slider", "aria-label": "Conversation position", "aria-valuemin": 0, "aria-valuemax": durationMs, "aria-valuenow": positionMs, onClick: handleSeek, onKeyDown: handleKeyDown, tabIndex: 0, className: "relative h-8 cursor-pointer overflow-hidden rounded-md bg-muted focus:outline-none focus:ring-2 focus:ring-primary/50", children: [_jsx("span", { "aria-hidden": "true", className: "absolute inset-y-0 left-0 bg-primary/10", style: { width: `${fillPct}%` } }), marks.map((m) => (_jsx("span", { "data-mark-kind": m.kind, title: m.label ?? m.kind, className: ['absolute inset-y-1 w-0.5 rounded-sm', KIND_COLOR[m.kind]].join(' '), style: { left: `${m.positionPct}%` } }, m.id))), _jsx("span", { "aria-hidden": "true", className: "absolute -inset-y-0.5 w-0.5 bg-foreground", style: { left: `${fillPct}%` } })] }), _jsxs("div", { className: "flex justify-between pt-2 text-[10px] tabular-nums text-muted-foreground", children: [_jsx("span", { children: "0:00" }), _jsx("span", { children: isLive ? 'now' : formatDuration(durationMs) })] })] }));
36
+ }
37
+ function TransportBtn({ label, onClick, variant, children, }) {
38
+ return (_jsx("button", { type: "button", "aria-label": label, onClick: onClick, className: [
39
+ 'inline-flex size-6 items-center justify-center rounded',
40
+ variant === 'primary'
41
+ ? 'bg-primary text-primary-foreground'
42
+ : 'text-muted-foreground hover:bg-muted hover:text-foreground',
43
+ ].join(' '), children: children }));
44
+ }
@@ -0,0 +1,19 @@
1
+ import type { TimelineEvent } from './types.js';
2
+ export interface TimelineRailProps {
3
+ date: Date;
4
+ now: Date;
5
+ events: TimelineEvent[];
6
+ /** px per second. Default 1.2 (72 px/min). */
7
+ zoom?: number;
8
+ /** When true (default), tape auto-scrolls to keep LIVE pinned. */
9
+ followLive?: boolean;
10
+ tz?: string;
11
+ onZoomChange?: (px: number) => void;
12
+ onDateChange?: (next: Date) => void;
13
+ onSelect?: (event: TimelineEvent) => void;
14
+ onFollowChange?: (following: boolean) => void;
15
+ /** Click target on the "Manage retention" footer. */
16
+ onManageRetention?: () => void;
17
+ className?: string;
18
+ }
19
+ export declare function TimelineRail({ date, now, events, zoom, followLive, tz, onZoomChange, onDateChange, onSelect, onFollowChange, onManageRetention, className, }: TimelineRailProps): import("react/jsx-runtime").JSX.Element;
@@ -0,0 +1,64 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Archive } from 'lucide-react';
3
+ import { useEffect, useMemo, useRef, useState } from 'react';
4
+ import { DateSwitcher } from './date-switcher.js';
5
+ import { LiveIndicator } from './live-indicator.js';
6
+ import { TimelineTape } from './timeline-tape.js';
7
+ const FUTURE_PAD_SEC = 300;
8
+ const PAST_SPAN_SEC = 6 * 60 * 60;
9
+ const STICKY_TOP_PX = 12;
10
+ export function TimelineRail({ date, now, events, zoom = 1.2, followLive = true, tz, onZoomChange, onDateChange, onSelect, onFollowChange, onManageRetention, className, }) {
11
+ const scrollRef = useRef(null);
12
+ const [isFollowing, setIsFollowing] = useState(followLive);
13
+ // Mirror parent prop changes into local state so a controlled toggle of
14
+ // `followLive` from outside actually flips auto-follow.
15
+ useEffect(() => {
16
+ setIsFollowing(followLive);
17
+ }, [followLive]);
18
+ const liveCount = useMemo(() => events.filter((e) => e.isLive).length, [events]);
19
+ const totalCalls = useMemo(() => events.filter((e) => e.kind === 'session').length, [events]);
20
+ // NOW y inside the tape canvas
21
+ const nowY = FUTURE_PAD_SEC * zoom;
22
+ // Keep LIVE pinned by auto-scrolling when isFollowing
23
+ useEffect(() => {
24
+ const el = scrollRef.current;
25
+ if (!el)
26
+ return;
27
+ if (isFollowing) {
28
+ el.scrollTop = nowY - STICKY_TOP_PX;
29
+ }
30
+ }, [isFollowing, nowY]);
31
+ // Detect manual scroll-away → flip off auto-follow
32
+ useEffect(() => {
33
+ const el = scrollRef.current;
34
+ if (!el)
35
+ return;
36
+ const onScroll = () => {
37
+ if (!el)
38
+ return;
39
+ const expected = nowY - STICKY_TOP_PX;
40
+ const drift = Math.abs(el.scrollTop - expected);
41
+ if (drift > 32 && isFollowing) {
42
+ setIsFollowing(false);
43
+ onFollowChange?.(false);
44
+ }
45
+ };
46
+ el.addEventListener('scroll', onScroll, { passive: true });
47
+ return () => el.removeEventListener('scroll', onScroll);
48
+ }, [nowY, isFollowing, onFollowChange]);
49
+ const jumpToLive = () => {
50
+ setIsFollowing(true);
51
+ onFollowChange?.(true);
52
+ };
53
+ return (_jsxs("aside", { className: [
54
+ 'relative flex h-full flex-col border-r border-border bg-card text-sm',
55
+ className ?? '',
56
+ ].join(' '), "data-meda-component": "timeline-rail", children: [_jsx("div", { className: "px-3.5 pb-2.5 pt-3.5", children: _jsx(DateSwitcher, { value: date, now: now, tz: tz, onChange: (d) => onDateChange?.(d) }) }), _jsxs("div", { className: "flex items-center gap-2.5 px-3.5 pb-3", children: [_jsx(ZoomBtn, { label: "\u2212", onClick: () => onZoomChange?.(Math.max(0.2, zoom - 0.4)) }), _jsx(ZoomTrack, { value: zoom, min: 0.2, max: 6 }), _jsx(ZoomBtn, { label: "+", onClick: () => onZoomChange?.(Math.min(6, zoom + 0.4)) })] }), _jsxs("div", { className: "flex items-center gap-2 border-b border-border px-3.5 pb-2.5 text-[11px] tabular-nums text-muted-foreground", children: [liveCount > 0 && (_jsxs("span", { className: "inline-flex items-center gap-1.5 font-semibold text-emerald-500", children: [_jsx("span", { className: "size-1.5 animate-pulse rounded-full bg-emerald-500 shadow-[0_0_0_3px_rgba(16,185,129,0.18)]" }), liveCount, " live"] })), _jsxs("span", { className: "ml-auto", children: [_jsx("strong", { className: "font-semibold text-foreground", children: totalCalls }), " sessions"] })] }), _jsxs("div", { ref: scrollRef, className: "relative flex-1 overflow-y-auto overflow-x-hidden pb-16", children: [_jsx("div", { className: "sticky top-3 z-10 mx-3.5", children: _jsx(LiveIndicator, { now: now, tz: tz }) }), _jsx(TimelineTape, { now: now, events: events, pxPerSec: zoom, futurePadSec: FUTURE_PAD_SEC, pastSpanSec: PAST_SPAN_SEC, tz: tz, onSelect: onSelect })] }), !isFollowing && (_jsx("button", { type: "button", onClick: jumpToLive, className: "absolute right-3 top-32 z-20 inline-flex items-center gap-1.5 rounded-full bg-emerald-500 px-3 py-1 text-[11px] font-semibold text-white shadow-md hover:bg-emerald-600", children: "Jump to live" })), _jsxs("button", { type: "button", onClick: () => onManageRetention?.(), className: "absolute inset-x-0 bottom-0 z-20 flex items-center justify-center gap-1.5 bg-gradient-to-t from-card via-card/85 to-transparent px-3.5 py-2.5 text-[12px] text-primary hover:text-primary/80", children: [_jsx(Archive, { className: "size-3.5" }), " Manage retention"] })] }));
57
+ }
58
+ function ZoomBtn({ label, onClick }) {
59
+ return (_jsx("button", { type: "button", onClick: onClick, className: "inline-flex size-5 items-center justify-center rounded bg-muted text-sm leading-none text-muted-foreground hover:text-foreground", children: label }));
60
+ }
61
+ function ZoomTrack({ value, min, max }) {
62
+ const pct = Math.min(100, Math.max(0, ((value - min) / (max - min)) * 100));
63
+ return (_jsx("div", { tabIndex: 0, role: "slider", "aria-label": "Zoom", "aria-valuemin": min, "aria-valuemax": max, "aria-valuenow": value, className: "relative h-1 flex-1 rounded-sm bg-muted", children: _jsx("span", { className: "absolute -top-[3px] size-2.5 -translate-x-1/2 rounded-full bg-primary shadow-[0_0_0_4px_rgba(137,117,255,0.18)]", style: { left: `${pct}%` } }) }));
64
+ }
@@ -0,0 +1,19 @@
1
+ import type { TimelineEvent } from './types.js';
2
+ export interface TimelineTapeProps {
3
+ /** "Now" reference; canvas top represents `now + futurePadSec`. */
4
+ now: Date;
5
+ /** Event list. Order doesn't matter — placement uses startedAt. */
6
+ events: TimelineEvent[];
7
+ /** px per second of vertical scroll. Default 1.2 = 72 px/min. */
8
+ pxPerSec?: number;
9
+ /** Seconds of "future" rendered above NOW. Default 300 (5 min). */
10
+ futurePadSec?: number;
11
+ /** Total seconds visible past NOW (governs canvas height). Default 6h. */
12
+ pastSpanSec?: number;
13
+ /** Timezone for tick labels (caller's local tz by default). */
14
+ tz?: string;
15
+ /** Selection callback. */
16
+ onSelect?: (event: TimelineEvent) => void;
17
+ className?: string;
18
+ }
19
+ export declare function TimelineTape({ now, events, pxPerSec, futurePadSec, pastSpanSec, tz, onSelect, className, }: TimelineTapeProps): import("react/jsx-runtime").JSX.Element;
@@ -0,0 +1,66 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { formatClock } from '../lib/format-time.js';
3
+ import { EventCard } from './event-card.js';
4
+ const DEFAULT_PX_PER_SEC = 1.2;
5
+ const DEFAULT_FUTURE_PAD_SEC = 300;
6
+ const DEFAULT_PAST_SPAN_SEC = 6 * 60 * 60;
7
+ /** Convert a unix-ms instant to canvas y (px). y=0 at canvas top. */
8
+ function instantToY(instantMs, nowMs, pxPerSec, futurePadSec) {
9
+ const secsAfterNow = (instantMs - nowMs) / 1000;
10
+ // future is above now → smaller y. past is below now → larger y.
11
+ return (futurePadSec - secsAfterNow) * pxPerSec;
12
+ }
13
+ export function TimelineTape({ now, events, pxPerSec = DEFAULT_PX_PER_SEC, futurePadSec = DEFAULT_FUTURE_PAD_SEC, pastSpanSec = DEFAULT_PAST_SPAN_SEC, tz, onSelect, className, }) {
14
+ const nowMs = now.getTime();
15
+ const totalSec = futurePadSec + pastSpanSec;
16
+ const canvasHeight = totalSec * pxPerSec;
17
+ const nowY = instantToY(nowMs, nowMs, pxPerSec, futurePadSec);
18
+ // Compute tick positions at minute boundaries. Start from the future edge, go down.
19
+ const ticks = [];
20
+ const futureEdgeMs = nowMs + futurePadSec * 1000;
21
+ const pastEdgeMs = nowMs - pastSpanSec * 1000;
22
+ const firstTick = new Date(futureEdgeMs);
23
+ firstTick.setSeconds(0, 0);
24
+ for (let t = firstTick.getTime(); t >= pastEdgeMs; t -= 60_000) {
25
+ const y = instantToY(t, nowMs, pxPerSec, futurePadSec);
26
+ if (y < 0 || y > canvasHeight)
27
+ continue;
28
+ ticks.push({
29
+ y,
30
+ label: formatClock(new Date(t), { tz, withSeconds: false }),
31
+ major: true,
32
+ });
33
+ // 5 sub-ticks every 10s under each major
34
+ for (let sub = 1; sub <= 5; sub++) {
35
+ const subT = t - sub * 10_000;
36
+ if (subT < pastEdgeMs)
37
+ break;
38
+ const subY = instantToY(subT, nowMs, pxPerSec, futurePadSec);
39
+ ticks.push({ y: subY, label: '', major: false });
40
+ }
41
+ }
42
+ // Build tape segments + cards
43
+ const segments = events
44
+ .filter((e) => e.kind === 'session' || e.kind === 'error')
45
+ .map((e) => {
46
+ const startY = instantToY(e.startedAt, nowMs, pxPerSec, futurePadSec);
47
+ const endInstant = e.endedAt ?? (e.isLive ? nowMs : e.startedAt + 1000);
48
+ const endY = instantToY(endInstant, nowMs, pxPerSec, futurePadSec);
49
+ // "Top" of the segment is the more recent (smaller y).
50
+ const top = Math.min(startY, endY);
51
+ const height = Math.max(2, Math.abs(endY - startY));
52
+ return { event: e, top, height };
53
+ });
54
+ return (_jsxs("div", { className: ['relative ml-3.5 mr-3', className ?? ''].join(' '), style: { height: `${canvasHeight}px` }, "data-canvas-height": canvasHeight, children: [ticks.map((t) => t.major ? (_jsxs("div", { children: [_jsx("span", { className: "absolute left-0 w-[30px] -translate-y-[7px] text-right text-[11px] font-medium tabular-nums text-muted-foreground", style: { top: `${t.y}px` }, children: t.label }), _jsx("span", { className: "absolute h-px w-2 bg-border", style: { left: 32, top: `${t.y}px` } })] }, `maj-${t.y}`)) : (_jsx("span", { className: "absolute h-px w-1.5 bg-border/40", style: { left: 35, top: `${t.y}px` } }, `min-${t.y}`))), segments.map(({ event, top, height }) => (_jsx("span", { "aria-hidden": "true", "data-tape-segment-id": event.id, "data-tape-segment-live": String(event.isLive ?? false), className: [
55
+ 'absolute w-1 rounded-sm',
56
+ event.isLive
57
+ ? 'bg-emerald-500 shadow-[0_0_12px_rgba(16,185,129,0.5)]'
58
+ : event.kind === 'error'
59
+ ? 'bg-destructive'
60
+ : 'bg-sky-400 shadow-[0_0_8px_rgba(56,189,248,0.35)]',
61
+ ].join(' '), style: { left: 44, top: `${top}px`, height: `${height}px` } }, `seg-${event.id}`))), events.map((event) => {
62
+ const startY = instantToY(event.startedAt, nowMs, pxPerSec, futurePadSec);
63
+ const isSubEvent = event.kind === 'sub-event';
64
+ return (_jsx("div", { "data-event-id": event.id, className: "absolute", style: { top: `${startY}px`, left: 56, right: 0 }, children: _jsx(EventCard, { event: event, size: isSubEvent ? 'tiny' : 'default', onSelect: onSelect }) }, event.id));
65
+ }), _jsx("span", { "aria-hidden": "true", "data-now-y": nowY, className: "absolute", style: { top: `${nowY}px`, left: 0, width: 0, height: 0 } })] }));
66
+ }
@@ -0,0 +1,40 @@
1
+ import type { ReactNode } from 'react';
2
+ /** What kind of timeline entry this is. */
3
+ export type TimelineEventKind = 'session' | 'sub-event' | 'scheduled' | 'error';
4
+ /**
5
+ * Generic timeline entry. Apps map their domain (a phone call, a chat session, a workflow run)
6
+ * onto this shape and pass an array to <TimelineRail/>.
7
+ */
8
+ export interface TimelineEvent {
9
+ /** Stable identifier across re-renders. */
10
+ id: string;
11
+ /** Unix ms — when the event began. */
12
+ startedAt: number;
13
+ /** Unix ms — when the event ended. Omit for live or instantaneous events. */
14
+ endedAt?: number;
15
+ /** Coarse category — drives default styling. */
16
+ kind: TimelineEventKind;
17
+ /** Optional narrower tag for app-specific styling, e.g. "tool-call", "barge-in". */
18
+ variant?: string;
19
+ /** Card primary line content (typically a name). */
20
+ primary?: ReactNode;
21
+ /** Card secondary line content (typically meta — counts, cost). */
22
+ secondary?: ReactNode;
23
+ /** Highlight as currently selected. */
24
+ selected?: boolean;
25
+ /** Render with the live treatment (green pulse, etc). */
26
+ isLive?: boolean;
27
+ /**
28
+ * Optional override for the tape segment color expressed as a CSS color value.
29
+ * Defaults derived from kind + isLive + variant.
30
+ */
31
+ fillColor?: string;
32
+ }
33
+ /** Marks rendered along the per-conversation <ScrubBar/>. */
34
+ export interface ScrubMark {
35
+ id: string;
36
+ /** Position along the bar, 0..100. */
37
+ positionPct: number;
38
+ kind: 'turn' | 'tool' | 'barge' | 'error';
39
+ label?: string;
40
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,9 @@
1
+ export type { MicState, PcmFrame, TurnPhase } from './types.js';
2
+ export type { UseMicCaptureOptions, UseMicCaptureReturn } from './use-mic-capture.js';
3
+ export { useMicCapture } from './use-mic-capture.js';
4
+ export type { VoiceLevelProps } from './voice-level.js';
5
+ export { VoiceLevel } from './voice-level.js';
6
+ export type { VoiceOrbProps, VoiceOrbVariant } from './voice-orb.js';
7
+ export { VoiceOrb } from './voice-orb.js';
8
+ export type { VoiceStatusPillProps } from './voice-status-pill.js';
9
+ export { VoiceStatusPill } from './voice-status-pill.js';
@@ -0,0 +1,4 @@
1
+ export { useMicCapture } from './use-mic-capture.js';
2
+ export { VoiceLevel } from './voice-level.js';
3
+ export { VoiceOrb } from './voice-orb.js';
4
+ export { VoiceStatusPill } from './voice-status-pill.js';
@@ -0,0 +1 @@
1
+ export * from './index.js';
@@ -0,0 +1 @@
1
+ export * from './index.js';
@@ -0,0 +1,8 @@
1
+ export type MicState = 'idle' | 'requesting' | 'denied' | 'ready' | 'capturing';
2
+ export type TurnPhase = 'idle' | 'listening' | 'thinking' | 'speaking' | 'error';
3
+ export interface PcmFrame {
4
+ /** Int16 PCM at 16kHz mono, typically 20ms = 320 samples = 640 bytes. */
5
+ pcm: ArrayBuffer;
6
+ /** Voice activity proxy from RMS, 0..1. */
7
+ level: number;
8
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,14 @@
1
+ import type { MicState, PcmFrame } from './types.js';
2
+ export interface UseMicCaptureOptions {
3
+ autoStart?: boolean;
4
+ onFrame?: (frame: PcmFrame) => void;
5
+ audioConstraints?: MediaTrackConstraints;
6
+ }
7
+ export interface UseMicCaptureReturn {
8
+ state: MicState;
9
+ level: number;
10
+ error: Error | null;
11
+ start: () => Promise<void>;
12
+ stop: () => void;
13
+ }
14
+ export declare function useMicCapture(opts?: UseMicCaptureOptions): UseMicCaptureReturn;
@@ -0,0 +1,185 @@
1
+ import { useCallback, useEffect, useRef, useState } from 'react';
2
+ const WORKLET_CODE = `
3
+ const TARGET_RATE = 16000;
4
+ const FRAME_SAMPLES = 320;
5
+
6
+ class MicCapture extends AudioWorkletProcessor {
7
+ constructor() {
8
+ super();
9
+ this.inputRate = sampleRate;
10
+ this.ratio = this.inputRate / TARGET_RATE;
11
+ this.buffer = [];
12
+ // Persist the fractional resampling offset across process() callbacks so
13
+ // non-integer ratios (e.g. 44.1kHz→16kHz) don't accumulate ~1% drift.
14
+ this.resampleOffset = 0;
15
+ }
16
+ resampleAndAppend(input) {
17
+ let i = this.resampleOffset;
18
+ while (i < input.length) {
19
+ this.buffer.push(input[Math.floor(i)]);
20
+ i += this.ratio;
21
+ }
22
+ // Carry the fractional part forward into the next callback.
23
+ this.resampleOffset = i - input.length;
24
+ }
25
+ process(inputs) {
26
+ const ch = inputs[0]?.[0];
27
+ if (!ch) return true;
28
+ this.resampleAndAppend(ch);
29
+ while (this.buffer.length >= FRAME_SAMPLES) {
30
+ const frame = this.buffer.slice(0, FRAME_SAMPLES);
31
+ this.buffer = this.buffer.slice(FRAME_SAMPLES);
32
+ const pcm = new Int16Array(FRAME_SAMPLES);
33
+ let sumSq = 0;
34
+ for (let j = 0; j < FRAME_SAMPLES; j++) {
35
+ const s = Math.max(-1, Math.min(1, frame[j]));
36
+ pcm[j] = (s * 0x7fff) | 0;
37
+ sumSq += s * s;
38
+ }
39
+ const rms = Math.sqrt(sumSq / FRAME_SAMPLES);
40
+ this.port.postMessage({ pcm: pcm.buffer, level: rms }, [pcm.buffer]);
41
+ }
42
+ return true;
43
+ }
44
+ }
45
+ registerProcessor('meda-mic-capture', MicCapture);
46
+ `;
47
+ export function useMicCapture(opts = {}) {
48
+ const [state, setState] = useState('idle');
49
+ const [level, setLevel] = useState(0);
50
+ const [error, setError] = useState(null);
51
+ const ctxRef = useRef(null);
52
+ const streamRef = useRef(null);
53
+ const nodeRef = useRef(null);
54
+ const onFrameRef = useRef(opts.onFrame);
55
+ onFrameRef.current = opts.onFrame;
56
+ // Generation token: incremented on every start/stop so an in-flight start()
57
+ // can detect that a newer call has superseded it and bail out.
58
+ const genRef = useRef(0);
59
+ const stop = useCallback(() => {
60
+ genRef.current += 1;
61
+ nodeRef.current?.disconnect();
62
+ nodeRef.current = null;
63
+ streamRef.current?.getTracks().forEach((t) => {
64
+ t.stop();
65
+ });
66
+ streamRef.current = null;
67
+ void ctxRef.current?.close();
68
+ ctxRef.current = null;
69
+ setState('idle');
70
+ setLevel(0);
71
+ }, []);
72
+ const start = useCallback(async () => {
73
+ // Bug 5 fix: tear down any existing capture before starting a new one so
74
+ // we don't orphan the previous stream/context.
75
+ if (ctxRef.current ?? streamRef.current) {
76
+ stop();
77
+ }
78
+ // Grab the current generation so we can detect if stop() (or a second
79
+ // start()) is called while we are awaiting getUserMedia / addModule.
80
+ genRef.current += 1;
81
+ const gen = genRef.current;
82
+ setState('requesting');
83
+ setError(null);
84
+ // Helper for the stale-generation early-return paths (after addModule):
85
+ // releases resources acquired by this invocation without touching refs
86
+ // that may already belong to a superseding start().
87
+ const cleanupRefs = (stream, ctx) => {
88
+ stream?.getTracks().forEach((t) => {
89
+ t.stop();
90
+ });
91
+ void ctx?.close();
92
+ // Clear refs only if this generation still owns them (a superseding
93
+ // start() may have already set new refs).
94
+ if (genRef.current === gen) {
95
+ if (streamRef.current === stream)
96
+ streamRef.current = null;
97
+ if (ctxRef.current === ctx)
98
+ ctxRef.current = null;
99
+ nodeRef.current = null;
100
+ }
101
+ };
102
+ // Track resources acquired by THIS invocation so the catch block can
103
+ // release exactly what we opened — not whatever a newer start() may have
104
+ // placed in the shared refs.
105
+ let acquiredStream = null;
106
+ let acquiredCtx = null;
107
+ try {
108
+ acquiredStream = await navigator.mediaDevices.getUserMedia({
109
+ audio: opts.audioConstraints ?? {
110
+ echoCancellation: true,
111
+ noiseSuppression: true,
112
+ autoGainControl: true,
113
+ },
114
+ });
115
+ // If stop() fired while we were awaiting getUserMedia a newer generation
116
+ // is active — release the stream we just acquired and bail out.
117
+ if (genRef.current !== gen) {
118
+ acquiredStream.getTracks().forEach((t) => {
119
+ t.stop();
120
+ });
121
+ return;
122
+ }
123
+ streamRef.current = acquiredStream;
124
+ acquiredCtx = new AudioContext();
125
+ ctxRef.current = acquiredCtx;
126
+ const blob = new Blob([WORKLET_CODE], { type: 'application/javascript' });
127
+ const url = URL.createObjectURL(blob);
128
+ try {
129
+ await acquiredCtx.audioWorklet.addModule(url);
130
+ }
131
+ finally {
132
+ URL.revokeObjectURL(url);
133
+ }
134
+ // Check again after the async addModule call.
135
+ if (genRef.current !== gen) {
136
+ cleanupRefs(acquiredStream, acquiredCtx);
137
+ return;
138
+ }
139
+ const node = new AudioWorkletNode(acquiredCtx, 'meda-mic-capture');
140
+ nodeRef.current = node;
141
+ node.port.onmessage = (e) => {
142
+ const msg = e.data;
143
+ setLevel(msg.level);
144
+ onFrameRef.current?.({ pcm: msg.pcm, level: msg.level });
145
+ };
146
+ const src = acquiredCtx.createMediaStreamSource(acquiredStream);
147
+ src.connect(node);
148
+ // Web Audio graphs only run when connected to a destination. The
149
+ // worklet's process() callback is never invoked otherwise — no PCM
150
+ // frames, no level updates. Route through a 0-gain node so we don't
151
+ // produce audible feedback from the user's own mic.
152
+ const silent = acquiredCtx.createGain();
153
+ silent.gain.value = 0;
154
+ node.connect(silent);
155
+ silent.connect(acquiredCtx.destination);
156
+ setState('capturing');
157
+ }
158
+ catch (err) {
159
+ // Release only the resources THIS start() acquired. Reading from refs
160
+ // here would be unsafe — a superseding start() may have already stored
161
+ // its own resources there.
162
+ acquiredStream?.getTracks().forEach((t) => {
163
+ t.stop();
164
+ });
165
+ void acquiredCtx?.close();
166
+ if (genRef.current === gen) {
167
+ if (streamRef.current === acquiredStream)
168
+ streamRef.current = null;
169
+ if (ctxRef.current === acquiredCtx)
170
+ ctxRef.current = null;
171
+ nodeRef.current = null;
172
+ const e = err;
173
+ setError(e);
174
+ setState(e.name === 'NotAllowedError' ? 'denied' : 'idle');
175
+ }
176
+ }
177
+ }, [opts.audioConstraints, stop]);
178
+ // biome-ignore lint/correctness/useExhaustiveDependencies: intentionally empty - should only run on mount
179
+ useEffect(() => {
180
+ if (opts.autoStart)
181
+ void start();
182
+ return stop;
183
+ }, []);
184
+ return { state, level, error, start, stop };
185
+ }
@@ -0,0 +1,8 @@
1
+ export interface VoiceLevelProps {
2
+ level: number;
3
+ variant?: 'bars' | 'wave' | 'ring';
4
+ width?: number;
5
+ height?: number;
6
+ className?: string;
7
+ }
8
+ export declare function VoiceLevel({ level, variant, width, height, className, }: VoiceLevelProps): import("react/jsx-runtime").JSX.Element;
@@ -0,0 +1,29 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import * as React from 'react';
3
+ const BAR_COUNT = 9;
4
+ export function VoiceLevel({ level, variant = 'bars', width = 140, height = 48, className, }) {
5
+ const historyRef = React.useRef(Array(BAR_COUNT).fill(0));
6
+ const [, force] = React.useState(0);
7
+ // Only the 'bars' variant reads historyRef, so gate history updates and the
8
+ // resulting forced rerender to that variant — avoids a superfluous render on
9
+ // every level tick for 'ring' and 'wave'.
10
+ React.useEffect(() => {
11
+ if (variant !== 'bars')
12
+ return;
13
+ historyRef.current = [...historyRef.current.slice(1), Math.min(1, Math.max(0, level))];
14
+ force((v) => v + 1);
15
+ }, [level, variant]);
16
+ if (variant === 'bars') {
17
+ return (_jsx("div", { role: "presentation", className: ['flex items-end gap-1', className ?? ''].join(' '), style: { width, height }, children: historyRef.current.map((v, i) => (_jsx("span", { className: "flex-1 rounded-sm bg-primary", style: { height: `${15 + v * 85}%`, opacity: 0.4 + v * 0.6 } }, i))) }));
18
+ }
19
+ if (variant === 'ring') {
20
+ const r = Math.min(width, height) / 2 - 4;
21
+ const c = 2 * Math.PI * r;
22
+ const filled = c * Math.min(1, Math.max(0, level));
23
+ return (_jsxs("svg", { width: width, height: height, className: className, role: "presentation", children: [_jsx("circle", { cx: width / 2, cy: height / 2, r: r, stroke: "hsl(var(--border))", strokeWidth: "3", fill: "none" }), _jsx("circle", { cx: width / 2, cy: height / 2, r: r, stroke: "hsl(var(--primary))", strokeWidth: "3", fill: "none", strokeDasharray: `${filled} ${c}`, strokeLinecap: "round", transform: `rotate(-90 ${width / 2} ${height / 2})` })] }));
24
+ }
25
+ // 'wave' — simple bezier that scales with level
26
+ const peak = height / 2 - 2;
27
+ const amp = peak * Math.min(1, Math.max(0, level));
28
+ return (_jsx("svg", { width: width, height: height, className: className, role: "presentation", children: _jsx("path", { d: `M 0 ${height / 2} Q ${width / 4} ${height / 2 - amp}, ${width / 2} ${height / 2} T ${width} ${height / 2}`, stroke: "hsl(var(--primary))", strokeWidth: "2", fill: "none" }) }));
29
+ }
@@ -0,0 +1,12 @@
1
+ import type { TurnPhase } from './types.js';
2
+ export type VoiceOrbVariant = 'metal' | 'aurora';
3
+ export interface SceneProps {
4
+ level?: number;
5
+ outputLevel?: number;
6
+ phase?: TurnPhase;
7
+ colors: [string, string];
8
+ reducedMotion?: boolean;
9
+ pressed?: boolean;
10
+ variant?: VoiceOrbVariant;
11
+ }
12
+ export declare function Scene({ level, outputLevel, phase, colors, reducedMotion, pressed, variant, }: SceneProps): import("react/jsx-runtime").JSX.Element;