@comergehq/studio 0.1.28 → 0.1.30

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.
@@ -1,7 +1,8 @@
1
1
  import * as React from 'react';
2
- import { Keyboard, Platform, View, useWindowDimensions } from 'react-native';
2
+ import { InteractionManager, Keyboard, Platform, View, useWindowDimensions } from 'react-native';
3
3
 
4
4
  import type { App } from '../../data/apps/types';
5
+ import type { RelatedApps } from '../../data/apps/types';
5
6
  import type { MergeRequest } from '../../data/merge-requests/types';
6
7
  import { StudioBottomSheet } from '../../components/studio-sheet/StudioBottomSheet';
7
8
  import { StudioSheetPager } from '../../components/studio-sheet/StudioSheetPager';
@@ -70,6 +71,11 @@ export type StudioOverlayProps = {
70
71
  onNavigateHome?: () => void;
71
72
  showBubble: boolean;
72
73
  studioControlOptions?: StudioControlOptions;
74
+ relatedApps?: RelatedApps | null;
75
+ relatedAppsLoading?: boolean;
76
+ switchingRelatedAppId?: string | null;
77
+ onOpenRelatedApps?: () => void;
78
+ onSwitchRelatedApp?: (targetAppId: string) => void;
73
79
  };
74
80
 
75
81
  type SheetPage = 'preview' | 'chat';
@@ -110,12 +116,18 @@ export function StudioOverlay({
110
116
  onNavigateHome,
111
117
  showBubble,
112
118
  studioControlOptions,
119
+ relatedApps,
120
+ relatedAppsLoading,
121
+ switchingRelatedAppId,
122
+ onOpenRelatedApps,
123
+ onSwitchRelatedApp,
113
124
  }: StudioOverlayProps) {
114
125
  const theme = useTheme();
115
126
  const { width } = useWindowDimensions();
116
127
 
117
128
  const [sheetOpen, setSheetOpen] = React.useState(false);
118
129
  const sheetOpenRef = React.useRef(sheetOpen);
130
+ const pendingNavigateHomeRef = React.useRef(false);
119
131
  const [activePage, setActivePage] = React.useState<SheetPage>('preview');
120
132
 
121
133
  const [drawing, setDrawing] = React.useState(false);
@@ -214,6 +226,50 @@ export function StudioOverlay({
214
226
  [closeSheet, onTestMr]
215
227
  );
216
228
 
229
+ const handleNavigateHome = React.useCallback(() => {
230
+ if (!onNavigateHome) return;
231
+
232
+ if (Platform.OS !== 'android') {
233
+ onNavigateHome();
234
+ return;
235
+ }
236
+
237
+ // On Android Fabric, navigate only after the sheet fully dismisses.
238
+ if (!sheetOpenRef.current) {
239
+ InteractionManager.runAfterInteractions(() => {
240
+ onNavigateHome();
241
+ });
242
+ return;
243
+ }
244
+
245
+ pendingNavigateHomeRef.current = true;
246
+ Keyboard.dismiss();
247
+ setActivePage('preview');
248
+ closeSheet();
249
+ }, [closeSheet, onNavigateHome]);
250
+
251
+ const handleSheetDismiss = React.useCallback(() => {
252
+ if (Platform.OS !== 'android') return;
253
+ if (!pendingNavigateHomeRef.current) return;
254
+ pendingNavigateHomeRef.current = false;
255
+ InteractionManager.runAfterInteractions(() => {
256
+ onNavigateHome?.();
257
+ });
258
+ }, [onNavigateHome]);
259
+
260
+ React.useEffect(() => {
261
+ if (!sheetOpen) {
262
+ return;
263
+ }
264
+ pendingNavigateHomeRef.current = false;
265
+ }, [sheetOpen]);
266
+
267
+ React.useEffect(() => {
268
+ return () => {
269
+ pendingNavigateHomeRef.current = false;
270
+ };
271
+ }, []);
272
+
217
273
  React.useEffect(() => {
218
274
  sheetOpenRef.current = sheetOpen;
219
275
  }, [sheetOpen]);
@@ -236,7 +292,7 @@ export function StudioOverlay({
236
292
  {/* Testing glow around runtime */}
237
293
  <EdgeGlowFrame visible={isTesting} role="accent" thickness={40} intensity={1} />
238
294
 
239
- <StudioBottomSheet open={sheetOpen} onOpenChange={handleSheetOpenChange}>
295
+ <StudioBottomSheet open={sheetOpen} onOpenChange={handleSheetOpenChange} onDismiss={handleSheetDismiss}>
240
296
  <StudioSheetPager
241
297
  activePage={activePage}
242
298
  width={width}
@@ -254,7 +310,7 @@ export function StudioOverlay({
254
310
  testingMrId={testingMrId}
255
311
  toMergeRequestSummary={toMergeRequestSummary}
256
312
  onClose={closeSheet}
257
- onNavigateHome={onNavigateHome}
313
+ onNavigateHome={onNavigateHome ? handleNavigateHome : undefined}
258
314
  onGoToChat={goToChat}
259
315
  onStartDraw={isOwner ? startDraw : undefined}
260
316
  onSubmitMergeRequest={onSubmitMergeRequest}
@@ -266,6 +322,11 @@ export function StudioOverlay({
266
322
  onTestMr={handleTestMr}
267
323
  onOpenComments={() => setCommentsAppId(app?.id ?? null)}
268
324
  commentCountOverride={commentsCount ?? undefined}
325
+ relatedApps={relatedApps}
326
+ relatedAppsLoading={relatedAppsLoading}
327
+ switchingRelatedAppId={switchingRelatedAppId}
328
+ onOpenRelatedApps={onOpenRelatedApps}
329
+ onSwitchRelatedApp={onSwitchRelatedApp}
269
330
  />
270
331
  }
271
332
  chat={
@@ -282,7 +343,7 @@ export function StudioOverlay({
282
343
  onClearAttachments={() => setChatAttachments([])}
283
344
  onBack={backToPreview}
284
345
  onClose={closeSheet}
285
- onNavigateHome={onNavigateHome}
346
+ onNavigateHome={onNavigateHome ? handleNavigateHome : undefined}
286
347
  onStartDraw={startDraw}
287
348
  onSend={optimistic.onSend}
288
349
  onRetryMessage={optimistic.onRetry}
@@ -1,9 +1,14 @@
1
1
  import * as React from 'react';
2
- import { View } from 'react-native';
2
+ import { ActivityIndicator, Pressable, View } from 'react-native';
3
3
 
4
+ import type { RelatedApp, RelatedApps } from '../../../data/apps/types';
4
5
  import { StudioSheetHeader } from '../../../components/studio-sheet/StudioSheetHeader';
5
6
  import { StudioSheetHeaderIconButton } from '../../../components/studio-sheet/StudioSheetHeaderIconButton';
6
- import { IconChat, IconClose, IconHome, IconShare } from '../../../components/icons/StudioIcons';
7
+ import { IconChat, IconChevronDown, IconClose, IconHome, IconShare } from '../../../components/icons/StudioIcons';
8
+ import { Modal } from '../../../components/primitives/Modal';
9
+ import { Text } from '../../../components/primitives/Text';
10
+ import { PreviewStatusBadge } from '../../../components/preview/PreviewStatusBadge';
11
+ import { useTheme } from '../../../theme';
7
12
 
8
13
  export type PreviewPanelHeaderProps = {
9
14
  isOwner: boolean;
@@ -12,6 +17,16 @@ export type PreviewPanelHeaderProps = {
12
17
  onNavigateHome?: () => void;
13
18
  onGoToChat: () => void;
14
19
  onShare?: () => void;
20
+ relatedApps?: RelatedApps | null;
21
+ relatedAppsLoading?: boolean;
22
+ switchingRelatedAppId?: string | null;
23
+ onOpenRelatedApps?: () => void;
24
+ onSwitchRelatedApp?: (targetAppId: string) => void;
25
+ };
26
+
27
+ type RelatedAppListItem = {
28
+ app: RelatedApp;
29
+ section: 'original' | 'remix';
15
30
  };
16
31
 
17
32
  export function PreviewPanelHeader({
@@ -21,47 +36,207 @@ export function PreviewPanelHeader({
21
36
  onNavigateHome,
22
37
  onGoToChat,
23
38
  onShare,
39
+ relatedApps,
40
+ relatedAppsLoading,
41
+ switchingRelatedAppId,
42
+ onOpenRelatedApps,
43
+ onSwitchRelatedApp,
24
44
  }: PreviewPanelHeaderProps) {
25
- return (
26
- <StudioSheetHeader
27
- left={
28
- onNavigateHome ? (
29
- <StudioSheetHeaderIconButton onPress={onNavigateHome} accessibilityLabel="Home" appearance="glass" intent="primary">
30
- <IconHome size={20} colorToken="onPrimary" />
31
- </StudioSheetHeaderIconButton>
32
- ) : null
45
+ const theme = useTheme();
46
+ const [relatedAppsOpen, setRelatedAppsOpen] = React.useState(false);
47
+
48
+ const relatedAppItems = React.useMemo((): RelatedAppListItem[] => {
49
+ if (!relatedApps) return [];
50
+ const items: RelatedAppListItem[] = [];
51
+ if (relatedApps.original) {
52
+ items.push({ app: relatedApps.original, section: 'original' });
53
+ }
54
+ for (const remix of relatedApps.remixes) {
55
+ items.push({ app: remix, section: 'remix' });
56
+ }
57
+ return items;
58
+ }, [relatedApps]);
59
+
60
+ const uniqueRelatedCount = React.useMemo(() => {
61
+ return new Set(relatedAppItems.map((item) => item.app.id)).size;
62
+ }, [relatedAppItems]);
63
+
64
+ const shouldShowRelatedApps = uniqueRelatedCount >= 2;
65
+
66
+ const currentAppId = relatedApps?.current.id;
67
+ const originalAppId = relatedApps?.original?.id ?? null;
68
+
69
+ const sectionedRelatedApps = React.useMemo(() => {
70
+ const original: RelatedAppListItem[] = [];
71
+ const remixes: RelatedAppListItem[] = [];
72
+ const seenIds = new Set<string>();
73
+ for (const item of relatedAppItems) {
74
+ if (seenIds.has(item.app.id)) continue;
75
+ seenIds.add(item.app.id);
76
+ if (item.section === 'original') {
77
+ original.push(item);
78
+ } else {
79
+ remixes.push(item);
33
80
  }
34
- center={null}
35
- right={
36
- <View style={{ flexDirection: 'row', alignItems: 'center' }}>
37
- {isOwner ? (
38
- <StudioSheetHeaderIconButton
39
- onPress={onGoToChat}
40
- accessibilityLabel="Chat"
41
- intent="primary"
42
- appearance="glass"
43
- style={{ marginRight: 8 }}
44
- >
45
- <IconChat size={20} colorToken="onPrimary" />
81
+ }
82
+ return { original, remixes };
83
+ }, [relatedAppItems]);
84
+
85
+ const openRelatedApps = React.useCallback(() => {
86
+ setRelatedAppsOpen(true);
87
+ onOpenRelatedApps?.();
88
+ }, [onOpenRelatedApps]);
89
+
90
+ const closeRelatedApps = React.useCallback(() => {
91
+ setRelatedAppsOpen(false);
92
+ }, []);
93
+
94
+ const handleSelectRelatedApp = React.useCallback(
95
+ (targetAppId: string) => {
96
+ if (!relatedApps) return;
97
+ if (targetAppId === relatedApps.current.id) return;
98
+ onSwitchRelatedApp?.(targetAppId);
99
+ setRelatedAppsOpen(false);
100
+ },
101
+ [onSwitchRelatedApp, relatedApps]
102
+ );
103
+
104
+ const renderRelatedRow = React.useCallback(
105
+ (item: RelatedAppListItem) => {
106
+ const app = item.app;
107
+ const isCurrent = app.id === currentAppId;
108
+ const isOriginal = app.id === originalAppId;
109
+ const isReady = app.status === 'ready';
110
+ const isSwitching = switchingRelatedAppId === app.id;
111
+ const disabled = isCurrent || !isReady || Boolean(switchingRelatedAppId);
112
+
113
+ return (
114
+ <Pressable
115
+ key={app.id}
116
+ accessibilityRole="button"
117
+ accessibilityLabel={`Switch to ${app.name}`}
118
+ disabled={disabled}
119
+ onPress={() => handleSelectRelatedApp(app.id)}
120
+ style={{
121
+ borderRadius: theme.radii.md,
122
+ borderWidth: 1,
123
+ borderColor: theme.colors.border,
124
+ backgroundColor: theme.colors.surface,
125
+ paddingHorizontal: theme.spacing.md,
126
+ paddingVertical: theme.spacing.sm,
127
+ marginBottom: theme.spacing.sm,
128
+ opacity: disabled ? 0.6 : 1,
129
+ }}
130
+ >
131
+ <View style={{ flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', gap: 8 }}>
132
+ <View style={{ flex: 1, paddingRight: 8 }}>
133
+ <Text numberOfLines={1} style={{ color: theme.colors.text, fontWeight: theme.typography.fontWeight.semibold }}>
134
+ {app.name}
135
+ </Text>
136
+ <View style={{ height: 4 }} />
137
+ <View style={{ flexDirection: 'row', alignItems: 'center', flexWrap: 'wrap', gap: 6 }}>
138
+ {isOriginal ? (
139
+ <View style={{ borderRadius: 999, paddingHorizontal: 8, paddingVertical: 2, backgroundColor: theme.colors.neutral }}>
140
+ <Text style={{ color: theme.colors.textMuted, fontSize: 11 }}>Original</Text>
141
+ </View>
142
+ ) : null}
143
+ {isCurrent ? (
144
+ <View style={{ borderRadius: 999, paddingHorizontal: 8, paddingVertical: 2, backgroundColor: theme.colors.primary }}>
145
+ <Text style={{ color: theme.colors.onPrimary, fontSize: 11 }}>Current</Text>
146
+ </View>
147
+ ) : null}
148
+ </View>
149
+ </View>
150
+ <View style={{ alignItems: 'flex-end', gap: 8 }}>
151
+ {app.status ? <PreviewStatusBadge status={app.status} /> : null}
152
+ {isSwitching ? <ActivityIndicator size="small" color={theme.colors.primary} /> : null}
153
+ </View>
154
+ </View>
155
+ </Pressable>
156
+ );
157
+ },
158
+ [currentAppId, handleSelectRelatedApp, originalAppId, switchingRelatedAppId, theme]
159
+ );
160
+
161
+ return (
162
+ <>
163
+ <StudioSheetHeader
164
+ left={
165
+ onNavigateHome ? (
166
+ <StudioSheetHeaderIconButton onPress={onNavigateHome} accessibilityLabel="Home" appearance="glass" intent="primary">
167
+ <IconHome size={20} colorToken="onPrimary" />
46
168
  </StudioSheetHeaderIconButton>
47
- ) : null}
48
- {isPublic && onShare ? (
49
- <StudioSheetHeaderIconButton
50
- onPress={onShare}
51
- accessibilityLabel="Share"
52
- intent="primary"
53
- appearance="glass"
54
- style={{ marginRight: 8 }}
55
- >
56
- <IconShare size={20} colorToken="onPrimary" />
169
+ ) : null
170
+ }
171
+ center={null}
172
+ right={
173
+ <View style={{ flexDirection: 'row', alignItems: 'center' }}>
174
+ {isOwner ? (
175
+ <StudioSheetHeaderIconButton
176
+ onPress={onGoToChat}
177
+ accessibilityLabel="Chat"
178
+ intent="primary"
179
+ appearance="glass"
180
+ style={{ marginRight: 8 }}
181
+ >
182
+ <IconChat size={20} colorToken="onPrimary" />
183
+ </StudioSheetHeaderIconButton>
184
+ ) : null}
185
+ {isPublic && onShare ? (
186
+ <StudioSheetHeaderIconButton
187
+ onPress={onShare}
188
+ accessibilityLabel="Share"
189
+ intent="primary"
190
+ appearance="glass"
191
+ style={{ marginRight: 8 }}
192
+ >
193
+ <IconShare size={20} colorToken="onPrimary" />
194
+ </StudioSheetHeaderIconButton>
195
+ ) : null}
196
+ {shouldShowRelatedApps ? (
197
+ <StudioSheetHeaderIconButton
198
+ onPress={openRelatedApps}
199
+ accessibilityLabel="Related apps"
200
+ intent="primary"
201
+ appearance="glass"
202
+ style={{ marginRight: 8 }}
203
+ >
204
+ {relatedAppsLoading ? (
205
+ <ActivityIndicator size="small" color={theme.colors.onPrimary} />
206
+ ) : (
207
+ <IconChevronDown size={20} colorToken="onPrimary" />
208
+ )}
209
+ </StudioSheetHeaderIconButton>
210
+ ) : null}
211
+ <StudioSheetHeaderIconButton onPress={onClose} accessibilityLabel="Close" appearance="glass" intent="primary">
212
+ <IconClose size={20} colorToken="onPrimary" />
57
213
  </StudioSheetHeaderIconButton>
214
+ </View>
215
+ }
216
+ />
217
+
218
+ <Modal visible={relatedAppsOpen} onRequestClose={closeRelatedApps}>
219
+ <View style={{ gap: theme.spacing.sm }}>
220
+ <Text style={{ color: theme.colors.text, fontSize: 18, fontWeight: theme.typography.fontWeight.semibold }}>
221
+ Related apps
222
+ </Text>
223
+
224
+ {sectionedRelatedApps.original.length > 0 ? (
225
+ <View>
226
+ <Text style={{ color: theme.colors.textMuted, marginBottom: theme.spacing.xs }}>Original</Text>
227
+ {sectionedRelatedApps.original.map(renderRelatedRow)}
228
+ </View>
229
+ ) : null}
230
+
231
+ {sectionedRelatedApps.remixes.length > 0 ? (
232
+ <View>
233
+ <Text style={{ color: theme.colors.textMuted, marginBottom: theme.spacing.xs }}>Remixes</Text>
234
+ {sectionedRelatedApps.remixes.map(renderRelatedRow)}
235
+ </View>
58
236
  ) : null}
59
- <StudioSheetHeaderIconButton onPress={onClose} accessibilityLabel="Close" appearance="glass" intent="primary">
60
- <IconClose size={20} colorToken="onPrimary" />
61
- </StudioSheetHeaderIconButton>
62
237
  </View>
63
- }
64
- />
238
+ </Modal>
239
+ </>
65
240
  );
66
241
  }
67
242