@medalsocial/meda 0.2.0 → 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.
- package/dist/chat/index.d.ts +11 -0
- package/dist/chat/index.js +5 -0
- package/dist/chat/latency-badge.d.ts +7 -0
- package/dist/chat/latency-badge.js +19 -0
- package/dist/chat/latency-breakdown.d.ts +8 -0
- package/dist/chat/latency-breakdown.js +9 -0
- package/dist/chat/public.d.ts +1 -0
- package/dist/chat/public.js +1 -0
- package/dist/chat/tool-call-block.d.ts +6 -0
- package/dist/chat/tool-call-block.js +19 -0
- package/dist/chat/transcript-stream.d.ts +10 -0
- package/dist/chat/transcript-stream.js +40 -0
- package/dist/chat/turn-card.d.ts +10 -0
- package/dist/chat/turn-card.js +23 -0
- package/dist/chat/types.d.ts +36 -0
- package/dist/chat/types.js +1 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +3 -0
- package/dist/lib/format-time.d.ts +9 -0
- package/dist/lib/format-time.js +34 -0
- package/dist/panel/index.d.ts +6 -0
- package/dist/panel/index.js +3 -0
- package/dist/panel/inspector-field.d.ts +9 -0
- package/dist/panel/inspector-field.js +4 -0
- package/dist/panel/inspector-json.d.ts +7 -0
- package/dist/panel/inspector-json.js +73 -0
- package/dist/panel/inspector.d.ts +7 -0
- package/dist/panel/inspector.js +12 -0
- package/dist/panel/public.d.ts +1 -0
- package/dist/panel/public.js +1 -0
- package/dist/panel/types.d.ts +6 -0
- package/dist/panel/types.js +1 -0
- package/dist/timeline/date-switcher.d.ts +11 -0
- package/dist/timeline/date-switcher.js +62 -0
- package/dist/timeline/event-card.d.ts +9 -0
- package/dist/timeline/event-card.js +26 -0
- package/dist/timeline/index.d.ts +13 -0
- package/dist/timeline/index.js +6 -0
- package/dist/timeline/live-indicator.d.ts +7 -0
- package/dist/timeline/live-indicator.js +5 -0
- package/dist/timeline/public.d.ts +1 -0
- package/dist/timeline/public.js +1 -0
- package/dist/timeline/scrub-bar.d.ts +14 -0
- package/dist/timeline/scrub-bar.js +44 -0
- package/dist/timeline/timeline-rail.d.ts +19 -0
- package/dist/timeline/timeline-rail.js +64 -0
- package/dist/timeline/timeline-tape.d.ts +19 -0
- package/dist/timeline/timeline-tape.js +66 -0
- package/dist/timeline/types.d.ts +40 -0
- package/dist/timeline/types.js +1 -0
- package/package.json +15 -2
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export type { LatencyBadgeProps, LatencyKind } from './latency-badge.js';
|
|
2
|
+
export { LatencyBadge } from './latency-badge.js';
|
|
3
|
+
export type { LatencyBreakdownProps } from './latency-breakdown.js';
|
|
4
|
+
export { LatencyBreakdown } from './latency-breakdown.js';
|
|
5
|
+
export type { ToolCallBlockProps } from './tool-call-block.js';
|
|
6
|
+
export { ToolCallBlock } from './tool-call-block.js';
|
|
7
|
+
export type { TranscriptStreamProps } from './transcript-stream.js';
|
|
8
|
+
export { TranscriptStream } from './transcript-stream.js';
|
|
9
|
+
export type { TurnCardProps } from './turn-card.js';
|
|
10
|
+
export { TurnCard } from './turn-card.js';
|
|
11
|
+
export type { Speaker, ToolCall, Turn, TurnLatency } from './types.js';
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export { LatencyBadge } from './latency-badge.js';
|
|
2
|
+
export { LatencyBreakdown } from './latency-breakdown.js';
|
|
3
|
+
export { ToolCallBlock } from './tool-call-block.js';
|
|
4
|
+
export { TranscriptStream } from './transcript-stream.js';
|
|
5
|
+
export { TurnCard } from './turn-card.js';
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export type LatencyKind = 'stt' | 'claude' | 'tts';
|
|
2
|
+
export interface LatencyBadgeProps {
|
|
3
|
+
kind: LatencyKind;
|
|
4
|
+
ms: number;
|
|
5
|
+
className?: string;
|
|
6
|
+
}
|
|
7
|
+
export declare function LatencyBadge({ kind, ms, className }: LatencyBadgeProps): import("react/jsx-runtime").JSX.Element;
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
const KIND = {
|
|
3
|
+
stt: { label: 'STT', tone: 'text-sky-400' },
|
|
4
|
+
claude: { label: 'Claude', tone: 'text-primary' },
|
|
5
|
+
tts: { label: 'TTS', tone: 'text-emerald-500' },
|
|
6
|
+
};
|
|
7
|
+
function fmt(ms) {
|
|
8
|
+
if (ms >= 1000)
|
|
9
|
+
return `${(ms / 1000).toFixed(1)}s`;
|
|
10
|
+
return `${Math.round(ms)}ms`;
|
|
11
|
+
}
|
|
12
|
+
export function LatencyBadge({ kind, ms, className }) {
|
|
13
|
+
const meta = KIND[kind];
|
|
14
|
+
return (_jsxs("span", { "data-kind": kind, className: [
|
|
15
|
+
'inline-flex items-center gap-1.5 text-[11px] tabular-nums',
|
|
16
|
+
meta.tone,
|
|
17
|
+
className ?? '',
|
|
18
|
+
].join(' '), children: [_jsx("span", { "aria-hidden": "true", className: "size-1 rounded-full bg-current" }), meta.label, " ", fmt(ms)] }));
|
|
19
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export interface LatencyBreakdownProps {
|
|
2
|
+
sttMs: number;
|
|
3
|
+
claudeMs: number;
|
|
4
|
+
ttsMs: number;
|
|
5
|
+
showLegend?: boolean;
|
|
6
|
+
className?: string;
|
|
7
|
+
}
|
|
8
|
+
export declare function LatencyBreakdown({ sttMs, claudeMs, ttsMs, showLegend, className, }: LatencyBreakdownProps): import("react/jsx-runtime").JSX.Element;
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { LatencyBadge } from './latency-badge.js';
|
|
3
|
+
export function LatencyBreakdown({ sttMs, claudeMs, ttsMs, showLegend = true, className, }) {
|
|
4
|
+
const total = Math.max(1, sttMs + claudeMs + ttsMs);
|
|
5
|
+
const sttPct = (sttMs / total) * 100;
|
|
6
|
+
const claudePct = (claudeMs / total) * 100;
|
|
7
|
+
const ttsPct = (ttsMs / total) * 100;
|
|
8
|
+
return (_jsxs("div", { className: className, children: [_jsxs("div", { className: "flex h-1.5 gap-0.5 overflow-hidden rounded-sm bg-muted", children: [_jsx("span", { "data-segment": "stt", className: "block bg-sky-400", style: { width: `${sttPct}%` } }), _jsx("span", { "data-segment": "claude", className: "block bg-primary", style: { width: `${claudePct}%` } }), _jsx("span", { "data-segment": "tts", className: "block bg-emerald-500", style: { width: `${ttsPct}%` } })] }), showLegend && (_jsxs("div", { className: "mt-1.5 flex flex-wrap gap-3 text-[11px] tabular-nums text-muted-foreground", children: [_jsx(LatencyBadge, { kind: "stt", ms: sttMs }), _jsx(LatencyBadge, { kind: "claude", ms: claudeMs }), _jsx(LatencyBadge, { kind: "tts", ms: ttsMs })] }))] }));
|
|
9
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export type { Speaker, ToolCall, Turn, TurnLatency } from './types.js';
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { Zap } from 'lucide-react';
|
|
3
|
+
function safeStringify(v) {
|
|
4
|
+
// Tolerate values JSON.stringify can't handle (BigInt, circular refs, etc.)
|
|
5
|
+
// so a malformed tool arg never crashes the transcript view.
|
|
6
|
+
try {
|
|
7
|
+
const out = JSON.stringify(v);
|
|
8
|
+
return out === undefined ? String(v) : out;
|
|
9
|
+
}
|
|
10
|
+
catch {
|
|
11
|
+
return String(v);
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
export function ToolCallBlock({ call, className }) {
|
|
15
|
+
return (_jsxs("div", { className: [
|
|
16
|
+
'mt-3 rounded-md border border-border border-l-2 border-l-amber-500 bg-card p-3 font-mono text-xs',
|
|
17
|
+
className ?? '',
|
|
18
|
+
].join(' '), children: [_jsxs("div", { className: "mb-1.5 flex items-center gap-2 text-[11px]", children: [_jsx(Zap, { className: "size-3 text-amber-500" }), _jsx("strong", { className: "font-semibold text-foreground", children: call.name }), call.latencyMs != null && (_jsxs("span", { className: "ml-auto tabular-nums text-muted-foreground", children: [call.latencyMs, "ms"] }))] }), _jsxs("div", { className: "text-muted-foreground", children: [Object.entries(call.args && typeof call.args === 'object' ? call.args : {}).map(([k, v], i) => (_jsxs("span", { children: [i > 0 && ', ', _jsx("span", { className: "text-primary", children: k }), ": ", '', _jsx("span", { className: "text-emerald-500", children: safeStringify(v) })] }, k))), call.resultSummary && (_jsxs("span", { className: "text-muted-foreground", children: [" \u2192 ", call.resultSummary] }))] })] }));
|
|
19
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { Turn } from './types.js';
|
|
2
|
+
export interface TranscriptStreamProps {
|
|
3
|
+
turns: Turn[];
|
|
4
|
+
/** When true (default), auto-scroll to bottom on new turn IF user is already near bottom. */
|
|
5
|
+
autoScroll?: boolean;
|
|
6
|
+
tz?: string;
|
|
7
|
+
onTurnPlay?: (turnId: string) => void;
|
|
8
|
+
className?: string;
|
|
9
|
+
}
|
|
10
|
+
export declare function TranscriptStream({ turns, autoScroll, tz, onTurnPlay, className, }: TranscriptStreamProps): import("react/jsx-runtime").JSX.Element;
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { useEffect, useRef } from 'react';
|
|
3
|
+
import { TurnCard } from './turn-card.js';
|
|
4
|
+
const SCROLL_THRESHOLD = 120;
|
|
5
|
+
export function TranscriptStream({ turns, autoScroll = true, tz, onTurnPlay, className, }) {
|
|
6
|
+
const ref = useRef(null);
|
|
7
|
+
// Snapshot of "was the user near the bottom?" sampled BEFORE the new turn
|
|
8
|
+
// renders, so that a tall appended turn doesn't push the bottom off-screen
|
|
9
|
+
// and falsely look like the user scrolled away.
|
|
10
|
+
const wasAtBottomRef = useRef(true);
|
|
11
|
+
useEffect(() => {
|
|
12
|
+
const el = ref.current;
|
|
13
|
+
if (!el)
|
|
14
|
+
return;
|
|
15
|
+
const onScroll = () => {
|
|
16
|
+
const distanceFromBottom = el.scrollHeight - el.scrollTop - el.clientHeight;
|
|
17
|
+
wasAtBottomRef.current = distanceFromBottom < SCROLL_THRESHOLD;
|
|
18
|
+
};
|
|
19
|
+
el.addEventListener('scroll', onScroll, { passive: true });
|
|
20
|
+
return () => el.removeEventListener('scroll', onScroll);
|
|
21
|
+
}, []);
|
|
22
|
+
// Auto-follow last turn when the user was near the bottom before the new turn appended.
|
|
23
|
+
// biome-ignore lint/correctness/useExhaustiveDependencies: turns changes trigger scroll check
|
|
24
|
+
useEffect(() => {
|
|
25
|
+
const el = ref.current;
|
|
26
|
+
if (!el || !autoScroll)
|
|
27
|
+
return;
|
|
28
|
+
if (wasAtBottomRef.current) {
|
|
29
|
+
el.scrollTop = el.scrollHeight;
|
|
30
|
+
}
|
|
31
|
+
}, [turns, autoScroll]);
|
|
32
|
+
if (turns.length === 0) {
|
|
33
|
+
return (_jsx("div", { ref: ref, className: [
|
|
34
|
+
'flex flex-1 items-center justify-center text-sm text-muted-foreground',
|
|
35
|
+
className ?? '',
|
|
36
|
+
].join(' '), children: "No turns yet." }));
|
|
37
|
+
}
|
|
38
|
+
const ref0 = turns[0]?.startedAt ?? 0;
|
|
39
|
+
return (_jsx("div", { ref: ref, className: ['flex-1 overflow-y-auto px-7 pb-6 pt-2', className ?? ''].join(' '), children: turns.map((t) => (_jsx(TurnCard, { turn: t, startedAtRef: ref0, tz: tz, onPlay: onTurnPlay }, t.id))) }));
|
|
40
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { Turn } from './types.js';
|
|
2
|
+
export interface TurnCardProps {
|
|
3
|
+
turn: Turn;
|
|
4
|
+
/** Reference instant for the relative offset column (typically the conversation start). */
|
|
5
|
+
startedAtRef: number;
|
|
6
|
+
tz?: string;
|
|
7
|
+
onPlay?: (turnId: string) => void;
|
|
8
|
+
className?: string;
|
|
9
|
+
}
|
|
10
|
+
export declare function TurnCard({ turn, startedAtRef, tz, onPlay, className }: TurnCardProps): import("react/jsx-runtime").JSX.Element;
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { Play } from 'lucide-react';
|
|
3
|
+
import { formatClock, formatRelativeOffset } from '../lib/format-time.js';
|
|
4
|
+
import { LatencyBadge } from './latency-badge.js';
|
|
5
|
+
import { ToolCallBlock } from './tool-call-block.js';
|
|
6
|
+
const SPEAKER_LABEL = {
|
|
7
|
+
user: 'You',
|
|
8
|
+
assistant: 'Assistant',
|
|
9
|
+
system: 'System',
|
|
10
|
+
};
|
|
11
|
+
export function TurnCard({ turn, startedAtRef, tz, onPlay, className }) {
|
|
12
|
+
const label = turn.speakerLabel ?? SPEAKER_LABEL[turn.speaker];
|
|
13
|
+
const offset = turn.startedAt - startedAtRef;
|
|
14
|
+
const speakerColor = turn.speaker === 'assistant'
|
|
15
|
+
? 'text-primary'
|
|
16
|
+
: turn.speaker === 'system'
|
|
17
|
+
? 'text-amber-500'
|
|
18
|
+
: 'text-foreground';
|
|
19
|
+
return (_jsxs("div", { className: [
|
|
20
|
+
'grid grid-cols-[64px_1fr] gap-5 border-b border-border py-4 last:border-b-0',
|
|
21
|
+
className ?? '',
|
|
22
|
+
].join(' '), children: [_jsxs("div", { className: "pt-1 text-[11px] tabular-nums text-muted-foreground", children: [_jsx("strong", { className: "mb-0.5 block text-[12px] font-medium text-muted-foreground", children: formatClock(new Date(turn.startedAt), { tz }) }), formatRelativeOffset(offset)] }), _jsxs("div", { children: [_jsxs("div", { className: "mb-1.5 flex items-center gap-2", children: [_jsx("span", { className: ['text-[11px] font-semibold uppercase tracking-wider', speakerColor].join(' '), children: label }), turn.modelLabel && (_jsx("span", { className: "rounded bg-muted px-1.5 py-px text-[10px] font-medium text-muted-foreground", children: turn.modelLabel })), turn.spokenSeconds != null && turn.speaker === 'user' && (_jsxs("span", { className: "rounded bg-muted px-1.5 py-px text-[10px] font-medium text-muted-foreground", children: ["spoken \u00B7 ", turn.spokenSeconds.toFixed(1), "s"] }))] }), _jsxs("p", { className: "text-[15px] leading-relaxed text-foreground", "data-streaming": String(turn.streaming ?? false), children: [turn.text, turn.streaming && (_jsx("span", { "aria-hidden": "true", className: "ml-0.5 animate-pulse text-primary", children: "\u258D" }))] }), turn.toolCalls?.map((c) => (_jsx(ToolCallBlock, { call: c }, c.id))), (turn.audioUrl || turn.latency) && (_jsxs("div", { className: "mt-2.5 flex flex-wrap items-center gap-4 text-[11px] text-muted-foreground", children: [turn.audioUrl && (_jsx("button", { type: "button", "aria-label": "Play this turn", onClick: () => onPlay?.(turn.id), className: "inline-flex size-6 items-center justify-center rounded-full bg-muted text-muted-foreground hover:text-foreground", children: _jsx(Play, { className: "size-3" }) })), turn.latency?.sttMs != null && _jsx(LatencyBadge, { kind: "stt", ms: turn.latency.sttMs }), turn.latency?.claudeMs != null && (_jsx(LatencyBadge, { kind: "claude", ms: turn.latency.claudeMs })), turn.latency?.ttsMs != null && _jsx(LatencyBadge, { kind: "tts", ms: turn.latency.ttsMs })] }))] })] }));
|
|
23
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
export type Speaker = 'user' | 'assistant' | 'system';
|
|
2
|
+
/** Per-stage latency breakdown for one turn. */
|
|
3
|
+
export interface TurnLatency {
|
|
4
|
+
sttMs?: number;
|
|
5
|
+
claudeMs?: number;
|
|
6
|
+
ttsMs?: number;
|
|
7
|
+
}
|
|
8
|
+
/** A single AI tool / function-call invocation. */
|
|
9
|
+
export interface ToolCall {
|
|
10
|
+
id: string;
|
|
11
|
+
name: string;
|
|
12
|
+
args: Record<string, unknown>;
|
|
13
|
+
/** Short human-readable summary, e.g. "3 events". */
|
|
14
|
+
resultSummary?: string;
|
|
15
|
+
latencyMs?: number;
|
|
16
|
+
}
|
|
17
|
+
/** One conversational exchange. */
|
|
18
|
+
export interface Turn {
|
|
19
|
+
id: string;
|
|
20
|
+
speaker: Speaker;
|
|
21
|
+
/** Display label, e.g. "Morpheus" or "Operator (Ali)". Optional override of speaker. */
|
|
22
|
+
speakerLabel?: string;
|
|
23
|
+
/** Optional model identifier, e.g. "claude-opus-4-7". */
|
|
24
|
+
modelLabel?: string;
|
|
25
|
+
/** URL or blob: URL for per-turn replay. */
|
|
26
|
+
audioUrl?: string;
|
|
27
|
+
/** Spoken duration if known. */
|
|
28
|
+
spokenSeconds?: number;
|
|
29
|
+
text: string;
|
|
30
|
+
/** Unix ms. */
|
|
31
|
+
startedAt: number;
|
|
32
|
+
/** True if the assistant is still streaming this turn. */
|
|
33
|
+
streaming?: boolean;
|
|
34
|
+
latency?: TurnLatency;
|
|
35
|
+
toolCalls?: ToolCall[];
|
|
36
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/dist/index.d.ts
CHANGED
package/dist/index.js
CHANGED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export interface FormatClockOptions {
|
|
2
|
+
/** IANA timezone, e.g. "Europe/Stockholm". Defaults to local tz. */
|
|
3
|
+
tz?: string;
|
|
4
|
+
/** Include seconds (default true). */
|
|
5
|
+
withSeconds?: boolean;
|
|
6
|
+
}
|
|
7
|
+
export declare function formatClock(date: Date, opts?: FormatClockOptions): string;
|
|
8
|
+
export declare function formatDuration(ms: number): string;
|
|
9
|
+
export declare function formatRelativeOffset(ms: number): string;
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
export function formatClock(date, opts = {}) {
|
|
2
|
+
const { tz, withSeconds = true } = opts;
|
|
3
|
+
const fmt = new Intl.DateTimeFormat('en-GB', {
|
|
4
|
+
hour: '2-digit',
|
|
5
|
+
minute: '2-digit',
|
|
6
|
+
second: withSeconds ? '2-digit' : undefined,
|
|
7
|
+
hour12: false,
|
|
8
|
+
timeZone: tz,
|
|
9
|
+
});
|
|
10
|
+
return fmt.format(date);
|
|
11
|
+
}
|
|
12
|
+
export function formatDuration(ms) {
|
|
13
|
+
if (ms < 0)
|
|
14
|
+
ms = 0;
|
|
15
|
+
const totalSec = Math.floor(ms / 1000);
|
|
16
|
+
const h = Math.floor(totalSec / 3600);
|
|
17
|
+
const m = Math.floor((totalSec % 3600) / 60);
|
|
18
|
+
const s = totalSec % 60;
|
|
19
|
+
if (h > 0)
|
|
20
|
+
return `${h}h ${m}m`;
|
|
21
|
+
if (m > 0)
|
|
22
|
+
return `${m}m ${s}s`;
|
|
23
|
+
return `${s}s`;
|
|
24
|
+
}
|
|
25
|
+
export function formatRelativeOffset(ms) {
|
|
26
|
+
const sign = ms < 0 ? '-' : '+';
|
|
27
|
+
const abs = Math.abs(ms);
|
|
28
|
+
const totalSec = Math.floor(abs / 1000);
|
|
29
|
+
const m = Math.floor(totalSec / 60);
|
|
30
|
+
const s = totalSec % 60;
|
|
31
|
+
if (m === 0)
|
|
32
|
+
return `${sign}${s}s`;
|
|
33
|
+
return `${sign}${m}m ${s}s`;
|
|
34
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export type { InspectorProps } from './inspector.js';
|
|
2
|
+
export { Inspector } from './inspector.js';
|
|
3
|
+
export type { InspectorFieldProps } from './inspector-field.js';
|
|
4
|
+
export { InspectorField } from './inspector-field.js';
|
|
5
|
+
export type { InspectorJSONProps } from './inspector-json.js';
|
|
6
|
+
export { InspectorJSON } from './inspector-json.js';
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { ReactNode } from 'react';
|
|
2
|
+
export interface InspectorFieldProps {
|
|
3
|
+
label: string;
|
|
4
|
+
value: ReactNode;
|
|
5
|
+
/** Optional smaller hint after the value, e.g. provider info. */
|
|
6
|
+
hint?: ReactNode;
|
|
7
|
+
className?: string;
|
|
8
|
+
}
|
|
9
|
+
export declare function InspectorField({ label, value, hint, className }: InspectorFieldProps): import("react/jsx-runtime").JSX.Element;
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
export function InspectorField({ label, value, hint, className }) {
|
|
3
|
+
return (_jsxs("div", { className: ['border-b border-border py-3 last:border-b-0', className ?? ''].join(' '), children: [_jsx("div", { className: "mb-1.5 text-[10px] font-semibold uppercase tracking-wider text-muted-foreground", children: label }), _jsxs("div", { className: "text-[13px] tabular-nums text-foreground", children: [value, hint != null && _jsxs("span", { className: "text-[12px] text-muted-foreground", children: [" ", hint] })] })] }));
|
|
4
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
function tokenize(value, level, key, indent, seen) {
|
|
3
|
+
const out = [];
|
|
4
|
+
const pad = ' '.repeat(level * indent);
|
|
5
|
+
if (key !== null) {
|
|
6
|
+
out.push({ kind: 'punct', text: pad });
|
|
7
|
+
out.push({ kind: 'key', text: `"${key}"` });
|
|
8
|
+
out.push({ kind: 'punct', text: ': ' });
|
|
9
|
+
}
|
|
10
|
+
else {
|
|
11
|
+
out.push({ kind: 'punct', text: pad });
|
|
12
|
+
}
|
|
13
|
+
if (value === null) {
|
|
14
|
+
out.push({ kind: 'literal', text: 'null' });
|
|
15
|
+
}
|
|
16
|
+
else if (typeof value === 'string') {
|
|
17
|
+
out.push({ kind: 'string', text: JSON.stringify(value) });
|
|
18
|
+
}
|
|
19
|
+
else if (typeof value === 'number') {
|
|
20
|
+
out.push({ kind: 'number', text: String(value) });
|
|
21
|
+
}
|
|
22
|
+
else if (typeof value === 'boolean') {
|
|
23
|
+
out.push({ kind: 'literal', text: String(value) });
|
|
24
|
+
}
|
|
25
|
+
else if (Array.isArray(value)) {
|
|
26
|
+
if (seen.has(value)) {
|
|
27
|
+
out.push({ kind: 'literal', text: '"[Circular]"' });
|
|
28
|
+
}
|
|
29
|
+
else {
|
|
30
|
+
seen.add(value);
|
|
31
|
+
out.push({ kind: 'punct', text: '[\n' });
|
|
32
|
+
value.forEach((v, i) => {
|
|
33
|
+
out.push(...tokenize(v, level + 1, null, indent, seen));
|
|
34
|
+
out.push({ kind: 'punct', text: i < value.length - 1 ? ',\n' : '\n' });
|
|
35
|
+
});
|
|
36
|
+
out.push({ kind: 'punct', text: `${pad}]` });
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
else if (typeof value === 'object') {
|
|
40
|
+
const obj = value;
|
|
41
|
+
if (seen.has(obj)) {
|
|
42
|
+
out.push({ kind: 'literal', text: '"[Circular]"' });
|
|
43
|
+
}
|
|
44
|
+
else {
|
|
45
|
+
seen.add(obj);
|
|
46
|
+
out.push({ kind: 'punct', text: '{\n' });
|
|
47
|
+
const entries = Object.entries(obj);
|
|
48
|
+
entries.forEach(([k, v], i) => {
|
|
49
|
+
out.push(...tokenize(v, level + 1, k, indent, seen));
|
|
50
|
+
out.push({ kind: 'punct', text: i < entries.length - 1 ? ',\n' : '\n' });
|
|
51
|
+
});
|
|
52
|
+
out.push({ kind: 'punct', text: `${pad}}` });
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
else {
|
|
56
|
+
out.push({ kind: 'literal', text: String(value) });
|
|
57
|
+
}
|
|
58
|
+
return out;
|
|
59
|
+
}
|
|
60
|
+
const KIND_CLASS = {
|
|
61
|
+
punct: 'text-muted-foreground',
|
|
62
|
+
key: 'text-primary',
|
|
63
|
+
string: 'text-emerald-500',
|
|
64
|
+
number: 'text-amber-500',
|
|
65
|
+
literal: 'text-amber-500',
|
|
66
|
+
};
|
|
67
|
+
export function InspectorJSON({ data, indent = 2, className }) {
|
|
68
|
+
const tokens = tokenize(data, 0, null, indent, new WeakSet());
|
|
69
|
+
return (_jsx("pre", { className: [
|
|
70
|
+
'mt-1.5 overflow-x-auto rounded-md border border-border bg-background p-2.5 font-mono text-[11px] leading-relaxed text-foreground',
|
|
71
|
+
className ?? '',
|
|
72
|
+
].join(' '), children: tokens.map((t, i) => (_jsx("span", { className: KIND_CLASS[t.kind], children: t.text }, i))) }));
|
|
73
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { InspectorTab } from './types.js';
|
|
2
|
+
export interface InspectorProps {
|
|
3
|
+
tabs: InspectorTab[];
|
|
4
|
+
defaultTab?: string;
|
|
5
|
+
className?: string;
|
|
6
|
+
}
|
|
7
|
+
export declare function Inspector({ tabs, defaultTab, className }: InspectorProps): import("react/jsx-runtime").JSX.Element;
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useState } from 'react';
|
|
3
|
+
export function Inspector({ tabs, defaultTab, className }) {
|
|
4
|
+
const [active, setActive] = useState(defaultTab ?? tabs[0]?.id ?? '');
|
|
5
|
+
const current = tabs.find((t) => t.id === active) ?? tabs[0];
|
|
6
|
+
return (_jsxs("aside", { className: ['flex h-full flex-col border-l border-border bg-card', className ?? ''].join(' '), children: [_jsx("div", { role: "tablist", className: "flex gap-0.5 border-b border-border px-3.5", children: tabs.map((t) => (_jsx("button", { type: "button", role: "tab", "aria-selected": t.id === active, onClick: () => setActive(t.id), className: [
|
|
7
|
+
'border-b-2 px-2.5 py-3 text-xs',
|
|
8
|
+
t.id === active
|
|
9
|
+
? 'border-primary text-foreground'
|
|
10
|
+
: 'border-transparent text-muted-foreground hover:text-foreground',
|
|
11
|
+
].join(' '), children: t.label }, t.id))) }), _jsx("div", { className: "flex-1 overflow-y-auto px-4 pb-8 pt-4", children: current?.content })] }));
|
|
12
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export type { InspectorTab } from './types.js';
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export interface DateSwitcherProps {
|
|
2
|
+
/** Currently selected date. */
|
|
3
|
+
value: Date;
|
|
4
|
+
/** Reference for "today" comparison and forward-disable. */
|
|
5
|
+
now: Date;
|
|
6
|
+
/** IANA timezone for "same day" / display semantics. Defaults to local. */
|
|
7
|
+
tz?: string;
|
|
8
|
+
onChange: (next: Date) => void;
|
|
9
|
+
className?: string;
|
|
10
|
+
}
|
|
11
|
+
export declare function DateSwitcher({ value, now, tz, onChange, className }: DateSwitcherProps): import("react/jsx-runtime").JSX.Element;
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { Calendar, ChevronLeft, ChevronRight } from 'lucide-react';
|
|
3
|
+
// "YYYY-MM-DD" in the given tz (or local if undefined). Comparing these
|
|
4
|
+
// strings is a robust same-day check that doesn't depend on local-time math.
|
|
5
|
+
function ymdInTz(d, tz) {
|
|
6
|
+
return new Intl.DateTimeFormat('en-CA', {
|
|
7
|
+
year: 'numeric',
|
|
8
|
+
month: '2-digit',
|
|
9
|
+
day: '2-digit',
|
|
10
|
+
timeZone: tz,
|
|
11
|
+
}).format(d);
|
|
12
|
+
}
|
|
13
|
+
function isSameDay(a, b, tz) {
|
|
14
|
+
return ymdInTz(a, tz) === ymdInTz(b, tz);
|
|
15
|
+
}
|
|
16
|
+
// Returns the tz offset (in minutes) for `date` in `tz`. Positive = east of
|
|
17
|
+
// UTC, e.g. Europe/Stockholm CET → +60. Tolerates "GMT", "GMT+05", and
|
|
18
|
+
// "GMT+05:30" formats from Intl.DateTimeFormat.
|
|
19
|
+
function tzOffsetMinutes(date, tz) {
|
|
20
|
+
const parts = new Intl.DateTimeFormat('en-US', {
|
|
21
|
+
timeZone: tz,
|
|
22
|
+
timeZoneName: 'longOffset',
|
|
23
|
+
}).formatToParts(date);
|
|
24
|
+
const name = parts.find((p) => p.type === 'timeZoneName')?.value ?? 'GMT';
|
|
25
|
+
const m = /GMT(?:([+-])(\d{1,2})(?::?(\d{2}))?)?/.exec(name);
|
|
26
|
+
if (!m?.[1])
|
|
27
|
+
return 0;
|
|
28
|
+
const sign = m[1] === '+' ? 1 : -1;
|
|
29
|
+
const hours = Number.parseInt(m[2] ?? '0', 10);
|
|
30
|
+
const mins = Number.parseInt(m[3] ?? '0', 10);
|
|
31
|
+
return sign * (hours * 60 + mins);
|
|
32
|
+
}
|
|
33
|
+
function addDays(d, delta, tz) {
|
|
34
|
+
if (!tz) {
|
|
35
|
+
// No tz: step the calendar day in the browser's local timezone (DST-safe
|
|
36
|
+
// there because setDate honors local DST transitions).
|
|
37
|
+
const c = new Date(d);
|
|
38
|
+
c.setDate(c.getDate() + delta);
|
|
39
|
+
return c;
|
|
40
|
+
}
|
|
41
|
+
// tz-aware: advance 24h, then correct for any DST shift between the
|
|
42
|
+
// original day and the target day so the *wall clock* in `tz` lands on the
|
|
43
|
+
// same hour:minute on the next/previous calendar day.
|
|
44
|
+
const offsetBefore = tzOffsetMinutes(d, tz);
|
|
45
|
+
const candidate = new Date(d.getTime() + delta * 86_400_000);
|
|
46
|
+
const offsetAfter = tzOffsetMinutes(candidate, tz);
|
|
47
|
+
return new Date(candidate.getTime() + (offsetBefore - offsetAfter) * 60_000);
|
|
48
|
+
}
|
|
49
|
+
function fmtDate(d, tz) {
|
|
50
|
+
return new Intl.DateTimeFormat('en-US', {
|
|
51
|
+
day: 'numeric',
|
|
52
|
+
month: 'short',
|
|
53
|
+
year: 'numeric',
|
|
54
|
+
timeZone: tz,
|
|
55
|
+
}).format(d);
|
|
56
|
+
}
|
|
57
|
+
export function DateSwitcher({ value, now, tz, onChange, className }) {
|
|
58
|
+
const isToday = isSameDay(value, now, tz);
|
|
59
|
+
const goPrev = () => onChange(addDays(value, -1, tz));
|
|
60
|
+
const goNext = () => onChange(addDays(value, 1, tz));
|
|
61
|
+
return (_jsxs("div", { className: ['flex items-center gap-2', className ?? ''].join(' '), children: [_jsx("button", { type: "button", "aria-label": "Previous day", onClick: goPrev, className: "inline-flex size-7 items-center justify-center rounded-md text-muted-foreground hover:bg-muted hover:text-foreground", children: _jsx(ChevronLeft, { className: "size-4" }) }), _jsxs("div", { className: "flex flex-1 items-center justify-center gap-1.5 rounded-md border border-border bg-card px-3 py-1.5 text-sm", children: [_jsx(Calendar, { className: "size-3.5 text-muted-foreground" }), _jsx("span", { className: "font-medium", children: fmtDate(value, tz) }), isToday && (_jsx("span", { className: "ml-1 rounded-full bg-emerald-500/15 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wider text-emerald-500", children: "today" }))] }), _jsx("button", { type: "button", "aria-label": "Next day", disabled: isToday, onClick: goNext, className: "inline-flex size-7 items-center justify-center rounded-md text-muted-foreground hover:bg-muted hover:text-foreground disabled:cursor-not-allowed disabled:opacity-30 disabled:hover:bg-transparent", children: _jsx(ChevronRight, { className: "size-4" }) })] }));
|
|
62
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { TimelineEvent } from './types.js';
|
|
2
|
+
export type EventCardSize = 'default' | 'tiny';
|
|
3
|
+
export interface EventCardProps {
|
|
4
|
+
event: TimelineEvent;
|
|
5
|
+
size?: EventCardSize;
|
|
6
|
+
onSelect?: (event: TimelineEvent) => void;
|
|
7
|
+
className?: string;
|
|
8
|
+
}
|
|
9
|
+
export declare function EventCard({ event, size, onSelect, className }: EventCardProps): import("react/jsx-runtime").JSX.Element;
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
export function EventCard({ event, size = 'default', onSelect, className }) {
|
|
3
|
+
const isLive = event.isLive ?? false;
|
|
4
|
+
const isSelected = event.selected ?? false;
|
|
5
|
+
const isError = event.kind === 'error';
|
|
6
|
+
const liveClasses = isLive
|
|
7
|
+
? 'border-emerald-500/40 bg-gradient-to-b from-emerald-500/10 to-card'
|
|
8
|
+
: '';
|
|
9
|
+
const errorClasses = isError ? 'border-destructive/40' : '';
|
|
10
|
+
const selectedClasses = isSelected
|
|
11
|
+
? 'border-primary/60 bg-primary/[0.10] shadow-[0_4px_12px_rgba(0,0,0,0.15)]'
|
|
12
|
+
: '';
|
|
13
|
+
const sizeClasses = size === 'tiny' ? 'px-2 py-1 text-[11px]' : 'px-2.5 py-2 text-xs';
|
|
14
|
+
return (_jsxs("button", { type: "button", "data-live": String(isLive), "data-selected": String(isSelected), "data-kind": event.kind, onClick: () => onSelect?.(event), className: [
|
|
15
|
+
'relative w-full cursor-pointer rounded-md border border-border bg-card text-left transition-[border-color,transform] duration-100',
|
|
16
|
+
'hover:translate-x-px hover:border-primary/40',
|
|
17
|
+
sizeClasses,
|
|
18
|
+
liveClasses,
|
|
19
|
+
errorClasses,
|
|
20
|
+
selectedClasses,
|
|
21
|
+
className ?? '',
|
|
22
|
+
].join(' '), children: [_jsx("span", { "aria-hidden": "true", className: "absolute -left-2.5 top-1/2 h-px w-2.5 bg-border" }), _jsxs("span", { className: "flex items-center gap-1.5 font-medium", children: [isLive && _jsx(LiveDot, {}), _jsx("span", { className: "min-w-0 flex-1 truncate", children: event.primary })] }), size === 'default' && event.secondary != null && (_jsx("span", { className: "mt-0.5 block text-[10px] text-muted-foreground tabular-nums", children: event.secondary }))] }));
|
|
23
|
+
}
|
|
24
|
+
function LiveDot() {
|
|
25
|
+
return (_jsx("span", { "aria-hidden": "true", className: "inline-block size-1.5 shrink-0 animate-pulse rounded-full bg-emerald-500 shadow-[0_0_0_3px_rgba(16,185,129,0.20)]" }));
|
|
26
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export type { DateSwitcherProps } from './date-switcher.js';
|
|
2
|
+
export { DateSwitcher } from './date-switcher.js';
|
|
3
|
+
export type { EventCardProps, EventCardSize } from './event-card.js';
|
|
4
|
+
export { EventCard } from './event-card.js';
|
|
5
|
+
export type { LiveIndicatorProps } from './live-indicator.js';
|
|
6
|
+
export { LiveIndicator } from './live-indicator.js';
|
|
7
|
+
export type { ScrubBarProps } from './scrub-bar.js';
|
|
8
|
+
export { ScrubBar } from './scrub-bar.js';
|
|
9
|
+
export type { TimelineRailProps } from './timeline-rail.js';
|
|
10
|
+
export { TimelineRail } from './timeline-rail.js';
|
|
11
|
+
export type { TimelineTapeProps } from './timeline-tape.js';
|
|
12
|
+
export { TimelineTape } from './timeline-tape.js';
|
|
13
|
+
export type { ScrubMark, TimelineEvent, TimelineEventKind } from './types.js';
|
|
@@ -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 {};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@medalsocial/meda",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "Shared Meda UI shell and runtime package.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"repository": {
|
|
@@ -32,6 +32,18 @@
|
|
|
32
32
|
"types": "./dist/voice/index.d.ts",
|
|
33
33
|
"default": "./dist/voice/index.js"
|
|
34
34
|
},
|
|
35
|
+
"./timeline": {
|
|
36
|
+
"types": "./dist/timeline/index.d.ts",
|
|
37
|
+
"default": "./dist/timeline/index.js"
|
|
38
|
+
},
|
|
39
|
+
"./chat": {
|
|
40
|
+
"types": "./dist/chat/index.d.ts",
|
|
41
|
+
"default": "./dist/chat/index.js"
|
|
42
|
+
},
|
|
43
|
+
"./panel": {
|
|
44
|
+
"types": "./dist/panel/index.d.ts",
|
|
45
|
+
"default": "./dist/panel/index.js"
|
|
46
|
+
},
|
|
35
47
|
"./styles.css": "./dist/styles.css"
|
|
36
48
|
},
|
|
37
49
|
"publishConfig": {
|
|
@@ -67,11 +79,11 @@
|
|
|
67
79
|
"@react-three/drei": "^10.7.7",
|
|
68
80
|
"@react-three/fiber": "^9.6.0",
|
|
69
81
|
"@tailwindcss/vite": "^4.2.4",
|
|
70
|
-
"@types/three": "^0.184.0",
|
|
71
82
|
"@testing-library/jest-dom": "^6.9.1",
|
|
72
83
|
"@testing-library/react": "^16.3.2",
|
|
73
84
|
"@types/react": "^19.2.14",
|
|
74
85
|
"@types/react-dom": "^19.2.3",
|
|
86
|
+
"@types/three": "^0.184.0",
|
|
75
87
|
"@vitejs/plugin-react": "^6.0.1",
|
|
76
88
|
"husky": "^9.1.7",
|
|
77
89
|
"jsdom": "^29.0.2",
|
|
@@ -81,6 +93,7 @@
|
|
|
81
93
|
"typescript": "~6.0.2",
|
|
82
94
|
"vite": "^8.0.4",
|
|
83
95
|
"vitest": "^4.1.4",
|
|
96
|
+
"vitest-axe": "^0.1.0",
|
|
84
97
|
"wrangler": "^4.0.0"
|
|
85
98
|
},
|
|
86
99
|
"scripts": {
|