@m6d/cortex-react 1.0.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 (37) hide show
  1. package/dist/index.d.ts +70 -0
  2. package/dist/index.js +3 -0
  3. package/dist/styles.css +2652 -0
  4. package/dist/theme.css +69 -0
  5. package/index.ts +17 -0
  6. package/package.json +67 -0
  7. package/src/chat-session.tsx +379 -0
  8. package/src/components/AttachmentQueue.tsx +75 -0
  9. package/src/components/ChatComposer.tsx +211 -0
  10. package/src/components/CopyButton.tsx +73 -0
  11. package/src/components/CortexChatWidget.tsx +538 -0
  12. package/src/components/JsonTree.tsx +111 -0
  13. package/src/components/LlmUsageBreakdown.tsx +42 -0
  14. package/src/components/LlmUsageChips.tsx +37 -0
  15. package/src/components/Message.tsx +81 -0
  16. package/src/components/MessageAbortedFlag.tsx +29 -0
  17. package/src/components/MessageAttachments.tsx +55 -0
  18. package/src/components/MessageList.tsx +102 -0
  19. package/src/components/MessageLlmInspector.tsx +184 -0
  20. package/src/components/MessagePart.tsx +54 -0
  21. package/src/components/MessageReasoningAnimated.tsx +12 -0
  22. package/src/components/MessageReasoningPart.tsx +76 -0
  23. package/src/components/MessageTextPart.tsx +78 -0
  24. package/src/components/MessageTokenUsage.tsx +145 -0
  25. package/src/components/MessageToolCallAnimated.tsx +86 -0
  26. package/src/components/MessageToolCallOutcome.tsx +95 -0
  27. package/src/components/MessageToolCallPart.tsx +95 -0
  28. package/src/components/MessageToolCallStatus.tsx +35 -0
  29. package/src/components/SubtleActivity.tsx +74 -0
  30. package/src/components/ThreadList.tsx +219 -0
  31. package/src/components/ToolAnimation.tsx +22 -0
  32. package/src/components/ToolExecuteCodeAnimated.tsx +8 -0
  33. package/src/components/ToolQueryGraphAnimated.tsx +8 -0
  34. package/src/config.ts +14 -0
  35. package/src/context.ts +48 -0
  36. package/src/cx.ts +4 -0
  37. package/src/format.ts +4 -0
@@ -0,0 +1,538 @@
1
+ import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react';
2
+ import {
3
+ applyWsEvent,
4
+ attachmentQueueFlags,
5
+ createAttachmentQueue,
6
+ createCortexApiClient,
7
+ createCortexSocket,
8
+ removeThread,
9
+ sortThreads,
10
+ translate,
11
+ upsertThread,
12
+ type MountMode,
13
+ } from '@cortex/client';
14
+ import type { AttachmentSummary, ThreadSummary, WsEvent } from '@cortex/contracts/wire';
15
+ import {
16
+ ChatSession,
17
+ type PendingSend,
18
+ type SessionHandle,
19
+ type SessionPatch,
20
+ type SessionUi,
21
+ } from '../chat-session';
22
+ import type { CortexReactConfig } from '../config';
23
+ import { CortexContext, type CortexContextValue } from '../context';
24
+ import { cx } from '../cx';
25
+ import { ChatComposer, type ChatComposerHandle } from './ChatComposer';
26
+ import { MessageList } from './MessageList';
27
+ import { ThreadList } from './ThreadList';
28
+
29
+ const initialSessionUi = {
30
+ messages: [],
31
+ isAgentWorking: false,
32
+ isLoadingMessages: false,
33
+ hasPendingToolCalls: false,
34
+ messageMetadata: new Map<string, never>(),
35
+ } satisfies SessionUi;
36
+
37
+ type MountedSession = { thread: ThreadSummary; mode: MountMode; epoch: number };
38
+
39
+ // fallow-ignore-next-line complexity -- 1:1 transcription of the Angular source; restructuring would risk parity drift
40
+ export function CortexChatWidget({
41
+ config,
42
+ className,
43
+ }: {
44
+ config: CortexReactConfig;
45
+ /** Sizing classes for the host, like the classes on Angular's widget element. */
46
+ className?: string;
47
+ }) {
48
+ const configRef = useRef(config);
49
+ configRef.current = config;
50
+
51
+ const [threads, setThreads] = useState<ThreadSummary[]>();
52
+ const [session, setSession] = useState<MountedSession>();
53
+ const [sessionUi, setSessionUi] = useState<SessionUi>(initialSessionUi);
54
+ const [debugMode, setDebugMode] = useState(false);
55
+ const [screen, setScreen] = useState<'threads' | 'chat'>('threads');
56
+ const [sidebarOpen, setSidebarOpen] = useState(false);
57
+
58
+ const sessionRef = useRef<SessionHandle | undefined>(undefined);
59
+ const pendingSendRef = useRef<PendingSend[]>([]);
60
+ const pendingThreadCreation = useRef<Promise<string> | undefined>(undefined);
61
+ const composerRef = useRef<ChatComposerHandle | null>(null);
62
+
63
+ const api = useMemo(() => createCortexApiClient(() => configRef.current.transport), []);
64
+
65
+ /**
66
+ * Selection is the thread handed to `selectThread`, resolved against the live
67
+ * list: the row there receives the title and isRunning updates the server
68
+ * pushes, so the selection follows them without hand-syncing. The snapshot
69
+ * only answers for a thread the list has not caught up with yet.
70
+ */
71
+ const selectedThread = useMemo(() => {
72
+ const snapshot = session?.thread;
73
+ if (!snapshot) return undefined;
74
+
75
+ return threads?.find((thread) => thread.id === snapshot.id) ?? snapshot;
76
+ }, [threads, session]);
77
+
78
+ const selectedThreadRef = useRef(selectedThread);
79
+ selectedThreadRef.current = selectedThread;
80
+ const sessionUiRef = useRef(sessionUi);
81
+ sessionUiRef.current = sessionUi;
82
+
83
+ const patchUi = useCallback((patch: SessionPatch) => {
84
+ setSessionUi((previous) => ({
85
+ ...previous,
86
+ ...(typeof patch === 'function' ? patch(previous) : patch),
87
+ }));
88
+ }, []);
89
+
90
+ const setRunning = useCallback((threadId: string, isRunning: boolean) => {
91
+ setThreads((current) => {
92
+ const thread = current?.find((candidate) => candidate.id === threadId);
93
+ return thread ? upsertThread(current ?? [], { ...thread, isRunning }) : current;
94
+ });
95
+ }, []);
96
+
97
+ const selectThread = useCallback(
98
+ (thread: ThreadSummary, options?: { skipLoadingMessages?: boolean }) => {
99
+ if (selectedThreadRef.current?.id === thread.id && sessionRef.current) return;
100
+
101
+ const previous = selectedThreadRef.current;
102
+ // Eager, not render-synced: a caller in the same tick (a second file in a
103
+ // multi-file drop, a queued send) must already see the selection.
104
+ selectedThreadRef.current = thread;
105
+ setSessionUi(initialSessionUi);
106
+ setSession({ thread, mode: options?.skipLoadingMessages ? 'skip' : 'load', epoch: 0 });
107
+
108
+ if (previous && previous.id !== thread.id) {
109
+ void configRef.current.hooks?.onThreadDeselected?.(previous);
110
+ }
111
+ void configRef.current.hooks?.onThreadSelected?.(thread);
112
+ },
113
+ [],
114
+ );
115
+
116
+ const deselectThread = useCallback(() => {
117
+ const previous = selectedThreadRef.current;
118
+ if (!previous && !sessionRef.current) return;
119
+
120
+ selectedThreadRef.current = undefined;
121
+ setSession(undefined);
122
+ setSessionUi(initialSessionUi);
123
+
124
+ if (previous) {
125
+ void configRef.current.hooks?.onThreadDeselected?.(previous);
126
+ }
127
+ }, []);
128
+
129
+ const remountSession = useCallback(
130
+ (thread: ThreadSummary) => {
131
+ // Like Angular's mountChat: a re-mount starts with a clean envelope and no
132
+ // owed tool calls; hydration brings the current ones back.
133
+ patchUi({ hasPendingToolCalls: false, messageMetadata: new Map() });
134
+ setSession((current) => ({ thread, mode: 'reload', epoch: (current?.epoch ?? 0) + 1 }));
135
+ },
136
+ [patchUi],
137
+ );
138
+
139
+ /**
140
+ * The id of the thread the next message belongs to, creating it when the
141
+ * widget is still on a blank chat. `prompt` only seeds the generated title.
142
+ * Resolved once for concurrent callers so a multi-file drop onto a blank chat
143
+ * does not race several thread creations against each other.
144
+ */
145
+ const ensureThread = useCallback(
146
+ (prompt?: string) => {
147
+ const selected = selectedThreadRef.current;
148
+ if (selected) return Promise.resolve(selected.id);
149
+
150
+ pendingThreadCreation.current ??= api.createThread(prompt).then(
151
+ (thread) => {
152
+ pendingThreadCreation.current = undefined;
153
+ setThreads((current) => upsertThread(current ?? [], thread));
154
+ selectThread(thread, { skipLoadingMessages: true });
155
+ return thread.id;
156
+ },
157
+ (error: unknown) => {
158
+ pendingThreadCreation.current = undefined;
159
+ throw error;
160
+ },
161
+ );
162
+ return pendingThreadCreation.current;
163
+ },
164
+ [api, selectThread],
165
+ );
166
+
167
+ const queueStore = useMemo(
168
+ () => createAttachmentQueue({ api, ensureThread: () => ensureThread() }),
169
+ [api, ensureThread],
170
+ );
171
+ const queueItems = useSyncExternalStore(
172
+ queueStore.subscribe,
173
+ queueStore.getState,
174
+ queueStore.getState,
175
+ );
176
+
177
+ const send = useCallback(
178
+ async (prompt: string, attachments: AttachmentSummary[] = []) => {
179
+ if (sessionUiRef.current.isAgentWorking || sessionUiRef.current.hasPendingToolCalls) return;
180
+
181
+ const handle = sessionRef.current;
182
+ if (handle) {
183
+ await handle.send(prompt, attachments);
184
+ return;
185
+ }
186
+
187
+ // Blank chat: the message goes out with the session the new thread
188
+ // mounts. A queue, not a slot — a second send racing the same creation
189
+ // must not overwrite the first.
190
+ pendingSendRef.current.push({ prompt, attachments });
191
+ try {
192
+ await ensureThread(prompt);
193
+ } catch (error) {
194
+ // A failed creation must not leave messages behind to replay into
195
+ // whatever thread mounts next.
196
+ pendingSendRef.current = [];
197
+ throw error;
198
+ }
199
+ },
200
+ [ensureThread],
201
+ );
202
+
203
+ const abort = useCallback(async () => {
204
+ await sessionRef.current?.abort();
205
+ }, []);
206
+
207
+ const addToolResult = useCallback((toolCallId: string, toolName: string, output: unknown) => {
208
+ sessionRef.current?.addToolResult(toolCallId, toolName, output);
209
+ }, []);
210
+
211
+ const deleteThread = useCallback(
212
+ async (threadId: string) => {
213
+ if (selectedThreadRef.current?.id === threadId) deselectThread();
214
+
215
+ await api.deleteThread(threadId);
216
+ setThreads((current) => (current ? removeThread(current, threadId) : current));
217
+ },
218
+ [api, deselectThread],
219
+ );
220
+
221
+ const onTurnFinished = useCallback(() => {
222
+ queueStore.discardConsumed();
223
+ setTimeout(() => composerRef.current?.focusInput());
224
+ }, [queueStore]);
225
+
226
+ const onSendFailed = useCallback(() => {
227
+ queueStore.restoreConsumed(selectedThreadRef.current?.id);
228
+ }, [queueStore]);
229
+
230
+ const reloadThreads = useCallback(async () => {
231
+ const listed = sortThreads(await api.listThreads());
232
+ setThreads(listed);
233
+ return listed;
234
+ }, [api]);
235
+
236
+ const handleWsEvent = useCallback(
237
+ (event: WsEvent) => {
238
+ setThreads((current) => {
239
+ const next = applyWsEvent(current ?? [], event);
240
+ // Before the first reload lands the list is still undefined (the loading
241
+ // skeleton); only an event that actually adds something may materialize it.
242
+ return current || next.length ? next : current;
243
+ });
244
+
245
+ const selectedId = selectedThreadRef.current?.id;
246
+ switch (event.type) {
247
+ case 'thread:deleted':
248
+ if (selectedId === event.payload.threadId) deselectThread();
249
+ break;
250
+ case 'thread:run-started':
251
+ // The payload rather than the list: the state update absorbing this
252
+ // event into the list has not been rendered yet.
253
+ if (selectedId === event.payload.thread.id) {
254
+ sessionRef.current?.reattach(event.payload.thread);
255
+ }
256
+ break;
257
+ case 'thread:messages-updated':
258
+ sessionRef.current?.refreshMessages(event.payload.threadId);
259
+ break;
260
+ }
261
+ },
262
+ [deselectThread],
263
+ );
264
+
265
+ useEffect(() => {
266
+ void reloadThreads();
267
+
268
+ // The first open is startup; only a socket that comes back marks a gap.
269
+ let openedOnce = false;
270
+ const socket = createCortexSocket({
271
+ // Thunks so every (re)connection attempt reads the config current then.
272
+ wsUrl: () => configRef.current.wsUrl,
273
+ transport: {
274
+ baseUrl: () => {
275
+ const baseUrl = configRef.current.transport.baseUrl;
276
+ return typeof baseUrl === 'string' ? baseUrl : baseUrl();
277
+ },
278
+ getHeaders: () => configRef.current.transport.getHeaders(),
279
+ },
280
+ onEvent: handleWsEvent,
281
+ onOpen: () => {
282
+ if (!openedOnce) {
283
+ openedOnce = true;
284
+ return;
285
+ }
286
+ // Anything could have happened while the socket was down, so the thread
287
+ // list is re-read from the server, and re-attaching sees the open
288
+ // thread's current `isRunning` to decide whether there is a run to tail.
289
+ void reloadThreads().then((listed) => {
290
+ const selected = selectedThreadRef.current;
291
+ if (!selected) return;
292
+
293
+ sessionRef.current?.reattach(
294
+ listed.find((thread) => thread.id === selected.id) ?? selected,
295
+ );
296
+ });
297
+ },
298
+ });
299
+
300
+ return () => {
301
+ socket.close();
302
+ };
303
+ }, [handleWsEvent, reloadThreads]);
304
+
305
+ // Leaving a thread abandons the files queued for it.
306
+ const previousThreadId = useRef<string | undefined>(undefined);
307
+ const selectedThreadId = selectedThread?.id;
308
+ useEffect(() => {
309
+ if (selectedThreadId === previousThreadId.current) return;
310
+
311
+ queueStore.clear(previousThreadId.current);
312
+ previousThreadId.current = selectedThreadId;
313
+ }, [selectedThreadId, queueStore]);
314
+
315
+ const locale = config.locale ?? 'en';
316
+ const t = useCallback(
317
+ (key: string, params?: Record<string, string | number>) => translate(locale, key, params),
318
+ [locale],
319
+ );
320
+
321
+ const viewMode = config.viewMode ?? 'helper';
322
+
323
+ const contextValue = {
324
+ config,
325
+ t,
326
+ api,
327
+ debugMode,
328
+ threads,
329
+ selectedThread,
330
+ deleteThread,
331
+ ...sessionUi,
332
+ send,
333
+ abort,
334
+ addToolResult,
335
+ queue: {
336
+ items: queueItems,
337
+ ...attachmentQueueFlags(queueItems),
338
+ accept: queueStore.accept,
339
+ remove: queueStore.remove,
340
+ consumeReady: queueStore.consumeReady,
341
+ },
342
+ } satisfies CortexContextValue;
343
+
344
+ function openThread(thread: ThreadSummary) {
345
+ selectThread(thread);
346
+ setScreen('chat');
347
+ setSidebarOpen(false);
348
+ }
349
+
350
+ function newChat() {
351
+ deselectThread();
352
+ setScreen('chat');
353
+ setSidebarOpen(false);
354
+ }
355
+
356
+ function goBack() {
357
+ deselectThread();
358
+ setScreen('threads');
359
+ }
360
+
361
+ return (
362
+ <CortexContext.Provider value={contextValue}>
363
+ <div className={cx('cortex-widget', className)} data-cortex-theme={config.theme}>
364
+ <div
365
+ className={cx(
366
+ 'cortex-widget__container',
367
+ viewMode === 'full' && 'cortex-widget__container--full',
368
+ sidebarOpen && 'cortex-widget__container--sidebar-open',
369
+ )}
370
+ onDragOver={(event) => event.preventDefault()}
371
+ onDrop={(event) => event.preventDefault()}
372
+ >
373
+ {/* SCREEN 1: THREADS */}
374
+ <ThreadList
375
+ className={cx(
376
+ 'cortex-widget__screen',
377
+ screen === 'threads' && 'cortex-widget__screen--active',
378
+ screen !== 'threads' && 'cortex-widget__screen--left',
379
+ )}
380
+ onThreadSelected={openThread}
381
+ onNewChatRequested={newChat}
382
+ />
383
+
384
+ {/* SCREEN 2: CHAT */}
385
+ <div
386
+ className={cx(
387
+ 'cortex-widget__screen',
388
+ screen === 'chat' && 'cortex-widget__screen--active',
389
+ screen !== 'chat' && 'cortex-widget__screen--right',
390
+ )}
391
+ >
392
+ <div className="cortex-widget__chat-header">
393
+ {/* Sidebar toggle (full mode, small screens) */}
394
+ <button
395
+ onClick={() => setSidebarOpen((open) => !open)}
396
+ className="cortex-widget__sidebar-toggle-btn"
397
+ >
398
+ <svg width="16" height="16" viewBox="0 0 16 16" fill="none">
399
+ <path
400
+ d="M2.5 4h11M2.5 8h11M2.5 12h11"
401
+ stroke="currentColor"
402
+ strokeWidth="1.4"
403
+ strokeLinecap="round"
404
+ />
405
+ </svg>
406
+ </button>
407
+
408
+ {/* Back button */}
409
+ <button onClick={goBack} className="cortex-widget__back-btn">
410
+ <svg
411
+ width="14"
412
+ height="14"
413
+ viewBox="0 0 16 16"
414
+ fill="none"
415
+ className="cortex-widget__back-icon"
416
+ >
417
+ <path
418
+ d="M10 3L5 8l5 5"
419
+ stroke="currentColor"
420
+ strokeWidth="1.5"
421
+ strokeLinecap="round"
422
+ strokeLinejoin="round"
423
+ />
424
+ </svg>
425
+ </button>
426
+
427
+ <div className="cortex-widget__chat-title-wrap">
428
+ <p className="cortex-widget__chat-title">
429
+ {selectedThread?.title ?? t('translate_new_chat')}
430
+ </p>
431
+ </div>
432
+
433
+ {config.showDebugButton && (
434
+ <button
435
+ onClick={() => setDebugMode((mode) => !mode)}
436
+ className={cx(
437
+ 'cortex-widget__debug-btn',
438
+ debugMode ? 'cortex-widget__debug-btn--on' : 'cortex-widget__debug-btn--off',
439
+ )}
440
+ >
441
+ {debugMode ? t('translate_debug') : t('translate_normal')}
442
+ </button>
443
+ )}
444
+ </div>
445
+
446
+ {sessionUi.isLoadingMessages && !sessionUi.isAgentWorking ? (
447
+ <div className="cortex-widget__messages-skeleton">
448
+ <div className="cortex-widget__msg-skel cortex-widget__msg-skel--user">
449
+ <div className="cortex-widget__msg-skel-bubble cortex-widget__msg-skel-bubble--user">
450
+ <div
451
+ className="cortex-skeleton cortex-widget__msg-skel-line"
452
+ style={{ width: '13rem' }}
453
+ />
454
+ <div
455
+ className="cortex-skeleton cortex-widget__msg-skel-line"
456
+ style={{ width: '9rem' }}
457
+ />
458
+ </div>
459
+ </div>
460
+ <div className="cortex-widget__msg-skel cortex-widget__msg-skel--assistant">
461
+ <div className="cortex-widget__msg-skel-bubble cortex-widget__msg-skel-bubble--assistant">
462
+ <div
463
+ className="cortex-skeleton cortex-widget__msg-skel-line"
464
+ style={{ width: '16rem' }}
465
+ />
466
+ <div
467
+ className="cortex-skeleton cortex-widget__msg-skel-line"
468
+ style={{ width: '18rem' }}
469
+ />
470
+ <div
471
+ className="cortex-skeleton cortex-widget__msg-skel-line"
472
+ style={{ width: '12rem' }}
473
+ />
474
+ </div>
475
+ </div>
476
+ <div className="cortex-widget__msg-skel cortex-widget__msg-skel--user">
477
+ <div className="cortex-widget__msg-skel-bubble cortex-widget__msg-skel-bubble--user">
478
+ <div
479
+ className="cortex-skeleton cortex-widget__msg-skel-line"
480
+ style={{ width: '11rem' }}
481
+ />
482
+ </div>
483
+ </div>
484
+ <div className="cortex-widget__msg-skel cortex-widget__msg-skel--assistant">
485
+ <div className="cortex-widget__msg-skel-bubble cortex-widget__msg-skel-bubble--assistant">
486
+ <div
487
+ className="cortex-skeleton cortex-widget__msg-skel-line"
488
+ style={{ width: '14rem' }}
489
+ />
490
+ <div
491
+ className="cortex-skeleton cortex-widget__msg-skel-line"
492
+ style={{ width: '15rem' }}
493
+ />
494
+ </div>
495
+ </div>
496
+ </div>
497
+ ) : (
498
+ <MessageList className="cortex-widget__messages" debugMode={debugMode} />
499
+ )}
500
+
501
+ {sessionUi.isAgentWorking && !sessionUi.hasPendingToolCalls && (
502
+ <div className="cortex-widget__working">
503
+ <div className="cortex-widget__working-dots">
504
+ <span className="cortex-working-dot" />
505
+ <span className="cortex-working-dot" />
506
+ <span className="cortex-working-dot" />
507
+ </div>
508
+ <span className="cortex-widget__working-text">{t('translate_thinking')}</span>
509
+ </div>
510
+ )}
511
+
512
+ {!sessionUi.hasPendingToolCalls && <ChatComposer ref={composerRef} />}
513
+ </div>
514
+
515
+ {/* Sidebar overlay backdrop (full mode, small screens) */}
516
+ <div className="cortex-widget__sidebar-backdrop" onClick={() => setSidebarOpen(false)} />
517
+ </div>
518
+
519
+ {session && (
520
+ <ChatSession
521
+ key={`${session.thread.id}:${session.epoch}`}
522
+ thread={session.thread}
523
+ mode={session.mode}
524
+ api={api}
525
+ configRef={configRef}
526
+ sessionRef={sessionRef}
527
+ pendingSendRef={pendingSendRef}
528
+ patchUi={patchUi}
529
+ setRunning={setRunning}
530
+ remount={remountSession}
531
+ onTurnFinished={onTurnFinished}
532
+ onSendFailed={onSendFailed}
533
+ />
534
+ )}
535
+ </div>
536
+ </CortexContext.Provider>
537
+ );
538
+ }
@@ -0,0 +1,111 @@
1
+ import { useMemo, useState } from 'react';
2
+ import type { MouseEvent } from 'react';
3
+ import { deepParseJson, describeJsonValue } from '@cortex/client';
4
+ import { cx } from '../cx';
5
+
6
+ type JsonTreeProps = {
7
+ data: unknown;
8
+ expandDepth?: number;
9
+ className?: string;
10
+ };
11
+
12
+ export function JsonTree({ data, expandDepth = 1, className }: JsonTreeProps) {
13
+ /** Memoized: the tree renders recursively, so parsing per pass would redo the
14
+ * whole document on every render. */
15
+ const parsedData = useMemo(() => deepParseJson(data), [data]);
16
+
17
+ /** Until the reader touches a node, its state follows expandDepth. */
18
+ const [userToggled, setUserToggled] = useState<Record<string, boolean | undefined>>({});
19
+
20
+ function isCollapsed(path: string, depth: number) {
21
+ return userToggled[path] ?? depth >= expandDepth;
22
+ }
23
+
24
+ function toggle(path: string, depth: number) {
25
+ setUserToggled((current) => ({ ...current, [path]: !isCollapsed(path, depth) }));
26
+ }
27
+
28
+ return (
29
+ <div className={cx('cortex-json-tree', className)}>
30
+ <JsonValue value={parsedData} path="$" depth={0} isCollapsed={isCollapsed} toggle={toggle} />
31
+ </div>
32
+ );
33
+ }
34
+
35
+ type JsonValueProps = {
36
+ value: unknown;
37
+ path: string;
38
+ depth: number;
39
+ isCollapsed: (path: string, depth: number) => boolean;
40
+ toggle: (path: string, depth: number) => void;
41
+ };
42
+
43
+ /**
44
+ * Recursive renderer. Every value goes through describeJsonValue(), so objects,
45
+ * arrays and primitives all arrive in one shape and this needs a single set of
46
+ * branches rather than one per container kind.
47
+ */
48
+ function JsonValue({ value, path, depth, isCollapsed, toggle }: JsonValueProps) {
49
+ const node = describeJsonValue(value, path);
50
+
51
+ if (node.kind === 'primitive') return <span className={node.className}>{node.text}</span>;
52
+
53
+ if (node.entries.length === 0) {
54
+ return (
55
+ <span className="jt-bracket">
56
+ {node.open}
57
+ {node.close}
58
+ </span>
59
+ );
60
+ }
61
+
62
+ const collapsed = isCollapsed(path, depth);
63
+
64
+ function onToggle(event: MouseEvent<HTMLSpanElement>) {
65
+ toggle(path, depth);
66
+ event.stopPropagation();
67
+ }
68
+
69
+ return (
70
+ <>
71
+ <span className="jt-toggle" onClick={onToggle} role="button">
72
+ <span className={cx('jt-arrow', collapsed && 'jt-arrow--collapsed')}>▾</span>
73
+ <span className="jt-bracket">{node.open}</span>
74
+ </span>
75
+
76
+ {collapsed ? (
77
+ <>
78
+ <span className="jt-collapsed-hint" onClick={onToggle} role="button">
79
+ {node.summary}
80
+ </span>
81
+ <span className="jt-bracket">{node.close}</span>
82
+ </>
83
+ ) : (
84
+ <>
85
+ <div className="jt-indent">
86
+ {node.entries.map((entry, index) => (
87
+ // One line of a container: its key if it has one, its value, and a separating comma.
88
+ <div className="jt-line" key={entry.path}>
89
+ {entry.key !== null && (
90
+ <>
91
+ <span className="jt-key">{`"${entry.key}"`}</span>
92
+ <span className="jt-colon">{': '}</span>
93
+ </>
94
+ )}
95
+ <JsonValue
96
+ value={entry.value}
97
+ path={entry.path}
98
+ depth={depth + 1}
99
+ isCollapsed={isCollapsed}
100
+ toggle={toggle}
101
+ />
102
+ {index < node.entries.length - 1 && <span className="jt-comma">,</span>}
103
+ </div>
104
+ ))}
105
+ </div>
106
+ <span className="jt-bracket">{node.close}</span>
107
+ </>
108
+ )}
109
+ </>
110
+ );
111
+ }
@@ -0,0 +1,42 @@
1
+ import type { TokenUsage } from '@cortex/contracts/wire';
2
+ import { useCortex } from '../context';
3
+ import { num } from '../format';
4
+
5
+ type LlmUsageBreakdownProps = {
6
+ usage: TokenUsage;
7
+ };
8
+
9
+ /**
10
+ * The expanded token accounting for one LLM request: where the input came from,
11
+ * what the output was spent on, and the total.
12
+ */
13
+ export function LlmUsageBreakdown({ usage }: LlmUsageBreakdownProps) {
14
+ const { t } = useCortex();
15
+
16
+ return (
17
+ <div className="cortex-llm-inspector__usage-rows">
18
+ <div className="cortex-llm-inspector__usage-row">
19
+ <span className="cortex-llm-inspector__usage-lbl">{t('translate_input')}</span>
20
+ <span className="cortex-llm-inspector__usage-val">{num(usage.input.total)}</span>
21
+ <span className="cortex-llm-inspector__usage-detail">
22
+ {t('translate_fresh')} {num(usage.input.noCache)} &middot; {t('translate_read')}{' '}
23
+ {num(usage.input.cacheRead)} &middot; {t('translate_write')} {num(usage.input.cacheWrite)}
24
+ </span>
25
+ </div>
26
+
27
+ <div className="cortex-llm-inspector__usage-row">
28
+ <span className="cortex-llm-inspector__usage-lbl">{t('translate_output')}</span>
29
+ <span className="cortex-llm-inspector__usage-val">{num(usage.output.total)}</span>
30
+ <span className="cortex-llm-inspector__usage-detail">
31
+ {t('translate_text')} {num(usage.output.text)} &middot; {t('translate_reasoning')}{' '}
32
+ {num(usage.output.reasoning)}
33
+ </span>
34
+ </div>
35
+
36
+ <div className="cortex-llm-inspector__usage-row cortex-llm-inspector__usage-row--total">
37
+ <span className="cortex-llm-inspector__usage-lbl">{t('translate_total')}</span>
38
+ <span className="cortex-llm-inspector__usage-val">{num(usage.total)}</span>
39
+ </div>
40
+ </div>
41
+ );
42
+ }