@guuey/chat 0.4.0 → 0.6.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/hitl.d.ts +58 -0
- package/dist/hitl.d.ts.map +1 -0
- package/dist/hitl.js +81 -0
- package/dist/index.d.ts +3 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -1
- package/dist/native/components.d.ts +110 -0
- package/dist/native/components.d.ts.map +1 -0
- package/dist/native/components.js +341 -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/dist/plan.d.ts +15 -0
- package/dist/plan.d.ts.map +1 -1
- package/dist/plan.js +211 -13
- package/dist/policy.d.ts +4 -0
- package/dist/policy.d.ts.map +1 -1
- package/dist/policy.js +2 -0
- package/dist/react/components.d.ts +42 -5
- package/dist/react/components.d.ts.map +1 -1
- package/dist/react/components.js +51 -1
- package/dist/react/guuey-chat.d.ts +62 -4
- package/dist/react/guuey-chat.d.ts.map +1 -1
- package/dist/react/guuey-chat.js +73 -8
- package/dist/react/transcript.d.ts +1 -1
- package/dist/react/transcript.d.ts.map +1 -1
- package/dist/react/transcript.js +2 -2
- package/dist/react/use-transcript.d.ts +21 -2
- package/dist/react/use-transcript.d.ts.map +1 -1
- package/dist/react/use-transcript.js +57 -3
- package/dist/react.d.ts +2 -2
- package/dist/react.d.ts.map +1 -1
- package/dist/react.js +1 -1
- package/dist/strings.d.ts +13 -0
- package/dist/strings.d.ts.map +1 -1
- package/dist/strings.js +8 -0
- package/dist/types.d.ts +146 -7
- package/dist/types.d.ts.map +1 -1
- package/package.json +18 -7
- package/src/corpus/README.md +13 -1
- package/src/corpus/__snapshots__/corpus.test.ts.snap +304 -0
- package/src/corpus/drive.ts +3 -3
- package/src/corpus/fixtures.ts +220 -1
- package/src/hitl.ts +103 -0
- package/src/index.ts +15 -0
- package/src/native/components.tsx +812 -0
- package/src/native/markdown.tsx +205 -0
- package/src/native/theme-native.ts +48 -0
- package/src/native/transcript.tsx +189 -0
- package/src/native.tsx +63 -0
- package/src/plan.ts +233 -20
- package/src/policy.ts +4 -0
- package/src/react/components.tsx +171 -8
- package/src/react/guuey-chat.tsx +143 -9
- package/src/react/transcript.tsx +4 -3
- package/src/react/use-transcript.ts +83 -5
- package/src/react.tsx +3 -1
- package/src/strings.ts +25 -0
- package/src/types.ts +152 -6
- package/styles.css +21 -0
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The R1 markdown surface, React Native projection.
|
|
3
|
+
*
|
|
4
|
+
* Same sanitizer boundary as the web kit (`react/markdown.tsx`), same
|
|
5
|
+
* pipeline: `@silverprotocol/richtext` parses markdown into a TYPED AST in
|
|
6
|
+
* which raw HTML is structurally unrepresentable — `<script>` in model
|
|
7
|
+
* output can only ever be literal text, there is no image node type (the
|
|
8
|
+
* spec's F5 ruling holds structurally), and link `href` is populated
|
|
9
|
+
* upstream only for http/https/mailto (`SAFE_HREF`). This module is pure
|
|
10
|
+
* presentation: AST node → themed RN elements. Navigable links open
|
|
11
|
+
* through `Linking` — the href is already allowlist-gated upstream, so no
|
|
12
|
+
* second gate is re-derived here.
|
|
13
|
+
*
|
|
14
|
+
* Streaming-tolerant by upstream design: an unclosed `**bol` renders as
|
|
15
|
+
* bold-so-far and completes in place as deltas arrive.
|
|
16
|
+
*
|
|
17
|
+
* Prior art: portal's `MarkdownText` (guuey#95) — this is its published,
|
|
18
|
+
* token-driven descendant (colors/fonts come from the kit's resolved
|
|
19
|
+
* `NativeChatTokens`, not an app theme context).
|
|
20
|
+
*/
|
|
21
|
+
import type { ReactNode } from "react";
|
|
22
|
+
import { Linking, Text, View } from "react-native";
|
|
23
|
+
import {
|
|
24
|
+
parseRichText,
|
|
25
|
+
type RichTextBlock,
|
|
26
|
+
type RichTextInline,
|
|
27
|
+
} from "@silverprotocol/richtext";
|
|
28
|
+
import type { NativeChatTokens } from "./theme-native.js";
|
|
29
|
+
|
|
30
|
+
function InlineRuns({
|
|
31
|
+
nodes,
|
|
32
|
+
color,
|
|
33
|
+
tokens,
|
|
34
|
+
bold,
|
|
35
|
+
italic,
|
|
36
|
+
}: {
|
|
37
|
+
nodes: RichTextInline[];
|
|
38
|
+
color: string;
|
|
39
|
+
tokens: NativeChatTokens;
|
|
40
|
+
bold?: boolean;
|
|
41
|
+
italic?: boolean;
|
|
42
|
+
}): ReactNode {
|
|
43
|
+
return nodes.map((node, i) => {
|
|
44
|
+
const baseStyle = {
|
|
45
|
+
color,
|
|
46
|
+
fontSize: tokens.fontSize,
|
|
47
|
+
fontFamily: tokens.fontFamily,
|
|
48
|
+
fontWeight: bold ? ("700" as const) : ("400" as const),
|
|
49
|
+
fontStyle: italic ? ("italic" as const) : ("normal" as const),
|
|
50
|
+
};
|
|
51
|
+
switch (node.type) {
|
|
52
|
+
case "text":
|
|
53
|
+
return (
|
|
54
|
+
<Text key={i} style={baseStyle}>
|
|
55
|
+
{node.text}
|
|
56
|
+
</Text>
|
|
57
|
+
);
|
|
58
|
+
case "break":
|
|
59
|
+
return <Text key={i}>{"\n"}</Text>;
|
|
60
|
+
case "strong":
|
|
61
|
+
return (
|
|
62
|
+
<InlineRuns key={i} nodes={node.children} color={color} tokens={tokens} bold italic={italic} />
|
|
63
|
+
);
|
|
64
|
+
case "em":
|
|
65
|
+
return (
|
|
66
|
+
<InlineRuns key={i} nodes={node.children} color={color} tokens={tokens} bold={bold} italic />
|
|
67
|
+
);
|
|
68
|
+
case "code":
|
|
69
|
+
return (
|
|
70
|
+
<Text
|
|
71
|
+
key={i}
|
|
72
|
+
style={{
|
|
73
|
+
color,
|
|
74
|
+
fontSize: tokens.fontSize - 1,
|
|
75
|
+
fontFamily: tokens.monoFontFamily,
|
|
76
|
+
backgroundColor: tokens.palette.canvasMuted,
|
|
77
|
+
}}
|
|
78
|
+
>
|
|
79
|
+
{node.code}
|
|
80
|
+
</Text>
|
|
81
|
+
);
|
|
82
|
+
case "link": {
|
|
83
|
+
const href = node.href;
|
|
84
|
+
if (href === undefined) {
|
|
85
|
+
// Unsafe or unresolvable target: the styled text, no press target.
|
|
86
|
+
return (
|
|
87
|
+
<Text key={i} style={{ ...baseStyle, textDecorationLine: "underline" }}>
|
|
88
|
+
<InlineRuns nodes={node.children} color={color} tokens={tokens} bold={bold} italic={italic} />
|
|
89
|
+
</Text>
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
return (
|
|
93
|
+
<Text
|
|
94
|
+
key={i}
|
|
95
|
+
accessibilityRole="link"
|
|
96
|
+
style={{ ...baseStyle, color: tokens.palette.accent, textDecorationLine: "underline" }}
|
|
97
|
+
onPress={() => {
|
|
98
|
+
void Linking.openURL(href).catch(() => {
|
|
99
|
+
// An unopenable-but-safe URL is a platform condition, not an error surface.
|
|
100
|
+
});
|
|
101
|
+
}}
|
|
102
|
+
>
|
|
103
|
+
<InlineRuns nodes={node.children} color={color} tokens={tokens} bold={bold} italic={italic} />
|
|
104
|
+
</Text>
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function BlockView({
|
|
112
|
+
block,
|
|
113
|
+
color,
|
|
114
|
+
tokens,
|
|
115
|
+
trailing,
|
|
116
|
+
}: {
|
|
117
|
+
block: RichTextBlock;
|
|
118
|
+
color: string;
|
|
119
|
+
tokens: NativeChatTokens;
|
|
120
|
+
trailing?: ReactNode;
|
|
121
|
+
}): ReactNode {
|
|
122
|
+
switch (block.type) {
|
|
123
|
+
case "paragraph":
|
|
124
|
+
return (
|
|
125
|
+
<Text style={{ fontSize: tokens.fontSize, lineHeight: Math.round(tokens.fontSize * 1.45) }}>
|
|
126
|
+
<InlineRuns nodes={block.children} color={color} tokens={tokens} />
|
|
127
|
+
{trailing}
|
|
128
|
+
</Text>
|
|
129
|
+
);
|
|
130
|
+
case "heading":
|
|
131
|
+
return (
|
|
132
|
+
<Text
|
|
133
|
+
style={{
|
|
134
|
+
color,
|
|
135
|
+
fontSize: tokens.fontSize + (block.level <= 3 ? 2 : 0),
|
|
136
|
+
fontFamily: tokens.fontFamily,
|
|
137
|
+
fontWeight: "700",
|
|
138
|
+
}}
|
|
139
|
+
>
|
|
140
|
+
<InlineRuns nodes={block.children} color={color} tokens={tokens} bold />
|
|
141
|
+
{trailing}
|
|
142
|
+
</Text>
|
|
143
|
+
);
|
|
144
|
+
case "code-fence":
|
|
145
|
+
return (
|
|
146
|
+
<View
|
|
147
|
+
style={{
|
|
148
|
+
backgroundColor: tokens.palette.canvasMuted,
|
|
149
|
+
borderRadius: tokens.radius,
|
|
150
|
+
padding: tokens.pad,
|
|
151
|
+
}}
|
|
152
|
+
>
|
|
153
|
+
<Text style={{ color, fontSize: tokens.fontSize - 2, fontFamily: tokens.monoFontFamily }}>
|
|
154
|
+
{block.code}
|
|
155
|
+
</Text>
|
|
156
|
+
{trailing}
|
|
157
|
+
</View>
|
|
158
|
+
);
|
|
159
|
+
case "list":
|
|
160
|
+
return (
|
|
161
|
+
<View style={{ gap: 2 }}>
|
|
162
|
+
{block.items.map((item, j) => (
|
|
163
|
+
<View key={j} style={{ flexDirection: "row" }}>
|
|
164
|
+
<Text style={{ color, fontSize: tokens.fontSize, fontFamily: tokens.fontFamily }}>
|
|
165
|
+
{block.ordered ? `${(block.start ?? 1) + j}. ` : "• "}
|
|
166
|
+
</Text>
|
|
167
|
+
<Text style={{ flexShrink: 1, fontSize: tokens.fontSize, lineHeight: Math.round(tokens.fontSize * 1.45) }}>
|
|
168
|
+
<InlineRuns nodes={item.children} color={color} tokens={tokens} />
|
|
169
|
+
{j === block.items.length - 1 ? trailing : null}
|
|
170
|
+
</Text>
|
|
171
|
+
</View>
|
|
172
|
+
))}
|
|
173
|
+
</View>
|
|
174
|
+
);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** Sanitized markdown → themed RN elements (see the module docblock). */
|
|
179
|
+
export function NativeMarkdown({
|
|
180
|
+
text,
|
|
181
|
+
color,
|
|
182
|
+
tokens,
|
|
183
|
+
trailing,
|
|
184
|
+
}: {
|
|
185
|
+
text: string;
|
|
186
|
+
color: string;
|
|
187
|
+
tokens: NativeChatTokens;
|
|
188
|
+
/** Appended inside the LAST block so a streaming caret hugs the text. */
|
|
189
|
+
trailing?: ReactNode;
|
|
190
|
+
}): ReactNode {
|
|
191
|
+
const blocks = parseRichText(text);
|
|
192
|
+
return (
|
|
193
|
+
<View style={{ gap: 6 }}>
|
|
194
|
+
{blocks.map((block, i) => (
|
|
195
|
+
<BlockView
|
|
196
|
+
key={i}
|
|
197
|
+
block={block}
|
|
198
|
+
color={color}
|
|
199
|
+
tokens={tokens}
|
|
200
|
+
trailing={i === blocks.length - 1 ? trailing : undefined}
|
|
201
|
+
/>
|
|
202
|
+
))}
|
|
203
|
+
</View>
|
|
204
|
+
);
|
|
205
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The React Native theme projection (spec §6): `GuueyChatTheme` is the
|
|
3
|
+
* cross-platform contract; CSS custom properties are the WEB projection
|
|
4
|
+
* (`react/theme-css.ts`), and this module is the native one — the same
|
|
5
|
+
* schema resolved into a flat token object RN components read as style
|
|
6
|
+
* values. No new vocabulary: every field here derives from the schema.
|
|
7
|
+
*/
|
|
8
|
+
import { resolveTheme, type GuueyChatPalette, type GuueyChatTheme } from "../theme.js";
|
|
9
|
+
|
|
10
|
+
export type NativeThemeMode = "light" | "dark";
|
|
11
|
+
|
|
12
|
+
/** The resolved style tokens the native components consume. */
|
|
13
|
+
export interface NativeChatTokens {
|
|
14
|
+
/** The mode-resolved palette (per-token fallback already applied). */
|
|
15
|
+
palette: GuueyChatPalette;
|
|
16
|
+
/** shape.radius → px: none 0, soft 10, round 18. */
|
|
17
|
+
radius: number;
|
|
18
|
+
/** shape.density → the bubble padding / row gap pair. */
|
|
19
|
+
pad: number;
|
|
20
|
+
gap: number;
|
|
21
|
+
/** typography — undefined means the platform default font. */
|
|
22
|
+
fontFamily: string | undefined;
|
|
23
|
+
monoFontFamily: string | undefined;
|
|
24
|
+
/** Base body size in sp, typography.scale applied (default 15). */
|
|
25
|
+
fontSize: number;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const RADIUS: Record<GuueyChatTheme["shape"]["radius"], number> = {
|
|
29
|
+
none: 0,
|
|
30
|
+
soft: 10,
|
|
31
|
+
round: 18,
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
/** Resolve a (possibly partial/stale) theme + mode into native tokens. */
|
|
35
|
+
export function resolveNativeTheme(theme: GuueyChatTheme, mode: NativeThemeMode): NativeChatTokens {
|
|
36
|
+
const resolved = resolveTheme(theme);
|
|
37
|
+
const palette = resolved.colors[mode];
|
|
38
|
+
const comfortable = resolved.shape.density === "comfortable";
|
|
39
|
+
return {
|
|
40
|
+
palette,
|
|
41
|
+
radius: RADIUS[resolved.shape.radius],
|
|
42
|
+
pad: comfortable ? 12 : 8,
|
|
43
|
+
gap: comfortable ? 10 : 6,
|
|
44
|
+
fontFamily: resolved.typography.fontFamily,
|
|
45
|
+
monoFontFamily: resolved.typography.monoFontFamily,
|
|
46
|
+
fontSize: Math.round(15 * (resolved.typography.scale ?? 1)),
|
|
47
|
+
};
|
|
48
|
+
}
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `<NativeTranscript>` — the plan walker + the §3.2 renderer obligations,
|
|
3
|
+
* restated over React Native's own mechanics:
|
|
4
|
+
*
|
|
5
|
+
* - **Scroll contract, the inverted way.** The list is an INVERTED
|
|
6
|
+
* FlatList over the reversed plan (the chat-app contract portal's
|
|
7
|
+
* agent-chat screen ratified — guuey#94/#100): content grows at the
|
|
8
|
+
* scroll ORIGIN, so "stick to bottom while streaming" and "never shift
|
|
9
|
+
* the viewport while the reader is up in history" hold by construction
|
|
10
|
+
* — no scrollTo calls during streaming, no re-pin race with content
|
|
11
|
+
* resize (an R6 card growing at the origin cannot move the reader's
|
|
12
|
+
* viewport). "Release on scroll-up" is likewise structural; the kit
|
|
13
|
+
* adds the jump-to-latest affordance when the reader has left the
|
|
14
|
+
* newest turn, and its scroll animation respects the platform
|
|
15
|
+
* reduce-motion setting.
|
|
16
|
+
* - **Windowing** is the list primitive's own virtualization — FlatList
|
|
17
|
+
* windows the DOM-equivalent natively; the plan's stable keys are the
|
|
18
|
+
* `keyExtractor`, so item identity survives streaming updates.
|
|
19
|
+
* - **Accessibility** lives on the item components (`components.tsx`).
|
|
20
|
+
*
|
|
21
|
+
* Platform chrome (keyboard insets, dismiss modes, dock scroll policies)
|
|
22
|
+
* stays HOST-OWNED: `listProps` passes the host's FlatList behavior
|
|
23
|
+
* through, with the kit composing `onScroll` so both the host's policy and
|
|
24
|
+
* the jump affordance see every event. The kit owns data/renderItem/keys/
|
|
25
|
+
* inversion — a host overriding those would be fighting the plan.
|
|
26
|
+
*/
|
|
27
|
+
import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
|
|
28
|
+
import {
|
|
29
|
+
AccessibilityInfo,
|
|
30
|
+
FlatList,
|
|
31
|
+
Pressable,
|
|
32
|
+
Text,
|
|
33
|
+
View,
|
|
34
|
+
type FlatListProps,
|
|
35
|
+
type NativeScrollEvent,
|
|
36
|
+
type NativeSyntheticEvent,
|
|
37
|
+
} from "react-native";
|
|
38
|
+
import { DEFAULT_CHAT_THEME, type GuueyChatTheme } from "../theme.js";
|
|
39
|
+
import { defaultChatStrings, type ChatStrings } from "../strings.js";
|
|
40
|
+
import type { DisplayItem, TranscriptPlan } from "../types.js";
|
|
41
|
+
import {
|
|
42
|
+
nativeTranscriptComponents,
|
|
43
|
+
renderNativeItem,
|
|
44
|
+
type NativeTranscriptComponents,
|
|
45
|
+
type NativeTranscriptItemContext,
|
|
46
|
+
} from "./components.js";
|
|
47
|
+
import { resolveNativeTheme, type NativeThemeMode } from "./theme-native.js";
|
|
48
|
+
|
|
49
|
+
/** Offset (px) past which the reader counts as "up in history". */
|
|
50
|
+
const JUMP_THRESHOLD_PX = 160;
|
|
51
|
+
|
|
52
|
+
/** The host-behavior pass-through — everything the kit deliberately does NOT own. */
|
|
53
|
+
export type NativeTranscriptListProps = Pick<
|
|
54
|
+
FlatListProps<DisplayItem>,
|
|
55
|
+
| "style"
|
|
56
|
+
| "contentContainerStyle"
|
|
57
|
+
| "keyboardDismissMode"
|
|
58
|
+
| "keyboardShouldPersistTaps"
|
|
59
|
+
| "automaticallyAdjustKeyboardInsets"
|
|
60
|
+
| "onScroll"
|
|
61
|
+
| "scrollEventThrottle"
|
|
62
|
+
| "ListEmptyComponent"
|
|
63
|
+
| "scrollIndicatorInsets"
|
|
64
|
+
>;
|
|
65
|
+
|
|
66
|
+
export interface NativeTranscriptProps
|
|
67
|
+
extends Pick<
|
|
68
|
+
NativeTranscriptItemContext,
|
|
69
|
+
"onToggle" | "onRetry" | "onPromptAction" | "onErrorAction" | "resolvedMounts" | "onViewPhase" | "onViewRef"
|
|
70
|
+
> {
|
|
71
|
+
plan: TranscriptPlan;
|
|
72
|
+
/** Per-slot component overrides (spec §3's override column). */
|
|
73
|
+
components?: Partial<NativeTranscriptComponents>;
|
|
74
|
+
/** The i18n seam — pass the same strings the policy carries. */
|
|
75
|
+
strings?: ChatStrings;
|
|
76
|
+
theme?: GuueyChatTheme;
|
|
77
|
+
mode?: NativeThemeMode;
|
|
78
|
+
/** Host-owned FlatList behavior (keyboard/scroll chrome). */
|
|
79
|
+
listProps?: NativeTranscriptListProps;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function NativeTranscript(props: NativeTranscriptProps): ReactNode {
|
|
83
|
+
const {
|
|
84
|
+
plan,
|
|
85
|
+
components,
|
|
86
|
+
strings = defaultChatStrings,
|
|
87
|
+
theme = DEFAULT_CHAT_THEME,
|
|
88
|
+
mode = "light",
|
|
89
|
+
listProps,
|
|
90
|
+
onToggle,
|
|
91
|
+
onRetry,
|
|
92
|
+
onPromptAction,
|
|
93
|
+
onErrorAction,
|
|
94
|
+
resolvedMounts,
|
|
95
|
+
onViewPhase,
|
|
96
|
+
onViewRef,
|
|
97
|
+
} = props;
|
|
98
|
+
|
|
99
|
+
const tokens = useMemo(() => resolveNativeTheme(theme, mode), [theme, mode]);
|
|
100
|
+
const resolvedComponents: NativeTranscriptComponents = useMemo(
|
|
101
|
+
() => ({ ...nativeTranscriptComponents, ...components }),
|
|
102
|
+
[components],
|
|
103
|
+
);
|
|
104
|
+
const ctx: NativeTranscriptItemContext = useMemo(
|
|
105
|
+
() => ({ strings, tokens, onToggle, onRetry, onPromptAction, onErrorAction, resolvedMounts, onViewPhase, onViewRef }),
|
|
106
|
+
[strings, tokens, onToggle, onRetry, onPromptAction, onErrorAction, resolvedMounts, onViewPhase, onViewRef],
|
|
107
|
+
);
|
|
108
|
+
|
|
109
|
+
// Inverted-list data: reversed plan, newest at index 0 (the visual
|
|
110
|
+
// bottom / scroll origin). Keys are the plan's stable keys.
|
|
111
|
+
const data = useMemo(() => [...plan.items].reverse(), [plan.items]);
|
|
112
|
+
|
|
113
|
+
// Reduce motion: the ONE animation the kit owns is the jump scroll.
|
|
114
|
+
const [reduceMotion, setReduceMotion] = useState(false);
|
|
115
|
+
useEffect(() => {
|
|
116
|
+
let alive = true;
|
|
117
|
+
void AccessibilityInfo.isReduceMotionEnabled().then((v) => {
|
|
118
|
+
if (alive) setReduceMotion(v);
|
|
119
|
+
});
|
|
120
|
+
const sub = AccessibilityInfo.addEventListener("reduceMotionChanged", setReduceMotion);
|
|
121
|
+
return () => {
|
|
122
|
+
alive = false;
|
|
123
|
+
sub.remove();
|
|
124
|
+
};
|
|
125
|
+
}, []);
|
|
126
|
+
|
|
127
|
+
const listRef = useRef<FlatList<DisplayItem>>(null);
|
|
128
|
+
const [showJump, setShowJump] = useState(false);
|
|
129
|
+
const hostOnScroll = listProps?.onScroll;
|
|
130
|
+
const onScroll = useCallback(
|
|
131
|
+
(e: NativeSyntheticEvent<NativeScrollEvent>) => {
|
|
132
|
+
// Inverted coordinates: offset 0 IS the newest turn.
|
|
133
|
+
setShowJump(e.nativeEvent.contentOffset.y > JUMP_THRESHOLD_PX);
|
|
134
|
+
hostOnScroll?.(e);
|
|
135
|
+
},
|
|
136
|
+
[hostOnScroll],
|
|
137
|
+
);
|
|
138
|
+
|
|
139
|
+
const StatusComponent = resolvedComponents.status;
|
|
140
|
+
|
|
141
|
+
return (
|
|
142
|
+
<View style={{ flex: 1, backgroundColor: tokens.palette.canvas }}>
|
|
143
|
+
<FlatList
|
|
144
|
+
ref={listRef}
|
|
145
|
+
{...listProps}
|
|
146
|
+
onScroll={onScroll}
|
|
147
|
+
scrollEventThrottle={listProps?.scrollEventThrottle ?? 16}
|
|
148
|
+
// Inverted only while non-empty: an inverted ListEmptyComponent
|
|
149
|
+
// renders upside-down (RN gotcha — portal's receipt).
|
|
150
|
+
inverted={data.length > 0}
|
|
151
|
+
data={data}
|
|
152
|
+
keyExtractor={(item) => item.key}
|
|
153
|
+
renderItem={({ item }) => (
|
|
154
|
+
<View style={{ paddingVertical: tokens.gap / 2 }}>
|
|
155
|
+
{renderNativeItem(item, resolvedComponents, ctx)}
|
|
156
|
+
</View>
|
|
157
|
+
)}
|
|
158
|
+
// Inverted list: the header renders at the VISUAL BOTTOM — where
|
|
159
|
+
// the live status line belongs.
|
|
160
|
+
ListHeaderComponent={
|
|
161
|
+
plan.status !== null ? <StatusComponent item={plan.status} ctx={ctx} /> : null
|
|
162
|
+
}
|
|
163
|
+
/>
|
|
164
|
+
{showJump ? (
|
|
165
|
+
<Pressable
|
|
166
|
+
accessibilityRole="button"
|
|
167
|
+
onPress={() => {
|
|
168
|
+
setShowJump(false);
|
|
169
|
+
listRef.current?.scrollToOffset({ offset: 0, animated: !reduceMotion });
|
|
170
|
+
}}
|
|
171
|
+
style={{
|
|
172
|
+
position: "absolute",
|
|
173
|
+
bottom: 12,
|
|
174
|
+
alignSelf: "center",
|
|
175
|
+
backgroundColor: tokens.palette.surface,
|
|
176
|
+
borderRadius: 999,
|
|
177
|
+
paddingHorizontal: 14,
|
|
178
|
+
paddingVertical: 8,
|
|
179
|
+
elevation: 3,
|
|
180
|
+
}}
|
|
181
|
+
>
|
|
182
|
+
<Text style={{ color: tokens.palette.ink, fontSize: tokens.fontSize - 2, fontFamily: tokens.fontFamily }}>
|
|
183
|
+
{strings.jumpToLatest}
|
|
184
|
+
</Text>
|
|
185
|
+
</Pressable>
|
|
186
|
+
) : null}
|
|
187
|
+
</View>
|
|
188
|
+
);
|
|
189
|
+
}
|
package/src/native.tsx
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* React Native entry point (`@guuey/chat/native`) — the RN renderer over
|
|
3
|
+
* the SAME headless view-model as `./react` (wave 3c, guuey#135).
|
|
4
|
+
*
|
|
5
|
+
* Zero duplication by construction: the view-model, policies, theme
|
|
6
|
+
* schema, and strings are the root subpath's exports, and the
|
|
7
|
+
* renderer-state hooks (`useTranscript`, `useTranscriptInputs`) are the
|
|
8
|
+
* SAME modules the web kit uses — they are React-generic (no DOM), so both
|
|
9
|
+
* arms share one implementation. Only the walk differs: RN primitives, the
|
|
10
|
+
* inverted-list scroll contract, and the schema's native theme projection
|
|
11
|
+
* (`resolveNativeTheme` — CSS custom properties are the web projection).
|
|
12
|
+
*
|
|
13
|
+
* The R6 default is the documented native default-gap: this tier ships
|
|
14
|
+
* WITHOUT a WebView dependency, so `view` is a required override (a
|
|
15
|
+
* labeled placeholder renders otherwise — never blank). See
|
|
16
|
+
* `native/components.tsx`.
|
|
17
|
+
*
|
|
18
|
+
* `react` and `react-native` are OPTIONAL peers — the root subpath stays
|
|
19
|
+
* importable everywhere; this arm loads only inside an RN runtime.
|
|
20
|
+
*/
|
|
21
|
+
export {
|
|
22
|
+
NativeTranscript,
|
|
23
|
+
type NativeTranscriptProps,
|
|
24
|
+
type NativeTranscriptListProps,
|
|
25
|
+
} from "./native/transcript.js";
|
|
26
|
+
export {
|
|
27
|
+
nativeTranscriptComponents,
|
|
28
|
+
renderNativeItem,
|
|
29
|
+
NativeUserMessage,
|
|
30
|
+
NativeText,
|
|
31
|
+
NativeReasoning,
|
|
32
|
+
NativeTool,
|
|
33
|
+
NativeToolGroup,
|
|
34
|
+
NativeDataResult,
|
|
35
|
+
NativeView,
|
|
36
|
+
NativeViewRef,
|
|
37
|
+
NativeMedia,
|
|
38
|
+
NativeCode,
|
|
39
|
+
NativeCitations,
|
|
40
|
+
NativePrompt,
|
|
41
|
+
NativeError,
|
|
42
|
+
NativeHistoryBoundary,
|
|
43
|
+
NativeCompaction,
|
|
44
|
+
NativeUnknown,
|
|
45
|
+
NativeStatus,
|
|
46
|
+
type NativeTranscriptComponents,
|
|
47
|
+
type NativeTranscriptItemContext,
|
|
48
|
+
} from "./native/components.js";
|
|
49
|
+
export { NativeMarkdown } from "./native/markdown.js";
|
|
50
|
+
export {
|
|
51
|
+
resolveNativeTheme,
|
|
52
|
+
type NativeChatTokens,
|
|
53
|
+
type NativeThemeMode,
|
|
54
|
+
} from "./native/theme-native.js";
|
|
55
|
+
// The React-generic renderer-state owner + live assembler — shared with
|
|
56
|
+
// the web kit (one implementation, two walks).
|
|
57
|
+
export {
|
|
58
|
+
useTranscript,
|
|
59
|
+
useTranscriptInputs,
|
|
60
|
+
type UseTranscriptArgs,
|
|
61
|
+
type UseTranscriptResult,
|
|
62
|
+
type UseTranscriptInputsResult,
|
|
63
|
+
} from "./react/use-transcript.js";
|