@comergehq/studio 0.1.20 → 0.1.22

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.20",
3
+ "version": "0.1.22",
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 }}>
@@ -0,0 +1,112 @@
1
+ import { log } from '../../logger';
2
+ import { getSupabaseClient } from './client';
3
+
4
+ type SupabaseClient = ReturnType<typeof getSupabaseClient>;
5
+ type RealtimeChannel = ReturnType<SupabaseClient['channel']>;
6
+ type ChannelConfigurer = (channel: RealtimeChannel) => void;
7
+
8
+ type ChannelEntry = {
9
+ key: string;
10
+ channel: RealtimeChannel | null;
11
+ subscribers: Map<number, ChannelConfigurer>;
12
+ backoffMs: number;
13
+ timer: ReturnType<typeof setTimeout> | null;
14
+ };
15
+
16
+ const INITIAL_BACKOFF_MS = 1000;
17
+ const MAX_BACKOFF_MS = 30000;
18
+
19
+ const realtimeLog = log.extend('realtime');
20
+ const entries = new Map<string, ChannelEntry>();
21
+ let subscriberIdCounter = 0;
22
+
23
+ function clearTimer(entry: ChannelEntry) {
24
+ if (!entry.timer) return;
25
+ clearTimeout(entry.timer);
26
+ entry.timer = null;
27
+ }
28
+
29
+ function buildChannel(entry: ChannelEntry): RealtimeChannel {
30
+ const supabase = getSupabaseClient();
31
+ const channel = supabase.channel(entry.key);
32
+ entry.subscribers.forEach((configure) => {
33
+ configure(channel);
34
+ });
35
+ return channel;
36
+ }
37
+
38
+ function scheduleResubscribe(entry: ChannelEntry, reason: string) {
39
+ if (entry.timer) return;
40
+ const delay = entry.backoffMs;
41
+ entry.backoffMs = Math.min(entry.backoffMs * 2, MAX_BACKOFF_MS);
42
+ realtimeLog.warn(`[realtime] channel ${entry.key} ${reason}; resubscribe in ${delay}ms`);
43
+ entry.timer = setTimeout(() => {
44
+ entry.timer = null;
45
+ if (!entries.has(entry.key)) return;
46
+ if (entry.subscribers.size === 0) return;
47
+ subscribeChannel(entry);
48
+ }, delay);
49
+ }
50
+
51
+ function handleStatus(entry: ChannelEntry, status: string) {
52
+ if (status === 'SUBSCRIBED') {
53
+ entry.backoffMs = INITIAL_BACKOFF_MS;
54
+ clearTimer(entry);
55
+ return;
56
+ }
57
+ if (status === 'CLOSED' || status === 'TIMED_OUT' || status === 'CHANNEL_ERROR') {
58
+ scheduleResubscribe(entry, status);
59
+ }
60
+ }
61
+
62
+ function subscribeChannel(entry: ChannelEntry) {
63
+ try {
64
+ const supabase = getSupabaseClient();
65
+ if (entry.channel) supabase.removeChannel(entry.channel);
66
+ const channel = buildChannel(entry);
67
+ entry.channel = channel;
68
+ channel.subscribe((status) => handleStatus(entry, status));
69
+ } catch (error) {
70
+ realtimeLog.warn('[realtime] subscribe failed', error);
71
+ scheduleResubscribe(entry, 'SUBSCRIBE_FAILED');
72
+ }
73
+ }
74
+
75
+ export function subscribeManagedChannel(key: string, configure: ChannelConfigurer): () => void {
76
+ let entry = entries.get(key);
77
+ if (!entry) {
78
+ entry = {
79
+ key,
80
+ channel: null,
81
+ subscribers: new Map(),
82
+ backoffMs: INITIAL_BACKOFF_MS,
83
+ timer: null,
84
+ };
85
+ entries.set(key, entry);
86
+ }
87
+
88
+ const subscriberId = ++subscriberIdCounter;
89
+ entry.subscribers.set(subscriberId, configure);
90
+
91
+ if (!entry.channel) {
92
+ subscribeChannel(entry);
93
+ } else {
94
+ configure(entry.channel);
95
+ }
96
+
97
+ return () => {
98
+ const current = entries.get(key);
99
+ if (!current) return;
100
+ current.subscribers.delete(subscriberId);
101
+ if (current.subscribers.size === 0) {
102
+ clearTimer(current);
103
+ try {
104
+ if (current.channel) getSupabaseClient().removeChannel(current.channel);
105
+ } finally {
106
+ entries.delete(key);
107
+ }
108
+ return;
109
+ }
110
+ subscribeChannel(current);
111
+ };
112
+ }
@@ -2,7 +2,7 @@ import type { EditQueueRemoteDataSource } from './remote';
2
2
  import { editQueueRemoteDataSource } from './remote';
3
3
  import type { EditQueueItem, EditQueueListResponse, UpdateEditQueueItemRequest } from './types';
4
4
  import { BaseRepository } from '../../base-repository';
5
- import { getSupabaseClient } from '../../../core/services/supabase';
5
+ import { subscribeManagedChannel } from '../../../core/services/supabase/realtimeManager';
6
6
  import type { AttachmentMeta } from '../../attachment/types';
7
7
 
8
8
  type DbAppJobQueueRow = {
@@ -89,45 +89,40 @@ class EditQueueRepositoryImpl extends BaseRepository implements EditQueueReposit
89
89
  onDelete?: (item: EditQueueItem) => void;
90
90
  }
91
91
  ): () => void {
92
- const supabase = getSupabaseClient();
93
- const channel = supabase
94
- .channel(`edit-queue:app:${appId}`)
95
- .on(
96
- 'postgres_changes',
97
- { event: 'INSERT', schema: 'public', table: 'app_job_queue', filter: `app_id=eq.${appId}` },
98
- (payload) => {
99
- const row = payload.new as DbAppJobQueueRow;
100
- if (row.kind !== 'edit') return;
101
- const item = mapQueueItem(row);
102
- if (!ACTIVE_STATUSES.includes(item.status)) return;
103
- handlers.onInsert?.(item);
104
- }
105
- )
106
- .on(
107
- 'postgres_changes',
108
- { event: 'UPDATE', schema: 'public', table: 'app_job_queue', filter: `app_id=eq.${appId}` },
109
- (payload) => {
110
- const row = payload.new as DbAppJobQueueRow;
111
- if (row.kind !== 'edit') return;
112
- const item = mapQueueItem(row);
113
- if (ACTIVE_STATUSES.includes(item.status)) handlers.onUpdate?.(item);
114
- else handlers.onDelete?.(item);
115
- }
116
- )
117
- .on(
118
- 'postgres_changes',
119
- { event: 'DELETE', schema: 'public', table: 'app_job_queue', filter: `app_id=eq.${appId}` },
120
- (payload) => {
121
- const row = payload.old as DbAppJobQueueRow;
122
- if (row.kind !== 'edit') return;
123
- handlers.onDelete?.(mapQueueItem(row));
124
- }
125
- )
126
- .subscribe();
127
-
128
- return () => {
129
- supabase.removeChannel(channel);
130
- };
92
+ return subscribeManagedChannel(`edit-queue:app:${appId}`, (channel) => {
93
+ channel
94
+ .on(
95
+ 'postgres_changes',
96
+ { event: 'INSERT', schema: 'public', table: 'app_job_queue', filter: `app_id=eq.${appId}` },
97
+ (payload) => {
98
+ const row = payload.new as DbAppJobQueueRow;
99
+ if (row.kind !== 'edit') return;
100
+ const item = mapQueueItem(row);
101
+ if (!ACTIVE_STATUSES.includes(item.status)) return;
102
+ handlers.onInsert?.(item);
103
+ }
104
+ )
105
+ .on(
106
+ 'postgres_changes',
107
+ { event: 'UPDATE', schema: 'public', table: 'app_job_queue', filter: `app_id=eq.${appId}` },
108
+ (payload) => {
109
+ const row = payload.new as DbAppJobQueueRow;
110
+ if (row.kind !== 'edit') return;
111
+ const item = mapQueueItem(row);
112
+ if (ACTIVE_STATUSES.includes(item.status)) handlers.onUpdate?.(item);
113
+ else handlers.onDelete?.(item);
114
+ }
115
+ )
116
+ .on(
117
+ 'postgres_changes',
118
+ { event: 'DELETE', schema: 'public', table: 'app_job_queue', filter: `app_id=eq.${appId}` },
119
+ (payload) => {
120
+ const row = payload.old as DbAppJobQueueRow;
121
+ if (row.kind !== 'edit') return;
122
+ handlers.onDelete?.(mapQueueItem(row));
123
+ }
124
+ );
125
+ });
131
126
  }
132
127
  }
133
128
 
@@ -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,11 +11,12 @@ 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';
17
18
  import { BaseRepository } from '../../data/base-repository';
18
- import { getSupabaseClient } from '../../core/services/supabase';
19
+ import { subscribeManagedChannel } from '../../core/services/supabase/realtimeManager';
19
20
 
20
21
  type DbAppRow = {
21
22
  id: string;
@@ -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);
@@ -145,38 +152,33 @@ class AppsRepositoryImpl extends BaseRepository implements AppsRepository {
145
152
  }
146
153
 
147
154
  private subscribeToAppChannel(channelKey: string, filter: string, handlers: AppSubscriptionHandlers): () => void {
148
- const supabase = getSupabaseClient();
149
- const channel = supabase
150
- .channel(channelKey)
151
- .on(
152
- 'postgres_changes',
153
- { event: 'INSERT', schema: 'public', table: 'app', filter },
154
- (payload) => {
155
- console.log('[subscribeToAppChannel] onInsert', payload);
156
- handlers.onInsert?.(mapDbAppRow(payload.new as DbAppRow));
157
- }
158
- )
159
- .on(
160
- 'postgres_changes',
161
- { event: 'UPDATE', schema: 'public', table: 'app', filter },
162
- (payload) => {
163
- console.log('[subscribeToAppChannel] onUpdate', payload);
164
- handlers.onUpdate?.(mapDbAppRow(payload.new as DbAppRow));
165
- }
166
- )
167
- .on(
168
- 'postgres_changes',
169
- { event: 'DELETE', schema: 'public', table: 'app', filter },
170
- (payload) => {
171
- console.log('[subscribeToAppChannel] onDelete', payload);
172
- handlers.onDelete?.(mapDbAppRow(payload.old as DbAppRow));
173
- }
174
- )
175
- .subscribe();
176
-
177
- return () => {
178
- supabase.removeChannel(channel);
179
- };
155
+ return subscribeManagedChannel(channelKey, (channel) => {
156
+ channel
157
+ .on(
158
+ 'postgres_changes',
159
+ { event: 'INSERT', schema: 'public', table: 'app', filter },
160
+ (payload) => {
161
+ console.log('[subscribeToAppChannel] onInsert', payload);
162
+ handlers.onInsert?.(mapDbAppRow(payload.new as DbAppRow));
163
+ }
164
+ )
165
+ .on(
166
+ 'postgres_changes',
167
+ { event: 'UPDATE', schema: 'public', table: 'app', filter },
168
+ (payload) => {
169
+ console.log('[subscribeToAppChannel] onUpdate', payload);
170
+ handlers.onUpdate?.(mapDbAppRow(payload.new as DbAppRow));
171
+ }
172
+ )
173
+ .on(
174
+ 'postgres_changes',
175
+ { event: 'DELETE', schema: 'public', table: 'app', filter },
176
+ (payload) => {
177
+ console.log('[subscribeToAppChannel] onDelete', payload);
178
+ handlers.onDelete?.(mapDbAppRow(payload.old as DbAppRow));
179
+ }
180
+ );
181
+ });
180
182
  }
181
183
  }
182
184
 
@@ -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;
@@ -2,7 +2,7 @@ import type { MessagesRemoteDataSource } from './remote';
2
2
  import { messagesRemoteDataSource } from './remote';
3
3
  import type { Message } from './types';
4
4
  import { BaseRepository } from '../../data/base-repository';
5
- import { getSupabaseClient } from '../../core/services/supabase';
5
+ import { subscribeManagedChannel } from '../../core/services/supabase/realtimeManager';
6
6
 
7
7
  type DbMessageRow = {
8
8
  id: string;
@@ -64,38 +64,33 @@ class MessagesRepositoryImpl extends BaseRepository implements MessagesRepositor
64
64
  onDelete?: (m: Message) => void;
65
65
  }
66
66
  ): () => void {
67
- const supabase = getSupabaseClient();
68
- const channel = supabase
69
- .channel(`messages:thread:${threadId}`)
70
- .on(
71
- 'postgres_changes',
72
- { event: 'INSERT', schema: 'public', table: 'message', filter: `thread_id=eq.${threadId}` },
73
- (payload) => {
74
- const row = payload.new as DbMessageRow;
75
- handlers.onInsert?.(mapDbRowToMessage(row));
76
- }
77
- )
78
- .on(
79
- 'postgres_changes',
80
- { event: 'UPDATE', schema: 'public', table: 'message', filter: `thread_id=eq.${threadId}` },
81
- (payload) => {
82
- const row = payload.new as DbMessageRow;
83
- handlers.onUpdate?.(mapDbRowToMessage(row));
84
- }
85
- )
86
- .on(
87
- 'postgres_changes',
88
- { event: 'DELETE', schema: 'public', table: 'message', filter: `thread_id=eq.${threadId}` },
89
- (payload) => {
90
- const row = payload.old as DbMessageRow;
91
- handlers.onDelete?.(mapDbRowToMessage(row));
92
- }
93
- )
94
- .subscribe();
95
-
96
- return () => {
97
- supabase.removeChannel(channel);
98
- };
67
+ return subscribeManagedChannel(`messages:thread:${threadId}`, (channel) => {
68
+ channel
69
+ .on(
70
+ 'postgres_changes',
71
+ { event: 'INSERT', schema: 'public', table: 'message', filter: `thread_id=eq.${threadId}` },
72
+ (payload) => {
73
+ const row = payload.new as DbMessageRow;
74
+ handlers.onInsert?.(mapDbRowToMessage(row));
75
+ }
76
+ )
77
+ .on(
78
+ 'postgres_changes',
79
+ { event: 'UPDATE', schema: 'public', table: 'message', filter: `thread_id=eq.${threadId}` },
80
+ (payload) => {
81
+ const row = payload.new as DbMessageRow;
82
+ handlers.onUpdate?.(mapDbRowToMessage(row));
83
+ }
84
+ )
85
+ .on(
86
+ 'postgres_changes',
87
+ { event: 'DELETE', schema: 'public', table: 'message', filter: `thread_id=eq.${threadId}` },
88
+ (payload) => {
89
+ const row = payload.old as DbMessageRow;
90
+ handlers.onDelete?.(mapDbRowToMessage(row));
91
+ }
92
+ );
93
+ });
99
94
  }
100
95
  }
101
96
 
@@ -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,
@@ -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}