@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.
- package/dist/native/components.d.ts +94 -0
- package/dist/native/components.d.ts.map +1 -0
- package/dist/native/components.js +292 -0
- package/dist/native/markdown.d.ts +31 -0
- package/dist/native/markdown.d.ts.map +1 -0
- package/dist/native/markdown.js +69 -0
- package/dist/native/theme-native.d.ts +27 -0
- package/dist/native/theme-native.d.ts.map +1 -0
- package/dist/native/theme-native.js +28 -0
- package/dist/native/transcript.d.ts +48 -0
- package/dist/native/transcript.d.ts.map +1 -0
- package/dist/native/transcript.js +86 -0
- package/dist/native.d.ts +26 -0
- package/dist/native.d.ts.map +1 -0
- package/dist/native.js +27 -0
- package/package.json +16 -5
- package/src/native/components.tsx +677 -0
- package/src/native/markdown.tsx +205 -0
- package/src/native/theme-native.ts +48 -0
- package/src/native/transcript.tsx +188 -0
- package/src/native.tsx +62 -0
|
@@ -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
|
+
}
|