@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/dist/index.js +308 -100
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +309 -101
- package/dist/index.mjs.map +1 -1
- package/package.json +1 -1
- package/src/components/chat/ChatMessageBubble.tsx +5 -2
- package/src/core/services/supabase/realtimeManager.ts +112 -0
- package/src/data/apps/edit-queue/repository.ts +35 -40
- package/src/data/apps/remote.ts +9 -0
- package/src/data/apps/repository.ts +35 -33
- package/src/data/apps/types.ts +15 -0
- package/src/data/messages/repository.ts +28 -33
- package/src/studio/ComergeStudio.tsx +22 -0
- package/src/studio/ui/PreviewPanel.tsx +22 -2
- package/src/studio/ui/StudioOverlay.tsx +9 -0
- package/src/studio/ui/preview-panel/PreviewCollaborateSection.tsx +95 -3
- package/src/studio/ui/preview-panel/usePreviewPanelData.ts +8 -0
package/package.json
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
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
|
|
package/src/data/apps/remote.ts
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
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
|
|
package/src/data/apps/types.ts
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
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 {
|
|
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}
|