@guuey/chat 0.4.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 (66) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +137 -0
  3. package/dist/history-inputs.d.ts +24 -0
  4. package/dist/history-inputs.d.ts.map +1 -0
  5. package/dist/history-inputs.js +34 -0
  6. package/dist/index.d.ts +19 -0
  7. package/dist/index.d.ts.map +1 -0
  8. package/dist/index.js +17 -0
  9. package/dist/plan.d.ts +5 -0
  10. package/dist/plan.d.ts.map +1 -0
  11. package/dist/plan.js +629 -0
  12. package/dist/policy.d.ts +110 -0
  13. package/dist/policy.d.ts.map +1 -0
  14. package/dist/policy.js +75 -0
  15. package/dist/react/components.d.ts +87 -0
  16. package/dist/react/components.d.ts.map +1 -0
  17. package/dist/react/components.js +270 -0
  18. package/dist/react/guuey-chat.d.ts +84 -0
  19. package/dist/react/guuey-chat.d.ts.map +1 -0
  20. package/dist/react/guuey-chat.js +103 -0
  21. package/dist/react/markdown.d.ts +32 -0
  22. package/dist/react/markdown.d.ts.map +1 -0
  23. package/dist/react/markdown.js +40 -0
  24. package/dist/react/theme-css.d.ts +16 -0
  25. package/dist/react/theme-css.d.ts.map +1 -0
  26. package/dist/react/theme-css.js +37 -0
  27. package/dist/react/transcript.d.ts +42 -0
  28. package/dist/react/transcript.d.ts.map +1 -0
  29. package/dist/react/transcript.js +88 -0
  30. package/dist/react/use-transcript.d.ts +39 -0
  31. package/dist/react/use-transcript.d.ts.map +1 -0
  32. package/dist/react/use-transcript.js +201 -0
  33. package/dist/react.d.ts +21 -0
  34. package/dist/react.d.ts.map +1 -0
  35. package/dist/react.js +20 -0
  36. package/dist/strings.d.ts +74 -0
  37. package/dist/strings.d.ts.map +1 -0
  38. package/dist/strings.js +45 -0
  39. package/dist/theme.d.ts +99 -0
  40. package/dist/theme.d.ts.map +1 -0
  41. package/dist/theme.js +182 -0
  42. package/dist/types.d.ts +283 -0
  43. package/dist/types.d.ts.map +1 -0
  44. package/dist/types.js +1 -0
  45. package/package.json +87 -0
  46. package/src/corpus/README.md +40 -0
  47. package/src/corpus/__snapshots__/corpus.test.ts.snap +1590 -0
  48. package/src/corpus/capture.ts +67 -0
  49. package/src/corpus/captures/issue2627-render-capture.coalesced.sse.txt +173 -0
  50. package/src/corpus/drive.ts +184 -0
  51. package/src/corpus/fixtures.ts +338 -0
  52. package/src/history-inputs.ts +48 -0
  53. package/src/index.ts +58 -0
  54. package/src/plan.ts +740 -0
  55. package/src/policy.ts +146 -0
  56. package/src/react/components.tsx +655 -0
  57. package/src/react/guuey-chat.tsx +227 -0
  58. package/src/react/markdown.tsx +114 -0
  59. package/src/react/theme-css.ts +50 -0
  60. package/src/react/transcript.tsx +187 -0
  61. package/src/react/use-transcript.ts +274 -0
  62. package/src/react.tsx +51 -0
  63. package/src/strings.ts +144 -0
  64. package/src/theme.ts +195 -0
  65. package/src/types.ts +320 -0
  66. package/styles.css +514 -0
@@ -0,0 +1,655 @@
1
+ /**
2
+ * The per-category component kit (spec §3's override slots): one component
3
+ * per `DisplayItem` variant, each a THIN walk of its item — every rendering
4
+ * decision (states, labels, collapse defaults, copy) was already made by
5
+ * `planTranscript`; components translate the decided item into markup and
6
+ * never consult policy or invent copy.
7
+ *
8
+ * Override contract: `TranscriptComponents` is the component map
9
+ * (`{ tool: MyToolChip }` replaces one row's renderer without forfeiting
10
+ * the rest); every default is exported for composition.
11
+ *
12
+ * Accessibility (spec §3.2, acceptance criteria):
13
+ * - every `expanded` toggle is a real `<button aria-expanded aria-controls>`
14
+ * (keyboard-operable for free);
15
+ * - the status line is `role="status"` (a polite live region);
16
+ * - a streaming text bubble announces via `aria-live="polite"`;
17
+ * - R10 prompts take focus on appearance and return it on resolution;
18
+ * - shimmer/pulse is CSS-only and disabled under `prefers-reduced-motion`
19
+ * (see styles.css).
20
+ */
21
+ import {
22
+ useEffect,
23
+ useRef,
24
+ useState,
25
+ type ComponentType,
26
+ type ReactNode,
27
+ } from "react";
28
+ import { GuueyView, type GuueyViewProps } from "@guuey/mcp-apps-host/react";
29
+ import type { ResolvedViewMount, ViewHostPhase } from "@guuey/mcp-apps-host";
30
+ import type { ChatStrings } from "../strings.js";
31
+ import type {
32
+ CitationsItem,
33
+ CodeItem,
34
+ CompactionItem,
35
+ DataResultItem,
36
+ DisplayItem,
37
+ ErrorItem,
38
+ HistoryBoundaryItem,
39
+ ItemKey,
40
+ MediaItem,
41
+ PromptItem,
42
+ ReasoningItem,
43
+ StatusLineItem,
44
+ ToolGroupItem,
45
+ ToolItem,
46
+ UnknownItem,
47
+ UserMessageItem,
48
+ TextItem,
49
+ ViewMountItem,
50
+ } from "../types.js";
51
+ import { Markdown } from "./markdown.js";
52
+
53
+ /** Everything a rendered item may need beyond itself. */
54
+ export interface TranscriptItemContext {
55
+ strings: ChatStrings;
56
+ /** Flip an item's collapse state (the renderer owns override state). */
57
+ onToggle: (key: ItemKey) => void;
58
+ /** R0 failed-send retry. */
59
+ onRetry?: (item: UserMessageItem) => void;
60
+ /** R10 prompt actions — the host owns what accept/decline DO. */
61
+ onPromptAction?: (item: PromptItem, action: "accept" | "decline" | "dismiss") => void;
62
+ /** R11 action slots (sign-in / upgrade / retry affordances). */
63
+ onErrorAction?: (item: ErrorItem) => void;
64
+ /** R6: locator mounts resolved by `useTranscript` ("expired" = failed). */
65
+ resolvedMounts: ReadonlyMap<ItemKey, ResolvedViewMount | "expired">;
66
+ /** R6: live phase reports wired back into the next plan. */
67
+ onViewPhase: (key: ItemKey, phase: ViewHostPhase) => void;
68
+ /** R6 pass-through (sandbox overrides etc. — policy-gated by the host). */
69
+ viewProps?: Pick<
70
+ GuueyViewProps,
71
+ "hostCapabilities" | "hostInfo" | "hostContext" | "onCallTool" | "negotiationTimeoutMs" | "dangerouslyAddSandboxFlags" | "allow"
72
+ >;
73
+ }
74
+
75
+ interface ItemProps<T> {
76
+ item: T;
77
+ ctx: TranscriptItemContext;
78
+ }
79
+
80
+ /** A collapse toggle + body pair with correct ARIA plumbing. */
81
+ function Collapsible({
82
+ itemKey,
83
+ expanded,
84
+ header,
85
+ ctx,
86
+ className,
87
+ children,
88
+ }: {
89
+ itemKey: ItemKey;
90
+ expanded: boolean;
91
+ header: ReactNode;
92
+ ctx: TranscriptItemContext;
93
+ className?: string;
94
+ children: ReactNode;
95
+ }): ReactNode {
96
+ const bodyId = `guuey-chat-body-${itemKey}`;
97
+ return (
98
+ <div className={className}>
99
+ <button
100
+ type="button"
101
+ className="guuey-chat-toggle"
102
+ aria-expanded={expanded}
103
+ aria-controls={bodyId}
104
+ onClick={() => ctx.onToggle(itemKey)}
105
+ >
106
+ {header}
107
+ <span aria-hidden="true" className="guuey-chat-toggle-glyph">
108
+ {expanded ? "▾" : "▸"}
109
+ </span>
110
+ </button>
111
+ {expanded ? (
112
+ <div id={bodyId} className="guuey-chat-body">
113
+ {children}
114
+ </div>
115
+ ) : null}
116
+ </div>
117
+ );
118
+ }
119
+
120
+ /** A copy-to-clipboard affordance with a transient confirmation state. */
121
+ function CopyButton({ text, ctx }: { text: string; ctx: TranscriptItemContext }): ReactNode {
122
+ const [copied, setCopied] = useState(false);
123
+ useEffect(() => {
124
+ if (!copied) return;
125
+ const timer = setTimeout(() => setCopied(false), 1500);
126
+ return () => clearTimeout(timer);
127
+ }, [copied]);
128
+ return (
129
+ <button
130
+ type="button"
131
+ className="guuey-chat-copy"
132
+ onClick={() => {
133
+ void navigator.clipboard?.writeText(text).then(() => setCopied(true));
134
+ }}
135
+ >
136
+ {copied ? ctx.strings.copied : ctx.strings.copy}
137
+ </button>
138
+ );
139
+ }
140
+
141
+ // ─── R0 ────────────────────────────────────────────────────────────────────
142
+
143
+ export function DefaultUserMessage({ item, ctx }: ItemProps<UserMessageItem>): ReactNode {
144
+ return (
145
+ <div className={`guuey-chat-user guuey-chat-user-${item.state}`}>
146
+ {/* User text is QUOTED, never rendered — whitespace preserved. */}
147
+ <div className="guuey-chat-user-bubble">{item.text}</div>
148
+ {item.state === "failed" ? (
149
+ <p className="guuey-chat-send-failed" role="status">
150
+ {ctx.strings.userCouldntSend}
151
+ {item.retry ? (
152
+ <button type="button" className="guuey-chat-retry" onClick={() => ctx.onRetry?.(item)}>
153
+ {ctx.strings.userRetry}
154
+ </button>
155
+ ) : null}
156
+ </p>
157
+ ) : null}
158
+ </div>
159
+ );
160
+ }
161
+
162
+ // ─── R1 ────────────────────────────────────────────────────────────────────
163
+
164
+ export function DefaultText({ item, ctx }: ItemProps<TextItem>): ReactNode {
165
+ return (
166
+ <div
167
+ className="guuey-chat-text"
168
+ {...(item.streaming ? { "aria-live": "polite" as const } : {})}
169
+ >
170
+ {item.markdown ? (
171
+ <Markdown text={item.text} />
172
+ ) : (
173
+ <p className="guuey-chat-verbatim">{item.text}</p>
174
+ )}
175
+ {item.streaming ? (
176
+ <span aria-hidden="true" className="guuey-chat-cursor">
177
+ {" ▍"}
178
+ </span>
179
+ ) : null}
180
+ {item.stopped ? <p className="guuey-chat-stopped">{ctx.strings.stopped}</p> : null}
181
+ </div>
182
+ );
183
+ }
184
+
185
+ // ─── R2 ────────────────────────────────────────────────────────────────────
186
+
187
+ export function DefaultReasoning({ item, ctx }: ItemProps<ReasoningItem>): ReactNode {
188
+ return (
189
+ <Collapsible
190
+ itemKey={item.key}
191
+ expanded={item.expanded}
192
+ ctx={ctx}
193
+ className={`guuey-chat-reasoning${item.streaming ? " guuey-chat-pulse" : ""}`}
194
+ header={<span>{item.label}</span>}
195
+ >
196
+ <p className="guuey-chat-reasoning-text">{item.text}</p>
197
+ </Collapsible>
198
+ );
199
+ }
200
+
201
+ // ─── R5 (also embedded inside R3's expansion) ──────────────────────────────
202
+
203
+ export function DefaultDataResult({ item, ctx }: ItemProps<DataResultItem>): ReactNode {
204
+ const bytes = ctx.strings.bytes(item.byteCount);
205
+ return (
206
+ <div className={`guuey-chat-data guuey-chat-data-${item.state}`}>
207
+ {item.state === "empty" ? (
208
+ <p className="guuey-chat-data-empty">{ctx.strings.noOutput}</p>
209
+ ) : item.preview === null ? (
210
+ <p className="guuey-chat-data-binary">{bytes}</p>
211
+ ) : (
212
+ <>
213
+ <pre className="guuey-chat-data-preview">{item.preview}</pre>
214
+ <div className="guuey-chat-data-meta">
215
+ {item.showBytes ? <span>{bytes}</span> : null}
216
+ <CopyButton text={item.preview} ctx={ctx} />
217
+ </div>
218
+ </>
219
+ )}
220
+ </div>
221
+ );
222
+ }
223
+
224
+ // ─── R3 ────────────────────────────────────────────────────────────────────
225
+
226
+ const TOOL_GLYPH: Record<ToolItem["state"], string> = {
227
+ running: "◌",
228
+ done: "✓",
229
+ failed: "✕",
230
+ orphaned: "–",
231
+ };
232
+
233
+ export function DefaultTool({ item, ctx }: ItemProps<ToolItem>): ReactNode {
234
+ // R4's display-bearing rule: in calm this call's line lives in its view
235
+ // row's chrome ("via {tool}") — rendering it here too would double it.
236
+ if (item.attribution) return null;
237
+ const expandable = item.argsPreview !== null || item.result !== null;
238
+ const header = (
239
+ <span className={`guuey-chat-tool-line guuey-chat-tool-${item.state}`}>
240
+ <span aria-hidden="true" className="guuey-chat-tool-glyph">
241
+ {TOOL_GLYPH[item.state]}
242
+ </span>
243
+ <span className="guuey-chat-tool-title">{item.title}</span>
244
+ {item.state === "orphaned" ? (
245
+ <span className="guuey-chat-tool-note">{ctx.strings.toolDidntFinish}</span>
246
+ ) : null}
247
+ </span>
248
+ );
249
+ if (!expandable) return <div className="guuey-chat-tool">{header}</div>;
250
+ return (
251
+ <Collapsible
252
+ itemKey={item.key}
253
+ expanded={item.expanded}
254
+ ctx={ctx}
255
+ className="guuey-chat-tool"
256
+ header={header}
257
+ >
258
+ {item.argsPreview !== null ? (
259
+ <pre className="guuey-chat-tool-args">{item.argsPreview}</pre>
260
+ ) : null}
261
+ {item.result !== null ? <DefaultDataResult item={item.result} ctx={ctx} /> : null}
262
+ </Collapsible>
263
+ );
264
+ }
265
+
266
+ // ─── R4 ────────────────────────────────────────────────────────────────────
267
+
268
+ export function DefaultToolGroup({ item, ctx }: ItemProps<ToolGroupItem>): ReactNode {
269
+ return (
270
+ <Collapsible
271
+ itemKey={item.key}
272
+ expanded={item.expanded}
273
+ ctx={ctx}
274
+ className="guuey-chat-tool-group"
275
+ header={
276
+ <span>
277
+ {item.label}
278
+ {item.failureBadge !== null ? (
279
+ <span className="guuey-chat-failure-badge">{item.failureBadge}</span>
280
+ ) : null}
281
+ </span>
282
+ }
283
+ >
284
+ {item.tools.map((tool) => (
285
+ <DefaultTool key={tool.key} item={tool} ctx={ctx} />
286
+ ))}
287
+ </Collapsible>
288
+ );
289
+ }
290
+
291
+ // ─── R6 ────────────────────────────────────────────────────────────────────
292
+
293
+ export function DefaultView({ item, ctx }: ItemProps<ViewMountItem>): ReactNode {
294
+ // Resolve what to actually mount: direct material, or the locator's
295
+ // resolution from `useTranscript` (renderer state — "expired" = miss).
296
+ const resolution = ctx.resolvedMounts.get(item.key);
297
+ const mount: ResolvedViewMount | null =
298
+ item.mount !== null && item.mount.channel !== "locator"
299
+ ? item.mount
300
+ : resolution !== undefined && resolution !== "expired"
301
+ ? resolution
302
+ : null;
303
+ const expired = item.phase === "expired" || resolution === "expired";
304
+
305
+ if (expired) {
306
+ return (
307
+ <div className="guuey-chat-view guuey-chat-view-expired">
308
+ <p role="status">{ctx.strings.viewExpired}</p>
309
+ </div>
310
+ );
311
+ }
312
+ if (mount === null) {
313
+ // A locator still resolving (or a plan-level expired mount) — labeled,
314
+ // never blank.
315
+ return (
316
+ <div className="guuey-chat-view guuey-chat-view-negotiating guuey-chat-shimmer">
317
+ <p role="status">{item.label ?? ctx.strings.viewNegotiating}</p>
318
+ </div>
319
+ );
320
+ }
321
+ return (
322
+ <div className="guuey-chat-view">
323
+ <GuueyView
324
+ mount={mount}
325
+ {...(ctx.viewProps ?? {})}
326
+ onPhaseChange={(phase) => ctx.onViewPhase(item.key, phase)}
327
+ />
328
+ {item.attribution !== null ? (
329
+ <p className="guuey-chat-attribution">{item.attribution}</p>
330
+ ) : null}
331
+ </div>
332
+ );
333
+ }
334
+
335
+ // ─── R7 ────────────────────────────────────────────────────────────────────
336
+
337
+ /** Inline images allow https URLs and image/* base64 data — nothing else. */
338
+ function imageSrc(item: MediaItem): string | null {
339
+ const source = item.source;
340
+ if (source.type === "url" && /^https:\/\//i.test(source.url)) return source.url;
341
+ if (source.type === "base64" && /^image\//.test(source.mediaType)) {
342
+ return `data:${source.mediaType};base64,${source.data}`;
343
+ }
344
+ return null;
345
+ }
346
+
347
+ export function DefaultMedia({ item }: ItemProps<MediaItem>): ReactNode {
348
+ const [failed, setFailed] = useState(false);
349
+ if (item.presentation === "inline" && item.media === "image" && !failed) {
350
+ const src = imageSrc(item);
351
+ if (src !== null) {
352
+ return (
353
+ <a className="guuey-chat-media-image" href={src} target="_blank" rel="noopener noreferrer">
354
+ <img src={src} alt={item.name ?? ""} onError={() => setFailed(true)} />
355
+ </a>
356
+ );
357
+ }
358
+ }
359
+ if (item.media === "audio" && item.source.type === "url" && /^https:\/\//i.test(item.source.url)) {
360
+ return <audio className="guuey-chat-media-audio" controls src={item.source.url} />;
361
+ }
362
+ // Attachment chip: files, documents, unloadable/oversized media.
363
+ return (
364
+ <span className={`guuey-chat-media-chip guuey-chat-media-${item.media}`}>
365
+ {item.name ?? item.media}
366
+ </span>
367
+ );
368
+ }
369
+
370
+ // ─── R8 ────────────────────────────────────────────────────────────────────
371
+
372
+ export function DefaultCode({ item, ctx }: ItemProps<CodeItem>): ReactNode {
373
+ return (
374
+ <div className="guuey-chat-code">
375
+ <div className="guuey-chat-code-meta">
376
+ <span className="guuey-chat-code-lang">{item.language}</span>
377
+ <CopyButton text={item.code} ctx={ctx} />
378
+ </div>
379
+ <pre className={item.wrap ? "guuey-chat-code-wrap" : undefined}>{item.code}</pre>
380
+ </div>
381
+ );
382
+ }
383
+
384
+ // ─── R9 ────────────────────────────────────────────────────────────────────
385
+
386
+ /** Citation links navigate only to http(s) targets. */
387
+ function safeCitationUrl(url: string | null): string | null {
388
+ return url !== null && /^https?:\/\//i.test(url) ? url : null;
389
+ }
390
+
391
+ export function DefaultCitations({ item, ctx }: ItemProps<CitationsItem>): ReactNode {
392
+ return (
393
+ <Collapsible
394
+ itemKey={item.key}
395
+ expanded={item.expanded}
396
+ ctx={ctx}
397
+ className="guuey-chat-citations"
398
+ header={<span>{item.label}</span>}
399
+ >
400
+ <ul className={`guuey-chat-citations-${item.style}`}>
401
+ {item.sources.map((source, i) => {
402
+ const url = safeCitationUrl(source.url);
403
+ const label = source.title ?? source.url ?? "";
404
+ return (
405
+ <li key={i}>
406
+ {url !== null ? (
407
+ <a href={url} target="_blank" rel="noopener noreferrer">
408
+ {label}
409
+ </a>
410
+ ) : (
411
+ <span>{label}</span>
412
+ )}
413
+ </li>
414
+ );
415
+ })}
416
+ </ul>
417
+ </Collapsible>
418
+ );
419
+ }
420
+
421
+ // ─── R10 ───────────────────────────────────────────────────────────────────
422
+
423
+ export function DefaultPrompt({ item, ctx }: ItemProps<PromptItem>): ReactNode {
424
+ const firstAction = useRef<HTMLButtonElement>(null);
425
+ const restoreTo = useRef<Element | null>(null);
426
+ const wasPending = useRef(false);
427
+
428
+ // Focus management (§3.2): take focus when the ask appears, hand it back
429
+ // when the ask resolves.
430
+ useEffect(() => {
431
+ if (item.state === "pending" && !wasPending.current) {
432
+ restoreTo.current = document.activeElement;
433
+ firstAction.current?.focus();
434
+ }
435
+ if (item.state !== "pending" && wasPending.current) {
436
+ const target = restoreTo.current;
437
+ if (target instanceof HTMLElement && target.isConnected) target.focus();
438
+ }
439
+ wasPending.current = item.state === "pending";
440
+ }, [item.state]);
441
+
442
+ if (item.state !== "pending") {
443
+ return (
444
+ <p className={`guuey-chat-prompt-record guuey-chat-prompt-${item.state}`}>
445
+ {item.promptKind}: {item.state}
446
+ </p>
447
+ );
448
+ }
449
+ return (
450
+ <div className="guuey-chat-prompt" role="group">
451
+ <p className="guuey-chat-prompt-ask">
452
+ {item.promptKind === "consent"
453
+ ? `${item.appId} requests ${item.requested} access`
454
+ : `Link your account to ${item.appId}`}
455
+ </p>
456
+ <div className="guuey-chat-prompt-actions">
457
+ <button
458
+ ref={firstAction}
459
+ type="button"
460
+ className="guuey-chat-prompt-accept"
461
+ onClick={() => ctx.onPromptAction?.(item, "accept")}
462
+ >
463
+ Allow
464
+ </button>
465
+ <button type="button" onClick={() => ctx.onPromptAction?.(item, "decline")}>
466
+ Decline
467
+ </button>
468
+ </div>
469
+ {item.raw !== null ? (
470
+ <pre className="guuey-chat-prompt-raw">{JSON.stringify(item.raw, null, 2)}</pre>
471
+ ) : null}
472
+ </div>
473
+ );
474
+ }
475
+
476
+ // ─── R11 ───────────────────────────────────────────────────────────────────
477
+
478
+ export function DefaultError({ item, ctx }: ItemProps<ErrorItem>): ReactNode {
479
+ return (
480
+ <div className={`guuey-chat-error guuey-chat-error-${item.family}`} role="alert">
481
+ <p>{item.copy}</p>
482
+ {ctx.onErrorAction !== undefined && (item.family === "transient" || item.family === "auth") ? (
483
+ <button type="button" className="guuey-chat-error-action" onClick={() => ctx.onErrorAction?.(item)}>
484
+ {item.family === "auth" ? ctx.strings.errorAuth : ctx.strings.userRetry}
485
+ </button>
486
+ ) : null}
487
+ {item.verbatim !== null ? <pre className="guuey-chat-error-verbatim">{item.verbatim}</pre> : null}
488
+ </div>
489
+ );
490
+ }
491
+
492
+ // ─── R13 / R14 / R15 ───────────────────────────────────────────────────────
493
+
494
+ export function DefaultHistoryBoundary({ item }: ItemProps<HistoryBoundaryItem>): ReactNode {
495
+ return (
496
+ <div
497
+ className={`guuey-chat-history guuey-chat-history-${item.state}${item.state === "loading" ? " guuey-chat-shimmer" : ""}`}
498
+ role="status"
499
+ >
500
+ {item.label}
501
+ </div>
502
+ );
503
+ }
504
+
505
+ export function DefaultCompaction({ item }: ItemProps<CompactionItem>): ReactNode {
506
+ return (
507
+ <div className="guuey-chat-compaction" role="separator">
508
+ {item.label}
509
+ </div>
510
+ );
511
+ }
512
+
513
+ export function DefaultUnknown({ item, ctx }: ItemProps<UnknownItem>): ReactNode {
514
+ return (
515
+ <Collapsible
516
+ itemKey={item.key}
517
+ expanded={item.expanded}
518
+ ctx={ctx}
519
+ className="guuey-chat-unknown"
520
+ header={
521
+ <span>
522
+ {item.label} <span className="guuey-chat-unknown-type">({item.typeName})</span>
523
+ </span>
524
+ }
525
+ >
526
+ {item.raw !== null ? (
527
+ <pre className="guuey-chat-unknown-raw">{JSON.stringify(item.raw, null, 2)}</pre>
528
+ ) : (
529
+ <p className="guuey-chat-unknown-size">{ctx.strings.bytes(item.byteSize)}</p>
530
+ )}
531
+ </Collapsible>
532
+ );
533
+ }
534
+
535
+ // ─── R12 / §4 ──────────────────────────────────────────────────────────────
536
+
537
+ export function DefaultStatus({ item }: ItemProps<StatusLineItem>): ReactNode {
538
+ return (
539
+ <p className={`guuey-chat-status guuey-chat-status-${item.state} guuey-chat-pulse`} role="status">
540
+ {item.copy}
541
+ {item.detail !== null ? <span className="guuey-chat-status-detail"> · {item.detail}</span> : null}
542
+ </p>
543
+ );
544
+ }
545
+
546
+ // ─── The component map ─────────────────────────────────────────────────────
547
+
548
+ /** One component per §3 override slot. */
549
+ export interface TranscriptComponents {
550
+ userMessage: ComponentType<ItemProps<UserMessageItem>>;
551
+ text: ComponentType<ItemProps<TextItem>>;
552
+ reasoning: ComponentType<ItemProps<ReasoningItem>>;
553
+ tool: ComponentType<ItemProps<ToolItem>>;
554
+ toolGroup: ComponentType<ItemProps<ToolGroupItem>>;
555
+ dataResult: ComponentType<ItemProps<DataResultItem>>;
556
+ view: ComponentType<ItemProps<ViewMountItem>>;
557
+ media: ComponentType<ItemProps<MediaItem>>;
558
+ code: ComponentType<ItemProps<CodeItem>>;
559
+ citations: ComponentType<ItemProps<CitationsItem>>;
560
+ prompt: ComponentType<ItemProps<PromptItem>>;
561
+ error: ComponentType<ItemProps<ErrorItem>>;
562
+ history: ComponentType<ItemProps<HistoryBoundaryItem>>;
563
+ compaction: ComponentType<ItemProps<CompactionItem>>;
564
+ unknown: ComponentType<ItemProps<UnknownItem>>;
565
+ status: ComponentType<ItemProps<StatusLineItem>>;
566
+ }
567
+
568
+ export const defaultTranscriptComponents: TranscriptComponents = {
569
+ userMessage: DefaultUserMessage,
570
+ text: DefaultText,
571
+ reasoning: DefaultReasoning,
572
+ tool: DefaultTool,
573
+ toolGroup: DefaultToolGroup,
574
+ dataResult: DefaultDataResult,
575
+ view: DefaultView,
576
+ media: DefaultMedia,
577
+ code: DefaultCode,
578
+ citations: DefaultCitations,
579
+ prompt: DefaultPrompt,
580
+ error: DefaultError,
581
+ history: DefaultHistoryBoundary,
582
+ compaction: DefaultCompaction,
583
+ unknown: DefaultUnknown,
584
+ status: DefaultStatus,
585
+ };
586
+
587
+ /** Dispatch one display item through the (possibly overridden) map. */
588
+ export function renderItem(
589
+ item: DisplayItem,
590
+ components: TranscriptComponents,
591
+ ctx: TranscriptItemContext,
592
+ ): ReactNode {
593
+ switch (item.kind) {
594
+ case "user": {
595
+ const C = components.userMessage;
596
+ return <C key={item.key} item={item} ctx={ctx} />;
597
+ }
598
+ case "text": {
599
+ const C = components.text;
600
+ return <C key={item.key} item={item} ctx={ctx} />;
601
+ }
602
+ case "reasoning": {
603
+ const C = components.reasoning;
604
+ return <C key={item.key} item={item} ctx={ctx} />;
605
+ }
606
+ case "tool": {
607
+ const C = components.tool;
608
+ return <C key={item.key} item={item} ctx={ctx} />;
609
+ }
610
+ case "tool-group": {
611
+ const C = components.toolGroup;
612
+ return <C key={item.key} item={item} ctx={ctx} />;
613
+ }
614
+ case "data-result": {
615
+ const C = components.dataResult;
616
+ return <C key={item.key} item={item} ctx={ctx} />;
617
+ }
618
+ case "view": {
619
+ const C = components.view;
620
+ return <C key={item.key} item={item} ctx={ctx} />;
621
+ }
622
+ case "media": {
623
+ const C = components.media;
624
+ return <C key={item.key} item={item} ctx={ctx} />;
625
+ }
626
+ case "code": {
627
+ const C = components.code;
628
+ return <C key={item.key} item={item} ctx={ctx} />;
629
+ }
630
+ case "citations": {
631
+ const C = components.citations;
632
+ return <C key={item.key} item={item} ctx={ctx} />;
633
+ }
634
+ case "prompt": {
635
+ const C = components.prompt;
636
+ return <C key={item.key} item={item} ctx={ctx} />;
637
+ }
638
+ case "error": {
639
+ const C = components.error;
640
+ return <C key={item.key} item={item} ctx={ctx} />;
641
+ }
642
+ case "history-boundary": {
643
+ const C = components.history;
644
+ return <C key={item.key} item={item} ctx={ctx} />;
645
+ }
646
+ case "compaction": {
647
+ const C = components.compaction;
648
+ return <C key={item.key} item={item} ctx={ctx} />;
649
+ }
650
+ case "unknown": {
651
+ const C = components.unknown;
652
+ return <C key={item.key} item={item} ctx={ctx} />;
653
+ }
654
+ }
655
+ }