@guuey/chat 0.4.0 → 0.5.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.
@@ -0,0 +1,677 @@
1
+ /**
2
+ * The React Native per-category component kit (spec §3's override slots,
3
+ * §3.2's RN-parity obligation): one component per `DisplayItem` variant,
4
+ * each a THIN walk of its item — every rendering decision was already made
5
+ * by `planTranscript`; these translate decided items into RN primitives and
6
+ * never consult policy or invent copy. The override contract mirrors the
7
+ * web kit: `NativeTranscriptComponents` is the component map.
8
+ *
9
+ * Accessibility (spec §3.2 restated over RN):
10
+ * - every `expanded` toggle is a `Pressable` with `accessibilityRole`
11
+ * "button" + `accessibilityState.expanded`;
12
+ * - the status line and streaming text announce via
13
+ * `accessibilityLiveRegion="polite"` (Android) and are `accessible`
14
+ * grouped nodes for screen readers on both platforms;
15
+ * - R10 prompts are a live-region group so their appearance announces
16
+ * (RN has no document focus to move — the web kit's focus contract maps
17
+ * to announcement here);
18
+ * - there are NO decorative animations in the native defaults, so
19
+ * reduce-motion holds trivially at this tier (the transcript's scroll
20
+ * animation is where motion lives — see `transcript.tsx`).
21
+ *
22
+ * The R6 DEFAULT is the documented native default-gap: the kit ships
23
+ * WITHOUT a WebView dependency, so the default `view` renderer is a
24
+ * labeled placeholder (`strings.viewSandboxUnavailable`) — never blank —
25
+ * and a host app supplies its own WebView-based mount as the `view` slot
26
+ * override (portal's card machinery is the reference consumer).
27
+ */
28
+ import type { ComponentType, ReactNode } from "react";
29
+ import { Image, Pressable, Text, View } from "react-native";
30
+ import type { ResolvedViewMount, ViewHostPhase } from "@guuey/mcp-apps-host";
31
+ import type { ChatStrings } from "../strings.js";
32
+ import type {
33
+ CitationsItem,
34
+ CodeItem,
35
+ CompactionItem,
36
+ DataResultItem,
37
+ DisplayItem,
38
+ ErrorItem,
39
+ HistoryBoundaryItem,
40
+ ItemKey,
41
+ MediaItem,
42
+ PromptItem,
43
+ ReasoningItem,
44
+ StatusLineItem,
45
+ ToolGroupItem,
46
+ ToolItem,
47
+ UnknownItem,
48
+ UserMessageItem,
49
+ TextItem,
50
+ ViewMountItem,
51
+ } from "../types.js";
52
+ import { Linking } from "react-native";
53
+ import { NativeMarkdown } from "./markdown.js";
54
+ import type { NativeChatTokens } from "./theme-native.js";
55
+
56
+ /** Everything a rendered native item may need beyond itself. */
57
+ export interface NativeTranscriptItemContext {
58
+ strings: ChatStrings;
59
+ /** The resolved theme tokens (see `resolveNativeTheme`). */
60
+ tokens: NativeChatTokens;
61
+ /** Flip an item's collapse state (the renderer owns override state). */
62
+ onToggle: (key: ItemKey) => void;
63
+ /** R0 failed-send retry. */
64
+ onRetry?: (item: UserMessageItem) => void;
65
+ /** R10 prompt actions — the host owns what accept/decline DO. */
66
+ onPromptAction?: (item: PromptItem, action: "accept" | "decline" | "dismiss") => void;
67
+ /** R11 action slots (sign-in / retry affordances). */
68
+ onErrorAction?: (item: ErrorItem) => void;
69
+ /** R6: locator mounts resolved by `useTranscript` ("expired" = failed). */
70
+ resolvedMounts: ReadonlyMap<ItemKey, ResolvedViewMount | "expired">;
71
+ /** R6: live phase reports wired back into the next plan. */
72
+ onViewPhase: (key: ItemKey, phase: ViewHostPhase) => void;
73
+ }
74
+
75
+ interface ItemProps<T> {
76
+ item: T;
77
+ ctx: NativeTranscriptItemContext;
78
+ }
79
+
80
+ /** A collapse toggle + body pair with the RN accessibility plumbing. */
81
+ function Collapsible({
82
+ itemKey,
83
+ expanded,
84
+ header,
85
+ ctx,
86
+ children,
87
+ }: {
88
+ itemKey: ItemKey;
89
+ expanded: boolean;
90
+ header: ReactNode;
91
+ ctx: NativeTranscriptItemContext;
92
+ children: ReactNode;
93
+ }): ReactNode {
94
+ const { tokens } = ctx;
95
+ return (
96
+ <View
97
+ style={{
98
+ backgroundColor: tokens.palette.surface,
99
+ borderRadius: tokens.radius,
100
+ paddingHorizontal: tokens.pad,
101
+ paddingVertical: tokens.pad - 4,
102
+ }}
103
+ >
104
+ <Pressable
105
+ accessibilityRole="button"
106
+ accessibilityState={{ expanded }}
107
+ onPress={() => ctx.onToggle(itemKey)}
108
+ style={{ flexDirection: "row", alignItems: "center", gap: 6 }}
109
+ >
110
+ <View style={{ flexShrink: 1, flexDirection: "row", alignItems: "center", gap: 6 }}>
111
+ {header}
112
+ </View>
113
+ <Text
114
+ accessibilityElementsHidden
115
+ style={{ color: tokens.palette.inkMuted, fontSize: tokens.fontSize - 3 }}
116
+ >
117
+ {expanded ? "▾" : "▸"}
118
+ </Text>
119
+ </Pressable>
120
+ {expanded ? <View style={{ marginTop: 6, gap: 6 }}>{children}</View> : null}
121
+ </View>
122
+ );
123
+ }
124
+
125
+ function MutedText({ ctx, children }: { ctx: NativeTranscriptItemContext; children: ReactNode }): ReactNode {
126
+ const { tokens } = ctx;
127
+ return (
128
+ <Text
129
+ style={{
130
+ color: tokens.palette.inkMuted,
131
+ fontSize: tokens.fontSize - 2,
132
+ fontFamily: tokens.fontFamily,
133
+ }}
134
+ >
135
+ {children}
136
+ </Text>
137
+ );
138
+ }
139
+
140
+ // ─── R0 ────────────────────────────────────────────────────────────────────
141
+
142
+ export function NativeUserMessage({ item, ctx }: ItemProps<UserMessageItem>): ReactNode {
143
+ const { tokens } = ctx;
144
+ return (
145
+ <View style={{ alignItems: "flex-end", gap: 4, opacity: item.state === "sending" ? 0.6 : 1 }}>
146
+ <View
147
+ style={{
148
+ maxWidth: "82%",
149
+ backgroundColor: tokens.palette.ink,
150
+ borderRadius: tokens.radius,
151
+ paddingHorizontal: tokens.pad,
152
+ paddingVertical: tokens.pad - 2,
153
+ }}
154
+ >
155
+ {/* User text is QUOTED, never rendered — whitespace preserved. */}
156
+ <Text style={{ color: tokens.palette.canvas, fontSize: tokens.fontSize, fontFamily: tokens.fontFamily }}>
157
+ {item.text}
158
+ </Text>
159
+ </View>
160
+ {item.state === "failed" ? (
161
+ <View accessibilityLiveRegion="polite" style={{ flexDirection: "row", alignItems: "center", gap: 8 }}>
162
+ <Text style={{ color: tokens.palette.error, fontSize: tokens.fontSize - 2 }}>
163
+ {ctx.strings.userCouldntSend}
164
+ </Text>
165
+ {item.retry ? (
166
+ <Pressable accessibilityRole="button" onPress={() => ctx.onRetry?.(item)}>
167
+ <Text style={{ color: tokens.palette.accent, fontSize: tokens.fontSize - 2, fontWeight: "600" }}>
168
+ {ctx.strings.userRetry}
169
+ </Text>
170
+ </Pressable>
171
+ ) : null}
172
+ </View>
173
+ ) : null}
174
+ </View>
175
+ );
176
+ }
177
+
178
+ // ─── R1 ────────────────────────────────────────────────────────────────────
179
+
180
+ export function NativeText({ item, ctx }: ItemProps<TextItem>): ReactNode {
181
+ const { tokens } = ctx;
182
+ const caret = item.streaming ? (
183
+ <Text style={{ color: tokens.palette.accent }}>{" ▍"}</Text>
184
+ ) : undefined;
185
+ return (
186
+ <View
187
+ accessibilityLiveRegion={item.streaming ? "polite" : "none"}
188
+ style={{
189
+ alignSelf: "flex-start",
190
+ maxWidth: "92%",
191
+ backgroundColor: tokens.palette.surface,
192
+ borderRadius: tokens.radius,
193
+ paddingHorizontal: tokens.pad,
194
+ paddingVertical: tokens.pad - 2,
195
+ gap: 4,
196
+ }}
197
+ >
198
+ {item.markdown ? (
199
+ <NativeMarkdown text={item.text} color={tokens.palette.ink} tokens={tokens} trailing={caret} />
200
+ ) : (
201
+ <Text style={{ color: tokens.palette.ink, fontSize: tokens.fontSize, fontFamily: tokens.fontFamily }}>
202
+ {item.text}
203
+ {caret}
204
+ </Text>
205
+ )}
206
+ {item.stopped ? <MutedText ctx={ctx}>{ctx.strings.stopped}</MutedText> : null}
207
+ </View>
208
+ );
209
+ }
210
+
211
+ // ─── R2 ────────────────────────────────────────────────────────────────────
212
+
213
+ export function NativeReasoning({ item, ctx }: ItemProps<ReasoningItem>): ReactNode {
214
+ return (
215
+ <Collapsible itemKey={item.key} expanded={item.expanded} ctx={ctx} header={<MutedText ctx={ctx}>{item.label}</MutedText>}>
216
+ <MutedText ctx={ctx}>{item.text}</MutedText>
217
+ </Collapsible>
218
+ );
219
+ }
220
+
221
+ // ─── R5 ────────────────────────────────────────────────────────────────────
222
+
223
+ export function NativeDataResult({ item, ctx }: ItemProps<DataResultItem>): ReactNode {
224
+ const { tokens } = ctx;
225
+ const bytes = ctx.strings.bytes(item.byteCount);
226
+ if (item.state === "empty") return <MutedText ctx={ctx}>{ctx.strings.noOutput}</MutedText>;
227
+ if (item.preview === null) return <MutedText ctx={ctx}>{bytes}</MutedText>;
228
+ return (
229
+ <View style={{ gap: 4 }}>
230
+ <View
231
+ style={{
232
+ maxHeight: 220, // R5's scroll-cap projected to a fixed clamp (native has no rem)
233
+ overflow: "hidden",
234
+ backgroundColor: tokens.palette.canvasMuted,
235
+ borderRadius: tokens.radius,
236
+ padding: tokens.pad - 4,
237
+ }}
238
+ >
239
+ <Text style={{ color: tokens.palette.ink, fontSize: tokens.fontSize - 3, fontFamily: tokens.monoFontFamily }}>
240
+ {item.preview}
241
+ </Text>
242
+ </View>
243
+ {item.showBytes ? <MutedText ctx={ctx}>{bytes}</MutedText> : null}
244
+ </View>
245
+ );
246
+ }
247
+
248
+ // ─── R3 ────────────────────────────────────────────────────────────────────
249
+
250
+ const TOOL_GLYPH: Record<ToolItem["state"], string> = {
251
+ running: "◌",
252
+ done: "✓",
253
+ failed: "✕",
254
+ orphaned: "–",
255
+ };
256
+
257
+ export function NativeTool({ item, ctx }: ItemProps<ToolItem>): ReactNode {
258
+ // R4's display-bearing rule: in calm this call's line lives in its view
259
+ // row's chrome ("via {tool}") — rendering it here too would double it.
260
+ if (item.attribution) return null;
261
+ const { tokens } = ctx;
262
+ const failed = item.state === "failed";
263
+ const header = (
264
+ <>
265
+ <Text style={{ color: failed ? tokens.palette.error : tokens.palette.inkMuted, fontSize: tokens.fontSize - 2 }}>
266
+ {TOOL_GLYPH[item.state]}
267
+ </Text>
268
+ <Text
269
+ style={{
270
+ color: failed ? tokens.palette.error : tokens.palette.inkMuted,
271
+ fontSize: tokens.fontSize - 2,
272
+ fontFamily: tokens.fontFamily,
273
+ flexShrink: 1,
274
+ }}
275
+ >
276
+ {item.title}
277
+ {item.state === "orphaned" ? ` — ${ctx.strings.toolDidntFinish}` : ""}
278
+ </Text>
279
+ </>
280
+ );
281
+ const expandable = item.argsPreview !== null || item.result !== null;
282
+ if (!expandable) {
283
+ return <View style={{ flexDirection: "row", alignItems: "center", gap: 6, paddingHorizontal: ctx.tokens.pad }}>{header}</View>;
284
+ }
285
+ return (
286
+ <Collapsible itemKey={item.key} expanded={item.expanded} ctx={ctx} header={header}>
287
+ {item.argsPreview !== null ? (
288
+ <Text style={{ color: tokens.palette.inkMuted, fontSize: tokens.fontSize - 3, fontFamily: tokens.monoFontFamily }}>
289
+ {item.argsPreview}
290
+ </Text>
291
+ ) : null}
292
+ {item.result !== null ? <NativeDataResult item={item.result} ctx={ctx} /> : null}
293
+ </Collapsible>
294
+ );
295
+ }
296
+
297
+ // ─── R4 ────────────────────────────────────────────────────────────────────
298
+
299
+ export function NativeToolGroup({ item, ctx }: ItemProps<ToolGroupItem>): ReactNode {
300
+ const { tokens } = ctx;
301
+ return (
302
+ <Collapsible
303
+ itemKey={item.key}
304
+ expanded={item.expanded}
305
+ ctx={ctx}
306
+ header={
307
+ <>
308
+ <MutedText ctx={ctx}>{item.label}</MutedText>
309
+ {item.failureBadge !== null ? (
310
+ <Text style={{ color: tokens.palette.error, fontSize: tokens.fontSize - 3 }}>{item.failureBadge}</Text>
311
+ ) : null}
312
+ </>
313
+ }
314
+ >
315
+ {item.tools.map((tool) => (
316
+ <NativeTool key={tool.key} item={tool} ctx={ctx} />
317
+ ))}
318
+ </Collapsible>
319
+ );
320
+ }
321
+
322
+ // ─── R6 — the documented native default-gap ────────────────────────────────
323
+
324
+ export function NativeView({ item, ctx }: ItemProps<ViewMountItem>): ReactNode {
325
+ const { tokens } = ctx;
326
+ // The kit's native tier ships without a WebView dependency, so there is
327
+ // no default mount host — the `view` slot is a REQUIRED override on
328
+ // native (labeled here, never blank; portal's card machinery is the
329
+ // reference override). The expired/labeled states still render honestly.
330
+ const label =
331
+ item.phase === "expired"
332
+ ? ctx.strings.viewExpired
333
+ : (item.label ?? ctx.strings.viewSandboxUnavailable);
334
+ return (
335
+ <View
336
+ accessibilityRole="summary"
337
+ style={{
338
+ backgroundColor: tokens.palette.canvasMuted,
339
+ borderRadius: tokens.radius,
340
+ padding: tokens.pad,
341
+ gap: 4,
342
+ }}
343
+ >
344
+ <MutedText ctx={ctx}>{label}</MutedText>
345
+ {item.attribution !== null ? <MutedText ctx={ctx}>{item.attribution}</MutedText> : null}
346
+ </View>
347
+ );
348
+ }
349
+
350
+ // ─── R7 ────────────────────────────────────────────────────────────────────
351
+
352
+ /** Inline images allow https URLs and image/* base64 data — nothing else. */
353
+ function imageSrc(item: MediaItem): string | null {
354
+ const source = item.source;
355
+ if (source.type === "url" && /^https:\/\//i.test(source.url)) return source.url;
356
+ if (source.type === "base64" && /^image\//.test(source.mediaType)) {
357
+ return `data:${source.mediaType};base64,${source.data}`;
358
+ }
359
+ return null;
360
+ }
361
+
362
+ export function NativeMedia({ item, ctx }: ItemProps<MediaItem>): ReactNode {
363
+ const { tokens } = ctx;
364
+ if (item.presentation === "inline" && item.media === "image") {
365
+ const src = imageSrc(item);
366
+ if (src !== null) {
367
+ return (
368
+ <Image
369
+ accessibilityLabel={item.name ?? ""}
370
+ source={{ uri: src }}
371
+ resizeMode="contain"
372
+ style={{ width: "100%", height: 220, borderRadius: tokens.radius, backgroundColor: tokens.palette.canvasMuted }}
373
+ />
374
+ );
375
+ }
376
+ }
377
+ // Attachment chip: files, documents, audio (RN has no default audio
378
+ // element — a chip is the honest default), unloadable/oversized media.
379
+ return (
380
+ <View
381
+ style={{
382
+ alignSelf: "flex-start",
383
+ backgroundColor: tokens.palette.canvasMuted,
384
+ borderRadius: tokens.radius,
385
+ paddingHorizontal: tokens.pad,
386
+ paddingVertical: 4,
387
+ }}
388
+ >
389
+ <MutedText ctx={ctx}>{item.name ?? item.media}</MutedText>
390
+ </View>
391
+ );
392
+ }
393
+
394
+ // ─── R8 ────────────────────────────────────────────────────────────────────
395
+
396
+ export function NativeCode({ item, ctx }: ItemProps<CodeItem>): ReactNode {
397
+ const { tokens } = ctx;
398
+ return (
399
+ <View style={{ backgroundColor: tokens.palette.canvasMuted, borderRadius: tokens.radius, padding: tokens.pad - 2, gap: 4 }}>
400
+ <MutedText ctx={ctx}>{item.language}</MutedText>
401
+ <Text style={{ color: tokens.palette.ink, fontSize: tokens.fontSize - 3, fontFamily: tokens.monoFontFamily }}>
402
+ {item.code}
403
+ </Text>
404
+ </View>
405
+ );
406
+ }
407
+
408
+ // ─── R9 ────────────────────────────────────────────────────────────────────
409
+
410
+ /** Citation links navigate only to http(s) targets. */
411
+ function safeCitationUrl(url: string | null): string | null {
412
+ return url !== null && /^https?:\/\//i.test(url) ? url : null;
413
+ }
414
+
415
+ export function NativeCitations({ item, ctx }: ItemProps<CitationsItem>): ReactNode {
416
+ const { tokens } = ctx;
417
+ return (
418
+ <Collapsible itemKey={item.key} expanded={item.expanded} ctx={ctx} header={<MutedText ctx={ctx}>{item.label}</MutedText>}>
419
+ {item.sources.map((source, i) => {
420
+ const url = safeCitationUrl(source.url);
421
+ const label = source.title ?? source.url ?? "";
422
+ if (url === null) return <MutedText key={i} ctx={ctx}>{label}</MutedText>;
423
+ return (
424
+ <Pressable
425
+ key={i}
426
+ accessibilityRole="link"
427
+ onPress={() => {
428
+ void Linking.openURL(url).catch(() => {});
429
+ }}
430
+ >
431
+ <Text style={{ color: tokens.palette.accent, fontSize: tokens.fontSize - 2, textDecorationLine: "underline" }}>
432
+ {label}
433
+ </Text>
434
+ </Pressable>
435
+ );
436
+ })}
437
+ </Collapsible>
438
+ );
439
+ }
440
+
441
+ // ─── R10 ───────────────────────────────────────────────────────────────────
442
+
443
+ export function NativePrompt({ item, ctx }: ItemProps<PromptItem>): ReactNode {
444
+ const { tokens } = ctx;
445
+ if (item.state !== "pending") {
446
+ return (
447
+ <MutedText ctx={ctx}>
448
+ {item.promptKind}: {item.state}
449
+ </MutedText>
450
+ );
451
+ }
452
+ return (
453
+ <View
454
+ // RN has no document focus to move; the live region announces the ask
455
+ // (the web kit's focus contract, restated over the platform).
456
+ accessibilityLiveRegion="polite"
457
+ accessible
458
+ style={{ backgroundColor: tokens.palette.surface, borderRadius: tokens.radius, padding: tokens.pad, gap: 8 }}
459
+ >
460
+ <Text style={{ color: tokens.palette.ink, fontSize: tokens.fontSize, fontFamily: tokens.fontFamily }}>
461
+ {item.promptKind === "consent"
462
+ ? `${item.appId} requests ${item.requested} access`
463
+ : `Link your account to ${item.appId}`}
464
+ </Text>
465
+ <View style={{ flexDirection: "row", gap: 12 }}>
466
+ <Pressable accessibilityRole="button" onPress={() => ctx.onPromptAction?.(item, "accept")}>
467
+ <Text style={{ color: tokens.palette.accent, fontWeight: "700", fontSize: tokens.fontSize }}>Allow</Text>
468
+ </Pressable>
469
+ <Pressable accessibilityRole="button" onPress={() => ctx.onPromptAction?.(item, "decline")}>
470
+ <Text style={{ color: tokens.palette.inkMuted, fontSize: tokens.fontSize }}>Decline</Text>
471
+ </Pressable>
472
+ </View>
473
+ </View>
474
+ );
475
+ }
476
+
477
+ // ─── R11 ───────────────────────────────────────────────────────────────────
478
+
479
+ export function NativeError({ item, ctx }: ItemProps<ErrorItem>): ReactNode {
480
+ const { tokens } = ctx;
481
+ return (
482
+ <View
483
+ accessibilityLiveRegion="assertive"
484
+ accessible
485
+ style={{
486
+ backgroundColor: tokens.palette.surface,
487
+ borderLeftWidth: 3,
488
+ borderLeftColor: tokens.palette.error,
489
+ borderRadius: tokens.radius,
490
+ padding: tokens.pad,
491
+ gap: 6,
492
+ }}
493
+ >
494
+ <Text style={{ color: tokens.palette.ink, fontSize: tokens.fontSize - 1, fontFamily: tokens.fontFamily }}>
495
+ {item.copy}
496
+ </Text>
497
+ {ctx.onErrorAction !== undefined && (item.family === "transient" || item.family === "auth") ? (
498
+ <Pressable accessibilityRole="button" onPress={() => ctx.onErrorAction?.(item)}>
499
+ <Text style={{ color: tokens.palette.accent, fontSize: tokens.fontSize - 1, fontWeight: "600" }}>
500
+ {item.family === "auth" ? ctx.strings.errorAuth : ctx.strings.userRetry}
501
+ </Text>
502
+ </Pressable>
503
+ ) : null}
504
+ {item.verbatim !== null ? (
505
+ <Text style={{ color: tokens.palette.inkMuted, fontSize: tokens.fontSize - 3, fontFamily: tokens.monoFontFamily }}>
506
+ {item.verbatim}
507
+ </Text>
508
+ ) : null}
509
+ </View>
510
+ );
511
+ }
512
+
513
+ // ─── R13 / R14 / R15 ───────────────────────────────────────────────────────
514
+
515
+ export function NativeHistoryBoundary({ item, ctx }: ItemProps<HistoryBoundaryItem>): ReactNode {
516
+ return (
517
+ <View accessibilityLiveRegion="polite" style={{ alignItems: "center", paddingVertical: 4 }}>
518
+ <MutedText ctx={ctx}>{item.label}</MutedText>
519
+ </View>
520
+ );
521
+ }
522
+
523
+ export function NativeCompaction({ item, ctx }: ItemProps<CompactionItem>): ReactNode {
524
+ return (
525
+ <View style={{ alignItems: "center", paddingVertical: 4 }}>
526
+ <MutedText ctx={ctx}>{item.label}</MutedText>
527
+ </View>
528
+ );
529
+ }
530
+
531
+ export function NativeUnknown({ item, ctx }: ItemProps<UnknownItem>): ReactNode {
532
+ return (
533
+ <Collapsible
534
+ itemKey={item.key}
535
+ expanded={item.expanded}
536
+ ctx={ctx}
537
+ header={
538
+ <MutedText ctx={ctx}>
539
+ {item.label} ({item.typeName})
540
+ </MutedText>
541
+ }
542
+ >
543
+ {item.raw !== null ? (
544
+ <Text style={{ color: ctx.tokens.palette.inkMuted, fontSize: ctx.tokens.fontSize - 3, fontFamily: ctx.tokens.monoFontFamily }}>
545
+ {JSON.stringify(item.raw, null, 2)}
546
+ </Text>
547
+ ) : (
548
+ <MutedText ctx={ctx}>{ctx.strings.bytes(item.byteSize)}</MutedText>
549
+ )}
550
+ </Collapsible>
551
+ );
552
+ }
553
+
554
+ // ─── R12 / §4 ──────────────────────────────────────────────────────────────
555
+
556
+ export function NativeStatus({ item, ctx }: ItemProps<StatusLineItem>): ReactNode {
557
+ const { tokens } = ctx;
558
+ return (
559
+ <View accessibilityLiveRegion="polite" accessible style={{ paddingHorizontal: tokens.pad, paddingVertical: 2 }}>
560
+ <MutedText ctx={ctx}>
561
+ {item.copy}
562
+ {item.detail !== null ? ` · ${item.detail}` : ""}
563
+ </MutedText>
564
+ </View>
565
+ );
566
+ }
567
+
568
+ // ─── The component map ─────────────────────────────────────────────────────
569
+
570
+ /** One component per §3 override slot — the native mirror of the web map. */
571
+ export interface NativeTranscriptComponents {
572
+ userMessage: ComponentType<ItemProps<UserMessageItem>>;
573
+ text: ComponentType<ItemProps<TextItem>>;
574
+ reasoning: ComponentType<ItemProps<ReasoningItem>>;
575
+ tool: ComponentType<ItemProps<ToolItem>>;
576
+ toolGroup: ComponentType<ItemProps<ToolGroupItem>>;
577
+ dataResult: ComponentType<ItemProps<DataResultItem>>;
578
+ view: ComponentType<ItemProps<ViewMountItem>>;
579
+ media: ComponentType<ItemProps<MediaItem>>;
580
+ code: ComponentType<ItemProps<CodeItem>>;
581
+ citations: ComponentType<ItemProps<CitationsItem>>;
582
+ prompt: ComponentType<ItemProps<PromptItem>>;
583
+ error: ComponentType<ItemProps<ErrorItem>>;
584
+ history: ComponentType<ItemProps<HistoryBoundaryItem>>;
585
+ compaction: ComponentType<ItemProps<CompactionItem>>;
586
+ unknown: ComponentType<ItemProps<UnknownItem>>;
587
+ status: ComponentType<ItemProps<StatusLineItem>>;
588
+ }
589
+
590
+ export const nativeTranscriptComponents: NativeTranscriptComponents = {
591
+ userMessage: NativeUserMessage,
592
+ text: NativeText,
593
+ reasoning: NativeReasoning,
594
+ tool: NativeTool,
595
+ toolGroup: NativeToolGroup,
596
+ dataResult: NativeDataResult,
597
+ view: NativeView,
598
+ media: NativeMedia,
599
+ code: NativeCode,
600
+ citations: NativeCitations,
601
+ prompt: NativePrompt,
602
+ error: NativeError,
603
+ history: NativeHistoryBoundary,
604
+ compaction: NativeCompaction,
605
+ unknown: NativeUnknown,
606
+ status: NativeStatus,
607
+ };
608
+
609
+ /** Dispatch one display item through the (possibly overridden) map. */
610
+ export function renderNativeItem(
611
+ item: DisplayItem,
612
+ components: NativeTranscriptComponents,
613
+ ctx: NativeTranscriptItemContext,
614
+ ): ReactNode {
615
+ switch (item.kind) {
616
+ case "user": {
617
+ const C = components.userMessage;
618
+ return <C key={item.key} item={item} ctx={ctx} />;
619
+ }
620
+ case "text": {
621
+ const C = components.text;
622
+ return <C key={item.key} item={item} ctx={ctx} />;
623
+ }
624
+ case "reasoning": {
625
+ const C = components.reasoning;
626
+ return <C key={item.key} item={item} ctx={ctx} />;
627
+ }
628
+ case "tool": {
629
+ const C = components.tool;
630
+ return <C key={item.key} item={item} ctx={ctx} />;
631
+ }
632
+ case "tool-group": {
633
+ const C = components.toolGroup;
634
+ return <C key={item.key} item={item} ctx={ctx} />;
635
+ }
636
+ case "data-result": {
637
+ const C = components.dataResult;
638
+ return <C key={item.key} item={item} ctx={ctx} />;
639
+ }
640
+ case "view": {
641
+ const C = components.view;
642
+ return <C key={item.key} item={item} ctx={ctx} />;
643
+ }
644
+ case "media": {
645
+ const C = components.media;
646
+ return <C key={item.key} item={item} ctx={ctx} />;
647
+ }
648
+ case "code": {
649
+ const C = components.code;
650
+ return <C key={item.key} item={item} ctx={ctx} />;
651
+ }
652
+ case "citations": {
653
+ const C = components.citations;
654
+ return <C key={item.key} item={item} ctx={ctx} />;
655
+ }
656
+ case "prompt": {
657
+ const C = components.prompt;
658
+ return <C key={item.key} item={item} ctx={ctx} />;
659
+ }
660
+ case "error": {
661
+ const C = components.error;
662
+ return <C key={item.key} item={item} ctx={ctx} />;
663
+ }
664
+ case "history-boundary": {
665
+ const C = components.history;
666
+ return <C key={item.key} item={item} ctx={ctx} />;
667
+ }
668
+ case "compaction": {
669
+ const C = components.compaction;
670
+ return <C key={item.key} item={item} ctx={ctx} />;
671
+ }
672
+ case "unknown": {
673
+ const C = components.unknown;
674
+ return <C key={item.key} item={item} ctx={ctx} />;
675
+ }
676
+ }
677
+ }