@comergehq/studio 0.1.1 → 0.1.3

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 (172) hide show
  1. package/dist/index.js +255 -245
  2. package/dist/index.js.map +1 -1
  3. package/dist/index.mjs +213 -203
  4. package/dist/index.mjs.map +1 -1
  5. package/package.json +9 -6
  6. package/src/components/chat/ChatComposer.tsx +277 -0
  7. package/src/components/chat/ChatHeader.tsx +31 -0
  8. package/src/components/chat/ChatMessageBubble.tsx +69 -0
  9. package/src/components/chat/ChatMessageList.tsx +137 -0
  10. package/src/components/chat/ChatPage.tsx +69 -0
  11. package/src/components/chat/ForkNoticeBanner.tsx +66 -0
  12. package/src/components/chat/MultilineTextInput.tsx +46 -0
  13. package/src/components/chat/ScrollToBottomButton.tsx +78 -0
  14. package/src/components/chat/TypingIndicator.tsx +54 -0
  15. package/src/components/chat/index.ts +28 -0
  16. package/src/components/comments/AppCommentsSheet.tsx +213 -0
  17. package/src/components/comments/CommentRow.tsx +63 -0
  18. package/src/components/comments/formatTimeAgo.ts +3 -0
  19. package/src/components/comments/index.ts +3 -0
  20. package/src/components/comments/useAppComments.ts +74 -0
  21. package/src/components/comments/useAppDetails.ts +35 -0
  22. package/src/components/comments/useIosKeyboardSnapFix.ts +24 -0
  23. package/src/components/dialogs/ConfirmMergeRequestDialog.tsx +156 -0
  24. package/src/components/dialogs/index.ts +4 -0
  25. package/src/components/draw/DrawColorPicker.tsx +77 -0
  26. package/src/components/draw/DrawModeOverlay.tsx +144 -0
  27. package/src/components/draw/DrawSurface.tsx +127 -0
  28. package/src/components/draw/DrawToolbar.tsx +253 -0
  29. package/src/components/draw/index.ts +15 -0
  30. package/src/components/draw/optionalHaptics.ts +15 -0
  31. package/src/components/draw/strokes.ts +21 -0
  32. package/src/components/draw/types.ts +9 -0
  33. package/src/components/floating-draggable-button/FloatingDraggableButton.tsx +323 -0
  34. package/src/components/floating-draggable-button/constants.ts +17 -0
  35. package/src/components/floating-draggable-button/index.ts +4 -0
  36. package/src/components/floating-draggable-button/types.ts +63 -0
  37. package/src/components/icons/MergeIcon.tsx +14 -0
  38. package/src/components/icons/StudioIcons.tsx +66 -0
  39. package/src/components/index.ts +17 -0
  40. package/src/components/merge-requests/MergeRequestStatusCard.tsx +179 -0
  41. package/src/components/merge-requests/ReviewMergeRequestActionButton.tsx +62 -0
  42. package/src/components/merge-requests/ReviewMergeRequestCard.tsx +192 -0
  43. package/src/components/merge-requests/ReviewMergeRequestCarousel.tsx +132 -0
  44. package/src/components/merge-requests/index.ts +7 -0
  45. package/src/components/merge-requests/mergeRequestStatusDisplay.ts +23 -0
  46. package/src/components/merge-requests/toIsoString.ts +9 -0
  47. package/src/components/merge-requests/useControlledExpansion.ts +16 -0
  48. package/src/components/models/index.ts +9 -0
  49. package/src/components/models/types.ts +43 -0
  50. package/src/components/overlays/EdgeGlowFrame.tsx +105 -0
  51. package/src/components/overlays/index.ts +4 -0
  52. package/src/components/preview/PreviewHeroCard.tsx +58 -0
  53. package/src/components/preview/PreviewImage.tsx +22 -0
  54. package/src/components/preview/PreviewMetaRow.tsx +70 -0
  55. package/src/components/preview/PreviewPage.tsx +36 -0
  56. package/src/components/preview/PreviewPlaceholder.tsx +72 -0
  57. package/src/components/preview/PreviewStatusBadge.tsx +63 -0
  58. package/src/components/preview/StatsBar.tsx +109 -0
  59. package/src/components/preview/index.ts +22 -0
  60. package/src/components/primitives/Avatar.tsx +68 -0
  61. package/src/components/primitives/Button.tsx +102 -0
  62. package/src/components/primitives/Card.tsx +30 -0
  63. package/src/components/primitives/Divider.tsx +17 -0
  64. package/src/components/primitives/Icon.tsx +40 -0
  65. package/src/components/primitives/MarkdownText.tsx +72 -0
  66. package/src/components/primitives/Modal.tsx +53 -0
  67. package/src/components/primitives/Surface.tsx +42 -0
  68. package/src/components/primitives/Text.tsx +83 -0
  69. package/src/components/primitives/index.ts +35 -0
  70. package/src/components/primitives/types.ts +30 -0
  71. package/src/components/studio-sheet/StudioBottomSheet.tsx +114 -0
  72. package/src/components/studio-sheet/StudioSheetBackground.tsx +63 -0
  73. package/src/components/studio-sheet/StudioSheetHeader.tsx +35 -0
  74. package/src/components/studio-sheet/StudioSheetHeaderIconButton.tsx +109 -0
  75. package/src/components/studio-sheet/StudioSheetPager.tsx +66 -0
  76. package/src/components/studio-sheet/index.ts +18 -0
  77. package/src/components/studio-sheet/types.ts +5 -0
  78. package/src/components/utils/color.ts +25 -0
  79. package/src/components/utils/formatTimeAgo.ts +19 -0
  80. package/src/core/logger.ts +42 -0
  81. package/src/core/services/http/baseUrl.ts +3 -0
  82. package/src/core/services/http/index.ts +128 -0
  83. package/src/core/services/http/public.ts +14 -0
  84. package/src/core/services/supabase/auth.ts +41 -0
  85. package/src/core/services/supabase/client.ts +43 -0
  86. package/src/core/services/supabase/index.ts +7 -0
  87. package/src/data/agent/remote.ts +30 -0
  88. package/src/data/agent/repository.ts +34 -0
  89. package/src/data/agent/types.ts +28 -0
  90. package/src/data/apps/bundles/remote.ts +47 -0
  91. package/src/data/apps/bundles/repository.ts +35 -0
  92. package/src/data/apps/bundles/types.ts +27 -0
  93. package/src/data/apps/images/remote.ts +61 -0
  94. package/src/data/apps/images/repository.ts +47 -0
  95. package/src/data/apps/remote.ts +97 -0
  96. package/src/data/apps/repository.ts +185 -0
  97. package/src/data/apps/types.ts +206 -0
  98. package/src/data/attachment/remote.ts +32 -0
  99. package/src/data/attachment/repository.ts +40 -0
  100. package/src/data/attachment/types.ts +42 -0
  101. package/src/data/base-remote.ts +3 -0
  102. package/src/data/base-repository.ts +11 -0
  103. package/src/data/comments/likes/remote.ts +87 -0
  104. package/src/data/comments/likes/repository.ts +61 -0
  105. package/src/data/comments/likes/types.ts +47 -0
  106. package/src/data/comments/remote.ts +71 -0
  107. package/src/data/comments/repository.ts +53 -0
  108. package/src/data/comments/types.ts +60 -0
  109. package/src/data/github/remote.ts +23 -0
  110. package/src/data/github/repository.ts +35 -0
  111. package/src/data/github/types.ts +23 -0
  112. package/src/data/home/remote.ts +24 -0
  113. package/src/data/home/repository.ts +28 -0
  114. package/src/data/home/types.ts +70 -0
  115. package/src/data/index.ts +3 -0
  116. package/src/data/likes/remote.ts +57 -0
  117. package/src/data/likes/repository.ts +47 -0
  118. package/src/data/likes/types.ts +46 -0
  119. package/src/data/me/remote.ts +28 -0
  120. package/src/data/me/repository.ts +30 -0
  121. package/src/data/me/types.ts +14 -0
  122. package/src/data/merge-requests/remote.ts +76 -0
  123. package/src/data/merge-requests/repository.ts +66 -0
  124. package/src/data/merge-requests/types.ts +33 -0
  125. package/src/data/messages/remote.ts +21 -0
  126. package/src/data/messages/repository.ts +104 -0
  127. package/src/data/messages/types.ts +20 -0
  128. package/src/data/public/studio-config/remote.ts +19 -0
  129. package/src/data/public/studio-config/repository.ts +23 -0
  130. package/src/data/public/studio-config/types.ts +6 -0
  131. package/src/data/ratings/remote.ts +76 -0
  132. package/src/data/ratings/repository.ts +63 -0
  133. package/src/data/ratings/types.ts +57 -0
  134. package/src/data/threads/remote.ts +40 -0
  135. package/src/data/threads/repository.ts +41 -0
  136. package/src/data/threads/types.ts +25 -0
  137. package/src/data/types.ts +8 -0
  138. package/src/data/users/remote.ts +31 -0
  139. package/src/data/users/repository.ts +45 -0
  140. package/src/data/users/types.ts +15 -0
  141. package/src/index.ts +6 -0
  142. package/src/studio/ComergeStudio.tsx +246 -0
  143. package/src/studio/bootstrap/StudioBootstrap.tsx +45 -0
  144. package/src/studio/bootstrap/useStudioBootstrap.ts +51 -0
  145. package/src/studio/hooks/useApp.ts +83 -0
  146. package/src/studio/hooks/useAppStats.ts +111 -0
  147. package/src/studio/hooks/useAttachmentUpload.ts +59 -0
  148. package/src/studio/hooks/useBundleManager.ts +389 -0
  149. package/src/studio/hooks/useMergeRequests.ts +173 -0
  150. package/src/studio/hooks/useStudioActions.ts +96 -0
  151. package/src/studio/hooks/useThreadMessages.ts +85 -0
  152. package/src/studio/lib/chat.ts +34 -0
  153. package/src/studio/ui/ChatPanel.tsx +154 -0
  154. package/src/studio/ui/ConfirmMergeFlow.tsx +55 -0
  155. package/src/studio/ui/PreviewPanel.tsx +131 -0
  156. package/src/studio/ui/RuntimeRenderer.tsx +40 -0
  157. package/src/studio/ui/StudioOverlay.tsx +257 -0
  158. package/src/studio/ui/preview-panel/PressableCardRow.tsx +49 -0
  159. package/src/studio/ui/preview-panel/PreviewCollaborateSection.tsx +174 -0
  160. package/src/studio/ui/preview-panel/PreviewCustomizeSection.tsx +160 -0
  161. package/src/studio/ui/preview-panel/PreviewHeroSection.tsx +56 -0
  162. package/src/studio/ui/preview-panel/PreviewMetaSection.tsx +67 -0
  163. package/src/studio/ui/preview-panel/PreviewPanelHeader.tsx +48 -0
  164. package/src/studio/ui/preview-panel/SectionTitle.tsx +31 -0
  165. package/src/studio/ui/preview-panel/usePreviewPanelData.ts +132 -0
  166. package/src/studio/ui/preview-panel/utils.ts +29 -0
  167. package/src/theme/index.ts +5 -0
  168. package/src/theme/tokens.ts +118 -0
  169. package/src/theme/types.ts +90 -0
  170. package/src/theme/useTheme.ts +11 -0
  171. package/dist/assets/images/merge.svg +0 -3
  172. package/dist/merge-72UG27QV.svg +0 -3
package/package.json CHANGED
@@ -1,14 +1,15 @@
1
1
  {
2
2
  "name": "@comergehq/studio",
3
- "version": "0.1.1",
3
+ "version": "0.1.3",
4
4
  "description": "Comerge studio",
5
- "main": "dist/index.js",
5
+ "main": "src/index.ts",
6
6
  "module": "dist/index.mjs",
7
7
  "types": "dist/index.d.ts",
8
- "react-native": "dist/index.js",
8
+ "react-native": "src/index.ts",
9
9
  "sideEffects": false,
10
10
  "files": [
11
11
  "dist",
12
+ "src",
12
13
  "README.md",
13
14
  "LICENSE"
14
15
  ],
@@ -28,17 +29,18 @@
28
29
  "exports": {
29
30
  ".": {
30
31
  "types": "./dist/index.d.ts",
32
+ "react-native": "./src/index.ts",
31
33
  "import": "./dist/index.mjs",
32
34
  "require": "./dist/index.js"
33
- }
35
+ },
36
+ "./package.json": "./package.json"
34
37
  },
35
38
  "scripts": {
36
39
  "clean": "node -e \"require('node:fs').rmSync('dist',{recursive:true,force:true})\"",
37
- "build": "tsup && node scripts/copy-assets.mjs",
40
+ "build": "tsup",
38
41
  "prepublishOnly": "npm run build"
39
42
  },
40
43
  "dependencies": {
41
- "@comergehq/runtime": "^0.1.1",
42
44
  "axios": "^1.13.1",
43
45
  "react-native-markdown-display": "^7.0.2",
44
46
  "react-native-logs": "^5.5.0"
@@ -52,6 +54,7 @@
52
54
  "@callstack/liquid-glass": "*",
53
55
  "@supabase/supabase-js": "*",
54
56
  "@gorhom/bottom-sheet": "*",
57
+ "@comergehq/runtime": "^0.1.1",
55
58
  "expo": "*",
56
59
  "expo-file-system": "*",
57
60
  "expo-haptics": "*",
@@ -0,0 +1,277 @@
1
+ import * as React from 'react';
2
+ import {
3
+ ActivityIndicator,
4
+ Animated,
5
+ Dimensions,
6
+ Image,
7
+ Pressable,
8
+ ScrollView,
9
+ View,
10
+ type ViewStyle,
11
+ } from 'react-native';
12
+ import { LiquidGlassView, isLiquidGlassSupported } from '@callstack/liquid-glass';
13
+ import { Plus } from 'lucide-react-native';
14
+
15
+ import { useTheme } from '../../theme';
16
+ import { MultilineTextInput } from './MultilineTextInput';
17
+ import { IconChevronRight, IconClose } from '../icons/StudioIcons';
18
+
19
+ export type ChatComposerProps = {
20
+ value?: string;
21
+ onChangeValue?: (text: string) => void;
22
+ placeholder?: string;
23
+ disabled?: boolean;
24
+ sending?: boolean;
25
+ autoFocus?: boolean;
26
+ onSend: (text: string, attachments?: string[]) => void | Promise<void>;
27
+ attachments?: string[];
28
+ onRemoveAttachment?: (index: number) => void;
29
+ onAddAttachment?: () => void;
30
+ renderAddAttachment?: () => React.ReactNode;
31
+ renderRemoveIcon?: () => React.ReactNode;
32
+ renderSendIcon?: () => React.ReactNode;
33
+ useBottomSheetTextInput?: boolean;
34
+ onLayout?: (e: { height: number }) => void;
35
+ style?: ViewStyle;
36
+ };
37
+
38
+ const THUMBNAIL_HEIGHT = 90;
39
+
40
+ function AspectRatioThumbnail({
41
+ uri,
42
+ onRemove,
43
+ renderRemoveIcon,
44
+ }: {
45
+ uri: string;
46
+ onRemove?: () => void;
47
+ renderRemoveIcon?: () => React.ReactNode;
48
+ }) {
49
+ const [aspectRatio, setAspectRatio] = React.useState(1);
50
+
51
+ return (
52
+ <View style={{ height: THUMBNAIL_HEIGHT, aspectRatio, position: 'relative' }}>
53
+ <View style={{ flex: 1, borderRadius: 8, overflow: 'hidden' }}>
54
+ <Image
55
+ source={{ uri }}
56
+ style={{ width: '100%', height: '100%' }}
57
+ resizeMode="contain"
58
+ onLoad={(e) => {
59
+ const { width, height } = (e as any).nativeEvent?.source ?? {};
60
+ if (width && height) setAspectRatio(width / height);
61
+ }}
62
+ />
63
+ </View>
64
+ {onRemove ? (
65
+ <Pressable
66
+ style={{
67
+ position: 'absolute',
68
+ top: -4,
69
+ right: -4,
70
+ width: 24,
71
+ height: 24,
72
+ borderRadius: 12,
73
+ backgroundColor: 'rgba(0, 0, 0, 0.7)',
74
+ alignItems: 'center',
75
+ justifyContent: 'center',
76
+ zIndex: 10,
77
+ }}
78
+ onPress={onRemove}
79
+ hitSlop={10}
80
+ >
81
+ {renderRemoveIcon ? renderRemoveIcon() : <IconClose size={12} colorToken="onPrimary" />}
82
+ </Pressable>
83
+ ) : null}
84
+ </View>
85
+ );
86
+ }
87
+
88
+ export function ChatComposer({
89
+ value,
90
+ onChangeValue,
91
+ placeholder = 'Describe the idea you want to build',
92
+ disabled = false,
93
+ sending = false,
94
+ autoFocus = false,
95
+ onSend,
96
+ attachments = [],
97
+ onRemoveAttachment,
98
+ onAddAttachment,
99
+ renderAddAttachment,
100
+ renderRemoveIcon,
101
+ renderSendIcon,
102
+ useBottomSheetTextInput,
103
+ onLayout,
104
+ style,
105
+ }: ChatComposerProps) {
106
+ const theme = useTheme();
107
+ const [internal, setInternal] = React.useState('');
108
+ const text = value ?? internal;
109
+ const setText = onChangeValue ?? setInternal;
110
+ const hasAttachments = attachments.length > 0;
111
+ const hasText = text.trim().length > 0;
112
+ const composerMinHeight = hasAttachments ? THUMBNAIL_HEIGHT + 44 + 24 : 44;
113
+
114
+ const isButtonDisabled = sending || disabled;
115
+ const maxInputHeight = React.useMemo(() => Dimensions.get('window').height * 0.5, []);
116
+ const shakeAnim = React.useRef(new Animated.Value(0)).current;
117
+ const [sendPressed, setSendPressed] = React.useState(false);
118
+ const inputRef = React.useRef<import('react-native').TextInput | null>(null);
119
+ const prevAutoFocusRef = React.useRef(false);
120
+
121
+ React.useEffect(() => {
122
+ const shouldFocus = autoFocus && !prevAutoFocusRef.current && !disabled && !sending;
123
+ prevAutoFocusRef.current = autoFocus;
124
+ if (!shouldFocus) return;
125
+
126
+ // Temporary workaround: Bottom sheets can take a moment to open
127
+ const t = setTimeout(() => {
128
+ inputRef.current?.focus();
129
+ }, 75);
130
+ return () => clearTimeout(t);
131
+ }, [autoFocus, disabled, sending]);
132
+
133
+ const triggerShake = React.useCallback(() => {
134
+ shakeAnim.setValue(0);
135
+ Animated.sequence([
136
+ Animated.timing(shakeAnim, { toValue: 10, duration: 50, useNativeDriver: true }),
137
+ Animated.timing(shakeAnim, { toValue: -10, duration: 50, useNativeDriver: true }),
138
+ Animated.timing(shakeAnim, { toValue: 10, duration: 50, useNativeDriver: true }),
139
+ Animated.timing(shakeAnim, { toValue: -10, duration: 50, useNativeDriver: true }),
140
+ Animated.timing(shakeAnim, { toValue: 0, duration: 50, useNativeDriver: true }),
141
+ ]).start();
142
+ }, [shakeAnim]);
143
+
144
+ const handleSend = React.useCallback(async () => {
145
+ if (isButtonDisabled) return;
146
+ // Require at least one character of text (attachments alone not enough)
147
+ if (!hasText) {
148
+ triggerShake();
149
+ return;
150
+ }
151
+ const trimmed = text.trim();
152
+ await onSend(trimmed, attachments.length > 0 ? attachments : undefined);
153
+ setText('');
154
+ }, [attachments, hasText, isButtonDisabled, onSend, setText, text, triggerShake]);
155
+
156
+ const textareaBgColor = theme.scheme === 'dark' ? '#18181B' : '#F6F6F6';
157
+ const placeholderTextColor = theme.scheme === 'dark' ? '#A1A1AA' : '#71717A';
158
+
159
+ return (
160
+ <View
161
+ style={[{ paddingHorizontal: 16, paddingBottom: 12, paddingTop: 8 }, style]}
162
+ onLayout={(e) => onLayout?.({ height: e.nativeEvent.layout.height })}
163
+ >
164
+ <View style={{ flexDirection: 'row', alignItems: 'flex-end', gap: 8 }}>
165
+ <Animated.View style={{ flex: 1, transform: [{ translateX: shakeAnim }] }}>
166
+ <LiquidGlassView
167
+ style={[
168
+ // LiquidGlassView doesn't reliably auto-size to children; ensure enough height for the
169
+ // thumbnail strip when attachments are present.
170
+ { borderRadius: 24, flex: 1, minHeight: composerMinHeight },
171
+ !isLiquidGlassSupported && { backgroundColor: textareaBgColor },
172
+ ]}
173
+ interactive
174
+ effect="clear"
175
+ >
176
+ {hasAttachments ? (
177
+ <ScrollView
178
+ horizontal
179
+ showsHorizontalScrollIndicator={false}
180
+ keyboardShouldPersistTaps="handled"
181
+ contentContainerStyle={{ gap: 8, paddingHorizontal: 12, paddingTop: 12 }}
182
+ >
183
+ {attachments.map((uri, index) => (
184
+ <AspectRatioThumbnail
185
+ key={`attachment-${index}`}
186
+ uri={uri}
187
+ onRemove={onRemoveAttachment ? () => onRemoveAttachment(index) : undefined}
188
+ renderRemoveIcon={renderRemoveIcon}
189
+ />
190
+ ))}
191
+ {onAddAttachment ? (
192
+ renderAddAttachment ? (
193
+ renderAddAttachment()
194
+ ) : (
195
+ <Pressable
196
+ style={{
197
+ height: THUMBNAIL_HEIGHT,
198
+ aspectRatio: 0.6,
199
+ borderRadius: 8,
200
+ borderWidth: 2,
201
+ borderColor: 'rgba(255, 255, 255, 0.3)',
202
+ borderStyle: 'dashed',
203
+ alignItems: 'center',
204
+ justifyContent: 'center',
205
+ backgroundColor: 'rgba(255, 255, 255, 0.05)',
206
+ }}
207
+ onPress={onAddAttachment}
208
+ >
209
+ <Plus size={24} color="rgba(255, 255, 255, 0.5)" />
210
+ </Pressable>
211
+ )
212
+ ) : null}
213
+ </ScrollView>
214
+ ) : null}
215
+
216
+ <MultilineTextInput
217
+ ref={inputRef}
218
+ value={text}
219
+ onChangeText={setText}
220
+ placeholder={placeholder}
221
+ editable={!disabled && !sending}
222
+ useBottomSheetTextInput={useBottomSheetTextInput}
223
+ autoFocus={autoFocus}
224
+ placeholderTextColor={placeholderTextColor}
225
+ scrollEnabled
226
+ style={{
227
+ maxHeight: maxInputHeight,
228
+ minHeight: 44,
229
+ color: theme.scheme === 'dark' ? '#FAFAFA' : '#09090B',
230
+ paddingHorizontal: 16,
231
+ paddingVertical: 12,
232
+ lineHeight: 20,
233
+ }}
234
+ />
235
+ </LiquidGlassView>
236
+ </Animated.View>
237
+
238
+ <LiquidGlassView
239
+ style={[{ borderRadius: 100 }, !isLiquidGlassSupported && { backgroundColor: textareaBgColor }]}
240
+ interactive
241
+ effect="clear"
242
+ >
243
+ <View
244
+ style={{
245
+ width: 44,
246
+ height: 44,
247
+ borderRadius: 22,
248
+ overflow: 'hidden',
249
+ backgroundColor: theme.colors.primary,
250
+ opacity: isButtonDisabled ? 0.6 : sendPressed ? 0.9 : 1,
251
+ }}
252
+ >
253
+ <Pressable
254
+ accessibilityRole="button"
255
+ accessibilityLabel="Send"
256
+ disabled={isButtonDisabled}
257
+ onPress={handleSend}
258
+ onPressIn={() => setSendPressed(true)}
259
+ onPressOut={() => setSendPressed(false)}
260
+ style={{ flex: 1, alignItems: 'center', justifyContent: 'center' }}
261
+ >
262
+ {sending ? (
263
+ <ActivityIndicator />
264
+ ) : renderSendIcon ? (
265
+ renderSendIcon()
266
+ ) : (
267
+ <IconChevronRight size={20} colorToken="onPrimary" />
268
+ )}
269
+ </Pressable>
270
+ </View>
271
+ </LiquidGlassView>
272
+ </View>
273
+ </View>
274
+ );
275
+ }
276
+
277
+
@@ -0,0 +1,31 @@
1
+ import * as React from 'react';
2
+ import { StyleSheet, View, type ViewStyle } from 'react-native';
3
+
4
+ import { StudioSheetHeader } from '../studio-sheet/StudioSheetHeader';
5
+
6
+ export type ChatHeaderProps = {
7
+ left?: React.ReactNode;
8
+ right?: React.ReactNode;
9
+ center?: React.ReactNode;
10
+ style?: ViewStyle;
11
+ };
12
+
13
+ export function ChatHeader({ left, right, center, style }: ChatHeaderProps) {
14
+ const flattenedStyle = StyleSheet.flatten([
15
+ {
16
+ paddingTop: 0,
17
+ } satisfies ViewStyle,
18
+ style,
19
+ ]);
20
+
21
+ return (
22
+ <StudioSheetHeader
23
+ left={left}
24
+ right={right}
25
+ center={center}
26
+ style={flattenedStyle}
27
+ />
28
+ );
29
+ }
30
+
31
+
@@ -0,0 +1,69 @@
1
+ import * as React from 'react';
2
+ import { View, type ViewStyle } from 'react-native';
3
+ import { CheckCheck, GitMerge } from 'lucide-react-native';
4
+
5
+ import type { ChatMessage } from '../models/types';
6
+ import { useTheme } from '../../theme';
7
+ import { MarkdownText } from '../primitives/MarkdownText';
8
+ import { Surface } from '../primitives/Surface';
9
+
10
+ export type ChatMessageBubbleProps = {
11
+ message: ChatMessage;
12
+ /**
13
+ * Optional custom renderer for message content (e.g. markdown).
14
+ */
15
+ renderContent?: (message: ChatMessage) => React.ReactNode;
16
+ style?: ViewStyle;
17
+ };
18
+
19
+ export function ChatMessageBubble({ message, renderContent, style }: ChatMessageBubbleProps) {
20
+ const theme = useTheme();
21
+ const metaEvent = message.meta?.event ?? null;
22
+ const metaStatus = message.meta?.status ?? null;
23
+
24
+ const isMergeApproved = metaEvent === 'merge_request.approved';
25
+ const isMergeRejected = metaEvent === 'merge_request.rejected';
26
+ const isMergeCompleted = metaEvent === 'merge.completed';
27
+
28
+ const isHuman = message.author === 'human' || isMergeApproved || isMergeRejected;
29
+
30
+ const align: ViewStyle = { alignSelf: isHuman ? 'flex-end' : 'flex-start' };
31
+ const bubbleVariant = isHuman ? 'surface' : 'surfaceRaised';
32
+ const cornerStyle: ViewStyle = isHuman ? { borderTopRightRadius: 0 } : { borderTopLeftRadius: 0 };
33
+
34
+ const bodyColor =
35
+ metaStatus === 'success' ? theme.colors.success : metaStatus === 'error' ? theme.colors.danger : undefined;
36
+
37
+ return (
38
+ <View style={[align, style]}>
39
+ <Surface
40
+ variant={bubbleVariant}
41
+ style={[
42
+ {
43
+ maxWidth: '85%',
44
+ borderRadius: theme.radii.lg,
45
+ paddingHorizontal: theme.spacing.lg,
46
+ paddingVertical: theme.spacing.md,
47
+ borderWidth: 1,
48
+ borderColor: theme.colors.border,
49
+ },
50
+ cornerStyle,
51
+ ]}
52
+ >
53
+ <View style={{ flexDirection: 'row', alignItems: 'center' }}>
54
+ {isMergeCompleted ? (
55
+ <CheckCheck size={16} color={theme.colors.success} style={{ marginRight: theme.spacing.sm }} />
56
+ ) : null}
57
+ {isMergeApproved ? (
58
+ <GitMerge size={16} color={theme.colors.text} style={{ marginRight: theme.spacing.sm }} />
59
+ ) : null}
60
+ <View style={{ flexShrink: 1, minWidth: 0 }}>
61
+ {renderContent ? renderContent(message) : <MarkdownText markdown={message.content} variant="chat" bodyColor={bodyColor} />}
62
+ </View>
63
+ </View>
64
+ </Surface>
65
+ </View>
66
+ );
67
+ }
68
+
69
+
@@ -0,0 +1,137 @@
1
+ import * as React from 'react';
2
+ import { View, type NativeScrollEvent, type NativeSyntheticEvent, type ViewStyle } from 'react-native';
3
+ import { BottomSheetFlatList } from '@gorhom/bottom-sheet';
4
+
5
+ import type { ChatMessage } from '../models/types';
6
+ import { useTheme } from '../../theme';
7
+ import { ChatMessageBubble, type ChatMessageBubbleProps } from './ChatMessageBubble';
8
+ import { TypingIndicator } from './TypingIndicator';
9
+
10
+ export type ChatMessageListRef = {
11
+ scrollToBottom: (options?: { animated?: boolean }) => void;
12
+ };
13
+
14
+ export type ChatMessageListProps = {
15
+ messages: ChatMessage[];
16
+ showTypingIndicator?: boolean;
17
+ renderMessageContent?: ChatMessageBubbleProps['renderContent'];
18
+ contentStyle?: ViewStyle;
19
+ /**
20
+ * Called when the user is near the bottom of the list.
21
+ */
22
+ onNearBottomChange?: (nearBottom: boolean) => void;
23
+ /**
24
+ * Distance threshold from bottom (in dp) that counts as "near bottom".
25
+ */
26
+ nearBottomThreshold?: number;
27
+ };
28
+
29
+ export const ChatMessageList = React.forwardRef<ChatMessageListRef, ChatMessageListProps>(
30
+ (
31
+ {
32
+ messages,
33
+ showTypingIndicator = false,
34
+ renderMessageContent,
35
+ contentStyle,
36
+ onNearBottomChange,
37
+ nearBottomThreshold = 200,
38
+ },
39
+ ref
40
+ ) => {
41
+ const theme = useTheme();
42
+ const listRef = React.useRef<React.ElementRef<typeof BottomSheetFlatList<ChatMessage>>>(null);
43
+ const nearBottomRef = React.useRef(true);
44
+ const initialScrollDoneRef = React.useRef(false);
45
+ const lastMessageIdRef = React.useRef<string | null>(null);
46
+
47
+ const scrollToBottom = React.useCallback((options?: { animated?: boolean }) => {
48
+ const animated = options?.animated ?? true;
49
+ // Scroll to visual bottom (latest messages) in a normal (non-inverted) list.
50
+ listRef.current?.scrollToEnd({ animated });
51
+ }, []);
52
+
53
+ React.useImperativeHandle(ref, () => ({ scrollToBottom }), [scrollToBottom]);
54
+
55
+ const handleScroll = React.useCallback(
56
+ (e: NativeSyntheticEvent<NativeScrollEvent>) => {
57
+ const { contentOffset, contentSize, layoutMeasurement } = e.nativeEvent;
58
+ const distanceFromBottom = Math.max(contentSize.height - (contentOffset.y + layoutMeasurement.height), 0);
59
+ const isNear = distanceFromBottom <= nearBottomThreshold;
60
+
61
+ if (nearBottomRef.current !== isNear) {
62
+ nearBottomRef.current = isNear;
63
+ onNearBottomChange?.(isNear);
64
+ }
65
+ },
66
+ [nearBottomThreshold, onNearBottomChange]
67
+ );
68
+
69
+ // On first load, start at the bottom
70
+ React.useEffect(() => {
71
+ if (initialScrollDoneRef.current) return;
72
+ if (messages.length === 0) return;
73
+
74
+ initialScrollDoneRef.current = true;
75
+ lastMessageIdRef.current = messages[messages.length - 1]?.id ?? null;
76
+ const id = requestAnimationFrame(() => scrollToBottom({ animated: false }));
77
+ return () => cancelAnimationFrame(id);
78
+ }, [messages, scrollToBottom]);
79
+
80
+ // When new messages arrive, keep the user pinned to the bottom only if they already were near it.
81
+ React.useEffect(() => {
82
+ if (!initialScrollDoneRef.current) return;
83
+ const lastId = messages.length > 0 ? messages[messages.length - 1]!.id : null;
84
+ const prevLastId = lastMessageIdRef.current;
85
+ lastMessageIdRef.current = lastId;
86
+ if (!lastId || lastId === prevLastId) return;
87
+ if (!nearBottomRef.current) return;
88
+
89
+ const id = requestAnimationFrame(() => scrollToBottom({ animated: true }));
90
+ return () => cancelAnimationFrame(id);
91
+ }, [messages, scrollToBottom]);
92
+
93
+ // When typing indicator appears, keep the user at bottom if they already were.
94
+ React.useEffect(() => {
95
+ if (showTypingIndicator && nearBottomRef.current) {
96
+ const id = requestAnimationFrame(() => scrollToBottom({ animated: true }));
97
+ return () => cancelAnimationFrame(id);
98
+ }
99
+ return undefined;
100
+ }, [showTypingIndicator, scrollToBottom]);
101
+
102
+ return (
103
+ <BottomSheetFlatList
104
+ ref={listRef}
105
+ data={messages}
106
+ keyExtractor={(m: ChatMessage) => m.id}
107
+ onScroll={handleScroll}
108
+ scrollEventThrottle={16}
109
+ showsVerticalScrollIndicator={false}
110
+ contentContainerStyle={[
111
+ {
112
+ paddingHorizontal: theme.spacing.lg,
113
+ paddingTop: theme.spacing.sm,
114
+ paddingBottom: theme.spacing.xl,
115
+ },
116
+ contentStyle,
117
+ ]}
118
+ renderItem={({ item, index }: { item: ChatMessage; index: number }) => (
119
+ <View style={{ marginTop: index === 0 ? 0 : theme.spacing.sm }}>
120
+ <ChatMessageBubble message={item} renderContent={renderMessageContent} />
121
+ </View>
122
+ )}
123
+ ListFooterComponent={
124
+ showTypingIndicator ? (
125
+ <View style={{ marginTop: theme.spacing.sm, alignSelf: 'flex-start', paddingHorizontal: theme.spacing.lg }}>
126
+ <TypingIndicator />
127
+ </View>
128
+ ) : null
129
+ }
130
+ maintainVisibleContentPosition={{ minIndexForVisible: 0, autoscrollToTopThreshold: nearBottomThreshold }}
131
+ />
132
+ );
133
+ }
134
+ );
135
+ ChatMessageList.displayName = 'ChatMessageList';
136
+
137
+
@@ -0,0 +1,69 @@
1
+ import * as React from 'react';
2
+ import { View, type ViewStyle } from 'react-native';
3
+
4
+ import type { ChatMessage } from '../models/types';
5
+ import { useTheme } from '../../theme';
6
+ import { ChatMessageList, type ChatMessageListProps, type ChatMessageListRef } from './ChatMessageList';
7
+ import { ChatComposer, type ChatComposerProps } from './ChatComposer';
8
+
9
+ export type ChatPageProps = {
10
+ header?: React.ReactNode;
11
+ messages: ChatMessage[];
12
+ showTypingIndicator?: boolean;
13
+ renderMessageContent?: ChatMessageListProps['renderMessageContent'];
14
+ topBanner?: React.ReactNode;
15
+ composer: Omit<ChatComposerProps, 'attachments'> & {
16
+ attachments?: ChatComposerProps['attachments'];
17
+ };
18
+ /**
19
+ * Optional overlay (e.g. ScrollToBottomButton).
20
+ */
21
+ overlay?: React.ReactNode;
22
+ style?: ViewStyle;
23
+ onNearBottomChange?: ChatMessageListProps['onNearBottomChange'];
24
+ listRef?: React.RefObject<ChatMessageListRef | null>;
25
+ };
26
+
27
+ export function ChatPage({
28
+ header,
29
+ messages,
30
+ showTypingIndicator,
31
+ renderMessageContent,
32
+ topBanner,
33
+ composer,
34
+ overlay,
35
+ style,
36
+ onNearBottomChange,
37
+ listRef,
38
+ }: ChatPageProps) {
39
+ const theme = useTheme();
40
+ const [composerHeight, setComposerHeight] = React.useState(0);
41
+ return (
42
+ <View style={[{ flex: 1 }, style]}>
43
+ {header ? <View>{header}</View> : null}
44
+ {topBanner ? (
45
+ <View style={{ paddingHorizontal: theme.spacing.lg, paddingTop: theme.spacing.sm }}>
46
+ {topBanner}
47
+ </View>
48
+ ) : null}
49
+ <View style={{ flex: 1 }}>
50
+ <ChatMessageList
51
+ ref={listRef}
52
+ messages={messages}
53
+ showTypingIndicator={showTypingIndicator}
54
+ renderMessageContent={renderMessageContent}
55
+ onNearBottomChange={onNearBottomChange}
56
+ contentStyle={{ paddingBottom: theme.spacing.xl + composerHeight }}
57
+ />
58
+ {overlay}
59
+ </View>
60
+ <ChatComposer
61
+ {...composer}
62
+ attachments={composer.attachments ?? []}
63
+ onLayout={({ height }) => setComposerHeight(height)}
64
+ />
65
+ </View>
66
+ );
67
+ }
68
+
69
+
@@ -0,0 +1,66 @@
1
+ import * as React from 'react';
2
+ import { View, type ViewStyle } from 'react-native';
3
+
4
+ import { useTheme } from '../../theme';
5
+ import { Card } from '../primitives/Card';
6
+ import { Text } from '../primitives/Text';
7
+
8
+ export type ForkNoticeBannerProps = {
9
+ isOwner?: boolean;
10
+ title?: string;
11
+ description?: string | null;
12
+ style?: ViewStyle;
13
+ };
14
+
15
+ export function ForkNoticeBanner({ isOwner = true, title, description, style }: ForkNoticeBannerProps) {
16
+ const theme = useTheme();
17
+ const resolvedTitle = title ?? (isOwner ? 'Remixed app' : 'Remix app');
18
+ const resolvedDescription =
19
+ description ??
20
+ (isOwner
21
+ ? 'Any changes you make will be a remix of the original app. You can view the edited version in the Remix tab in your apps page.'
22
+ : 'Once you make edits, this remixed version will appear on your Remixed apps page.');
23
+
24
+ return (
25
+ <Card
26
+ variant="surfaceRaised"
27
+ padded={false}
28
+ border
29
+ style={[
30
+ {
31
+ width: '100%',
32
+ paddingHorizontal: theme.spacing.lg,
33
+ paddingTop: 14,
34
+ paddingBottom: 8,
35
+ },
36
+ style,
37
+ ]}
38
+ >
39
+ <View style={{ minWidth: 0 }}>
40
+ <Text
41
+ style={{
42
+ color: '#22C55E', // green-500
43
+ fontSize: 14,
44
+ lineHeight: 18,
45
+ fontWeight: theme.typography.fontWeight.medium,
46
+ marginBottom: 4,
47
+ }}
48
+ >
49
+ {resolvedTitle}
50
+ </Text>
51
+ <Text
52
+ style={{
53
+ color: theme.colors.textMuted,
54
+ fontSize: 14,
55
+ lineHeight: 20,
56
+ paddingBottom: 6,
57
+ }}
58
+ >
59
+ {resolvedDescription}
60
+ </Text>
61
+ </View>
62
+ </Card>
63
+ );
64
+ }
65
+
66
+