@comergehq/studio 0.1.19 → 0.1.21

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@comergehq/studio",
3
- "version": "0.1.19",
3
+ "version": "0.1.21",
4
4
  "description": "Comerge studio",
5
5
  "main": "src/index.ts",
6
6
  "module": "dist/index.mjs",
@@ -25,6 +25,9 @@ export function ChatMessageBubble({ message, renderContent, style }: ChatMessage
25
25
  const isMergeRejected = metaEvent === 'merge_request.rejected';
26
26
  const isMergeCompleted = metaEvent === 'merge.completed';
27
27
 
28
+ const isSyncStarted = metaEvent === 'sync.started';
29
+ const isSyncCompleted = metaEvent === 'sync.completed';
30
+
28
31
  const isHuman = message.author === 'human' || isMergeApproved || isMergeRejected;
29
32
 
30
33
  const align: ViewStyle = { alignSelf: isHuman ? 'flex-end' : 'flex-start' };
@@ -51,10 +54,10 @@ export function ChatMessageBubble({ message, renderContent, style }: ChatMessage
51
54
  ]}
52
55
  >
53
56
  <View style={{ flexDirection: 'row', alignItems: 'center' }}>
54
- {isMergeCompleted ? (
57
+ {isMergeCompleted || isSyncCompleted ? (
55
58
  <CheckCheck size={16} color={theme.colors.success} style={{ marginRight: theme.spacing.sm }} />
56
59
  ) : null}
57
- {isMergeApproved ? (
60
+ {isMergeApproved || isSyncStarted ? (
58
61
  <GitMerge size={16} color={theme.colors.text} style={{ marginRight: theme.spacing.sm }} />
59
62
  ) : null}
60
63
  <View style={{ flexShrink: 1, minWidth: 0 }}>
@@ -14,6 +14,7 @@ import type {
14
14
  ListLikedAppsParams,
15
15
  ListPublicAppsParams,
16
16
  LikedAppsList,
17
+ SyncUpstreamResponse,
17
18
  } from './types';
18
19
 
19
20
  export interface AppsRemoteDataSource {
@@ -26,6 +27,7 @@ export interface AppsRemoteDataSource {
26
27
  getInsights(appId: string): Promise<ServiceResponse<AppInsights>>;
27
28
  getAnalytics(appId: string, params: AppAnalyticsParams): Promise<ServiceResponse<AppAnalyticsPoint[]>>;
28
29
  importFromGithub(payload: ImportGithubAppRequest): Promise<ServiceResponse<ImportGithubAppResponse>>;
30
+ syncUpstream(appId: string): Promise<ServiceResponse<SyncUpstreamResponse>>;
29
31
  }
30
32
 
31
33
  class AppsRemoteDataSourceImpl extends BaseRemote implements AppsRemoteDataSource {
@@ -90,6 +92,13 @@ class AppsRemoteDataSourceImpl extends BaseRemote implements AppsRemoteDataSourc
90
92
  );
91
93
  return data;
92
94
  }
95
+
96
+ async syncUpstream(appId: string): Promise<ServiceResponse<SyncUpstreamResponse>> {
97
+ const { data } = await api.post<ServiceResponse<SyncUpstreamResponse>>(
98
+ `/v1/apps/${encodeURIComponent(appId)}/sync-upstream`,
99
+ );
100
+ return data;
101
+ }
93
102
  }
94
103
 
95
104
  export const appsRemoteDataSource: AppsRemoteDataSource = new AppsRemoteDataSourceImpl();
@@ -11,6 +11,7 @@ import type {
11
11
  ListLikedAppsParams,
12
12
  ListPublicAppsParams,
13
13
  LikedAppsList,
14
+ SyncUpstreamResponse,
14
15
  } from './types';
15
16
  import { appsRemoteDataSource } from './remote';
16
17
  import type { AppsRemoteDataSource } from './remote';
@@ -82,6 +83,7 @@ export interface AppsRepository {
82
83
  subscribeCreatedApps(userId: string, handlers: AppSubscriptionHandlers): () => void;
83
84
  subscribeApp(appId: string, handlers: AppSubscriptionHandlers): () => void;
84
85
  importFromGithub(payload: ImportGithubAppRequest): Promise<ImportGithubAppResponse>;
86
+ syncUpstream(appId: string): Promise<SyncUpstreamResponse>;
85
87
  }
86
88
 
87
89
  class AppsRepositoryImpl extends BaseRepository implements AppsRepository {
@@ -134,6 +136,11 @@ class AppsRepositoryImpl extends BaseRepository implements AppsRepository {
134
136
  return this.unwrapOrThrow(res);
135
137
  }
136
138
 
139
+ async syncUpstream(appId: string): Promise<SyncUpstreamResponse> {
140
+ const res = await this.remote.syncUpstream(appId);
141
+ return this.unwrapOrThrow(res);
142
+ }
143
+
137
144
  subscribeCreatedApps(userId: string, handlers: AppSubscriptionHandlers): () => void {
138
145
  if (!userId) return () => {};
139
146
  return this.subscribeToAppChannel(`apps:createdBy:${userId}`, `created_by=eq.${userId}`, handlers);
@@ -29,6 +29,8 @@ export type MergeRequestEntry = {
29
29
  createdAt: string;
30
30
  };
31
31
 
32
+ export type SyncRequestEntry = MergeRequestEntry;
33
+
32
34
  export type LikeEntry = {
33
35
  userId: string;
34
36
  createdAt: string;
@@ -63,6 +65,12 @@ export type AppInsights = {
63
65
  merged: number;
64
66
  entries: MergeRequestEntry[];
65
67
  };
68
+ syncs: {
69
+ total: number;
70
+ approved: number;
71
+ merged: number;
72
+ entries: SyncRequestEntry[];
73
+ };
66
74
  likes: {
67
75
  total: number;
68
76
  entries: LikeEntry[];
@@ -137,6 +145,13 @@ export type ForkAppRequest = {
137
145
  forkedFromCommitId?: string;
138
146
  };
139
147
 
148
+ export type SyncUpstreamStatus = 'up-to-date' | 'queued';
149
+
150
+ export type SyncUpstreamResponse = {
151
+ status: SyncUpstreamStatus;
152
+ mergeRequestId?: string;
153
+ };
154
+
140
155
  export type ImportGithubAppRequest = {
141
156
  repoFullName: string;
142
157
  branch?: string;
@@ -16,6 +16,8 @@ import { StudioOverlay } from './ui/StudioOverlay';
16
16
  import { LiquidGlassResetProvider } from '../components/utils/liquidGlassReset';
17
17
  import { useEditQueue } from './hooks/useEditQueue';
18
18
  import { useEditQueueActions } from './hooks/useEditQueueActions';
19
+ import { appsRepository } from '../data/apps/repository';
20
+ import type { SyncUpstreamStatus } from '../data/apps/types';
19
21
 
20
22
  export type ComergeStudioProps = {
21
23
  appId: string;
@@ -245,6 +247,8 @@ function ComergeStudioInner({
245
247
 
246
248
  const [processingMrId, setProcessingMrId] = React.useState<string | null>(null);
247
249
  const [testingMrId, setTestingMrId] = React.useState<string | null>(null);
250
+ const [syncingUpstream, setSyncingUpstream] = React.useState(false);
251
+ const [upstreamSyncStatus, setUpstreamSyncStatus] = React.useState<SyncUpstreamStatus | null>(null);
248
252
 
249
253
  // Show typing dots when the last message isn't an outcome (agent still working).
250
254
  const chatShowTypingIndicator = React.useMemo(() => {
@@ -257,8 +261,23 @@ function ComergeStudioInner({
257
261
  React.useEffect(() => {
258
262
  updateLastEditQueueInfo(null);
259
263
  setSuppressQueueUntilResponse(false);
264
+ setUpstreamSyncStatus(null);
260
265
  }, [activeAppId, updateLastEditQueueInfo]);
261
266
 
267
+ const handleSyncUpstream = React.useCallback(async () => {
268
+ if (!app?.id) {
269
+ throw new Error('Missing app');
270
+ }
271
+ setSyncingUpstream(true);
272
+ try {
273
+ const result = await appsRepository.syncUpstream(activeAppId);
274
+ setUpstreamSyncStatus(result.status);
275
+ return result;
276
+ } finally {
277
+ setSyncingUpstream(false);
278
+ }
279
+ }, [activeAppId, app?.id]);
280
+
262
281
  React.useEffect(() => {
263
282
  if (!lastEditQueueInfo?.queueItemId) return;
264
283
  const stillPresent = editQueue.items.some((item) => item.id === lastEditQueueInfo.queueItemId);
@@ -320,6 +339,9 @@ function ComergeStudioInner({
320
339
  }
321
340
  : undefined
322
341
  }
342
+ onSyncUpstream={actions.isOwner && app?.forkedFromAppId ? handleSyncUpstream : undefined}
343
+ syncingUpstream={syncingUpstream}
344
+ upstreamSyncStatus={upstreamSyncStatus}
323
345
  onApprove={async (mr) => {
324
346
  if (processingMrId) return;
325
347
  setProcessingMrId(mr.id);
@@ -1,7 +1,7 @@
1
1
  import * as React from 'react';
2
2
  import { ActivityIndicator, Platform, Share, View } from 'react-native';
3
3
 
4
- import type { App } from '../../data/apps/types';
4
+ import type { App, SyncUpstreamStatus } from '../../data/apps/types';
5
5
  import type { MergeRequest } from '../../data/merge-requests/types';
6
6
  import { log } from '../../core/logger';
7
7
  import { PreviewPage } from '../../components/preview/PreviewPage';
@@ -30,6 +30,9 @@ export type PreviewPanelProps = {
30
30
  onGoToChat: () => void;
31
31
  onStartDraw?: () => void;
32
32
  onSubmitMergeRequest?: () => void | Promise<void>;
33
+ onSyncUpstream?: () => Promise<{ status: SyncUpstreamStatus }>;
34
+ syncingUpstream?: boolean;
35
+ upstreamSyncStatus?: SyncUpstreamStatus | null;
33
36
  onRequestApprove?: (mr: MergeRequest) => void;
34
37
  onReject?: (mr: MergeRequest) => void | Promise<void>;
35
38
  onTestMr?: (mr: MergeRequest) => void | Promise<void>;
@@ -54,6 +57,9 @@ export function PreviewPanel({
54
57
  onGoToChat,
55
58
  onStartDraw,
56
59
  onSubmitMergeRequest,
60
+ onSyncUpstream,
61
+ syncingUpstream,
62
+ upstreamSyncStatus,
57
63
  onRequestApprove,
58
64
  onReject,
59
65
  onTestMr,
@@ -63,9 +69,9 @@ export function PreviewPanel({
63
69
  const handleShare = React.useCallback(async () => {
64
70
  if (!app || !app.isPublic) return;
65
71
  const shareUrl = `https://remix.one/app/${app.id}`;
66
- const message = app.name ? `${app.name} on Comerge\n${shareUrl}` : `Check out this app on Comerge\n${shareUrl}`;
72
+ const message = app.name ? `${app.name} on Remix\n${shareUrl}` : `Check out this app on Remix\n${shareUrl}`;
67
73
  try {
68
- const title = app.name ?? 'Comerge app';
74
+ const title = app.name ?? 'Remix app';
69
75
  const payload =
70
76
  Platform.OS === 'ios'
71
77
  ? {
@@ -83,7 +89,17 @@ export function PreviewPanel({
83
89
  }
84
90
  }, [app]);
85
91
 
86
- const { imageUrl, imageLoaded, setImageLoaded, creator, insights, stats, showProcessing, canSubmitMergeRequest } = usePreviewPanelData({
92
+ const {
93
+ imageUrl,
94
+ imageLoaded,
95
+ setImageLoaded,
96
+ creator,
97
+ insights,
98
+ stats,
99
+ showProcessing,
100
+ canSubmitMergeRequest,
101
+ canSyncUpstream,
102
+ } = usePreviewPanelData({
87
103
  app,
88
104
  isOwner,
89
105
  outgoingMergeRequests,
@@ -145,6 +161,9 @@ export function PreviewPanel({
145
161
 
146
162
  <PreviewCollaborateSection
147
163
  canSubmitMergeRequest={canSubmitMergeRequest}
164
+ canSyncUpstream={canSyncUpstream}
165
+ syncingUpstream={syncingUpstream}
166
+ upstreamSyncStatus={upstreamSyncStatus}
148
167
  incomingMergeRequests={incomingMergeRequests}
149
168
  outgoingMergeRequests={outgoingMergeRequests}
150
169
  creatorStatsById={creatorStatsById}
@@ -153,6 +172,7 @@ export function PreviewPanel({
153
172
  testingMrId={testingMrId}
154
173
  toMergeRequestSummary={toMergeRequestSummary}
155
174
  onSubmitMergeRequest={onSubmitMergeRequest}
175
+ onSyncUpstream={onSyncUpstream}
156
176
  onRequestApprove={onRequestApprove}
157
177
  onReject={onReject}
158
178
  onTestMr={onTestMr}
@@ -46,6 +46,9 @@ export type StudioOverlayProps = {
46
46
  testingMrId?: string | null;
47
47
  toMergeRequestSummary: (mr: MergeRequest) => MergeRequestSummary;
48
48
  onSubmitMergeRequest?: () => void | Promise<void>;
49
+ onSyncUpstream?: () => Promise<{ status: import('../../data/apps/types').SyncUpstreamStatus }>;
50
+ syncingUpstream?: boolean;
51
+ upstreamSyncStatus?: import('../../data/apps/types').SyncUpstreamStatus | null;
49
52
  onApprove?: (mr: MergeRequest) => void | Promise<void>;
50
53
  onReject?: (mr: MergeRequest) => void | Promise<void>;
51
54
  onTestMr?: (mr: MergeRequest) => void | Promise<void>;
@@ -85,6 +88,9 @@ export function StudioOverlay({
85
88
  testingMrId,
86
89
  toMergeRequestSummary,
87
90
  onSubmitMergeRequest,
91
+ onSyncUpstream,
92
+ syncingUpstream,
93
+ upstreamSyncStatus,
88
94
  onApprove,
89
95
  onReject,
90
96
  onTestMr,
@@ -248,6 +254,9 @@ export function StudioOverlay({
248
254
  onGoToChat={goToChat}
249
255
  onStartDraw={isOwner ? startDraw : undefined}
250
256
  onSubmitMergeRequest={onSubmitMergeRequest}
257
+ onSyncUpstream={onSyncUpstream}
258
+ syncingUpstream={syncingUpstream}
259
+ upstreamSyncStatus={upstreamSyncStatus}
251
260
  onRequestApprove={(mr) => setConfirmMrId(mr.id)}
252
261
  onReject={onReject}
253
262
  onTestMr={handleTestMr}
@@ -1,9 +1,10 @@
1
1
  import * as React from 'react';
2
2
  import { ActivityIndicator, Alert, View } from 'react-native';
3
- import { Send } from 'lucide-react-native';
3
+ import { RefreshCw, Send } from 'lucide-react-native';
4
4
 
5
5
  import type { MergeRequest } from '../../../data/merge-requests/types';
6
6
  import type { UserStats } from '../../../data/users/types';
7
+ import type { SyncUpstreamStatus } from '../../../data/apps/types';
7
8
  import { MergeRequestStatusCard } from '../../../components/merge-requests/MergeRequestStatusCard';
8
9
  import { ReviewMergeRequestCarousel } from '../../../components/merge-requests/ReviewMergeRequestCarousel';
9
10
  import { Text } from '../../../components/primitives/Text';
@@ -16,6 +17,9 @@ import { MergeIcon } from '../../../components/icons/MergeIcon';
16
17
 
17
18
  export type PreviewCollaborateSectionProps = {
18
19
  canSubmitMergeRequest: boolean;
20
+ canSyncUpstream: boolean;
21
+ syncingUpstream?: boolean;
22
+ upstreamSyncStatus?: SyncUpstreamStatus | null;
19
23
  incomingMergeRequests: MergeRequest[];
20
24
  outgoingMergeRequests: MergeRequest[];
21
25
  creatorStatsById: Record<string, UserStats>;
@@ -24,6 +28,7 @@ export type PreviewCollaborateSectionProps = {
24
28
  testingMrId?: string | null;
25
29
  toMergeRequestSummary: (mr: MergeRequest) => import('../../../components/models/types').MergeRequestSummary;
26
30
  onSubmitMergeRequest?: () => void | Promise<void>;
31
+ onSyncUpstream?: () => Promise<{ status: SyncUpstreamStatus }>;
27
32
  onRequestApprove?: (mr: MergeRequest) => void;
28
33
  onReject?: (mr: MergeRequest) => void | Promise<void>;
29
34
  onTestMr?: (mr: MergeRequest) => void | Promise<void>;
@@ -31,6 +36,9 @@ export type PreviewCollaborateSectionProps = {
31
36
 
32
37
  export function PreviewCollaborateSection({
33
38
  canSubmitMergeRequest,
39
+ canSyncUpstream,
40
+ syncingUpstream,
41
+ upstreamSyncStatus,
34
42
  incomingMergeRequests,
35
43
  outgoingMergeRequests,
36
44
  creatorStatsById,
@@ -39,17 +47,24 @@ export function PreviewCollaborateSection({
39
47
  testingMrId,
40
48
  toMergeRequestSummary,
41
49
  onSubmitMergeRequest,
50
+ onSyncUpstream,
42
51
  onRequestApprove,
43
52
  onReject,
44
53
  onTestMr,
45
54
  }: PreviewCollaborateSectionProps) {
46
55
  const theme = useTheme();
47
56
  const [submittingMr, setSubmittingMr] = React.useState(false);
57
+ const [syncingLocal, setSyncingLocal] = React.useState(false);
48
58
 
49
- const hasSection = canSubmitMergeRequest || incomingMergeRequests.length > 0 || outgoingMergeRequests.length > 0;
59
+ const hasSection =
60
+ canSubmitMergeRequest || canSyncUpstream || incomingMergeRequests.length > 0 || outgoingMergeRequests.length > 0;
50
61
  if (!hasSection) return null;
51
62
 
52
- const showActionsSubtitle = (canSubmitMergeRequest && onSubmitMergeRequest) || (onTestMr && incomingMergeRequests.length > 0);
63
+ const isSyncing = Boolean(syncingUpstream || syncingLocal);
64
+ const showActionsSubtitle =
65
+ (canSubmitMergeRequest && onSubmitMergeRequest) ||
66
+ (canSyncUpstream && onSyncUpstream) ||
67
+ (onTestMr && incomingMergeRequests.length > 0);
53
68
 
54
69
  return (
55
70
  <>
@@ -131,6 +146,83 @@ export function PreviewCollaborateSection({
131
146
  />
132
147
  ) : null}
133
148
 
149
+ {canSyncUpstream && onSyncUpstream ? (
150
+ <PressableCardRow
151
+ accessibilityLabel="Sync from original"
152
+ disabled={isSyncing}
153
+ onPress={() => {
154
+ Alert.alert(
155
+ 'Sync from Original',
156
+ 'This will pull the latest upstream changes into your remix.',
157
+ [
158
+ { text: 'Cancel', style: 'cancel' },
159
+ {
160
+ text: 'Sync',
161
+ style: 'destructive',
162
+ onPress: () => {
163
+ setSyncingLocal(true);
164
+ Promise.resolve(onSyncUpstream())
165
+ .then((result) => {
166
+ if (result?.status === 'up-to-date') {
167
+ Alert.alert('Up to date', 'Your remix already includes the latest upstream changes.');
168
+ } else {
169
+ Alert.alert('Sync started', 'Upstream changes are being merged into your remix.');
170
+ }
171
+ })
172
+ .catch(() => {
173
+ Alert.alert('Sync failed', 'We could not start the sync. Please try again.');
174
+ })
175
+ .finally(() => setSyncingLocal(false));
176
+ },
177
+ },
178
+ ]
179
+ );
180
+ }}
181
+ style={{
182
+ padding: theme.spacing.lg,
183
+ borderRadius: theme.radii.lg,
184
+ backgroundColor: withAlpha(theme.colors.surfaceRaised, 0.5),
185
+ borderWidth: 1,
186
+ borderColor: withAlpha(theme.colors.primary, 0.25),
187
+ marginBottom: theme.spacing.sm,
188
+ }}
189
+ left={
190
+ <View
191
+ style={{
192
+ width: 40,
193
+ height: 40,
194
+ borderRadius: 999,
195
+ alignItems: 'center',
196
+ justifyContent: 'center',
197
+ backgroundColor: withAlpha(theme.colors.primary, 0.12),
198
+ marginRight: theme.spacing.lg,
199
+ }}
200
+ >
201
+ {isSyncing ? (
202
+ <ActivityIndicator color={theme.colors.primary} size="small" />
203
+ ) : (
204
+ <RefreshCw size={18} color={theme.colors.primary} />
205
+ )}
206
+ </View>
207
+ }
208
+ title={
209
+ <Text style={{ color: theme.colors.text, fontSize: 16, lineHeight: 20, fontWeight: theme.typography.fontWeight.semibold }}>
210
+ Sync from Original
211
+ </Text>
212
+ }
213
+ subtitle={
214
+ <Text style={{ color: theme.colors.textMuted, fontSize: 12, lineHeight: 16, marginTop: 2 }}>
215
+ {isSyncing
216
+ ? 'Syncing upstream changes...'
217
+ : upstreamSyncStatus === 'up-to-date'
218
+ ? 'You are already up to date with the original app'
219
+ : 'Pull the latest upstream changes into this remix'}
220
+ </Text>
221
+ }
222
+ right={<RefreshCw size={16} color={theme.colors.primary} />}
223
+ />
224
+ ) : null}
225
+
134
226
  {onTestMr && incomingMergeRequests.length > 0 ? (
135
227
  <ReviewMergeRequestCarousel
136
228
  mergeRequests={incomingMergeRequests}
@@ -115,6 +115,13 @@ export function usePreviewPanelData(params: {
115
115
  return false;
116
116
  }, [app, isOwner, outgoingMergeRequests]);
117
117
 
118
+ const canSyncUpstream = React.useMemo(() => {
119
+ if (!isOwner) return false;
120
+ if (!app) return false;
121
+ if (!app.forkedFromAppId) return false;
122
+ return app.status === 'ready';
123
+ }, [app, isOwner]);
124
+
118
125
  const showProcessing = app ? app.status !== 'ready' : false;
119
126
 
120
127
  return {
@@ -126,6 +133,7 @@ export function usePreviewPanelData(params: {
126
133
  stats,
127
134
  showProcessing,
128
135
  canSubmitMergeRequest,
136
+ canSyncUpstream,
129
137
  };
130
138
  }
131
139