@comergehq/studio 0.1.23 → 0.1.24
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.d.mts +3 -1
- package/dist/index.d.ts +3 -1
- package/dist/index.js +694 -306
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +721 -330
- package/dist/index.mjs.map +1 -1
- package/package.json +1 -1
- package/src/components/bubble/Bubble.tsx +9 -0
- package/src/components/bubble/types.ts +2 -0
- package/src/components/chat/ChatComposer.tsx +4 -21
- package/src/components/chat/ChatMessageBubble.tsx +33 -2
- package/src/components/chat/ChatMessageList.tsx +12 -1
- package/src/components/chat/ChatPage.tsx +8 -14
- package/src/components/merge-requests/ReviewMergeRequestCard.tsx +1 -1
- package/src/components/primitives/MarkdownText.tsx +134 -35
- package/src/components/studio-sheet/StudioBottomSheet.tsx +26 -29
- package/src/core/services/http/index.ts +64 -1
- package/src/core/services/supabase/realtimeManager.ts +55 -1
- package/src/data/agent/types.ts +1 -0
- package/src/data/apps/bundles/remote.ts +4 -3
- package/src/data/users/types.ts +1 -1
- package/src/index.ts +1 -0
- package/src/studio/ComergeStudio.tsx +6 -2
- package/src/studio/hooks/useApp.ts +24 -6
- package/src/studio/hooks/useBundleManager.ts +12 -1
- package/src/studio/hooks/useForegroundSignal.ts +2 -4
- package/src/studio/hooks/useMergeRequests.ts +6 -1
- package/src/studio/hooks/useOptimisticChatMessages.ts +55 -3
- package/src/studio/hooks/useStudioActions.ts +60 -6
- package/src/studio/hooks/useThreadMessages.ts +26 -5
- package/src/studio/ui/ChatPanel.tsx +6 -3
- package/src/studio/ui/StudioOverlay.tsx +7 -2
|
@@ -11,9 +11,53 @@ import { BASE_URL } from './baseUrl';
|
|
|
11
11
|
declare module 'axios' {
|
|
12
12
|
export interface AxiosRequestConfig {
|
|
13
13
|
_retried?: boolean;
|
|
14
|
+
_retryCount?: number;
|
|
15
|
+
skipRetry?: boolean;
|
|
14
16
|
}
|
|
15
17
|
}
|
|
16
18
|
|
|
19
|
+
const RETRYABLE_MAX_ATTEMPTS = 3;
|
|
20
|
+
const RETRYABLE_BASE_DELAY_MS = 500;
|
|
21
|
+
const RETRYABLE_MAX_DELAY_MS = 4000;
|
|
22
|
+
const RETRYABLE_JITTER_MS = 250;
|
|
23
|
+
|
|
24
|
+
function sleep(ms: number): Promise<void> {
|
|
25
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function isRetryableNetworkError(e: unknown): boolean {
|
|
29
|
+
const err = e as any;
|
|
30
|
+
const code = typeof err?.code === 'string' ? err.code : '';
|
|
31
|
+
const message = typeof err?.message === 'string' ? err.message : '';
|
|
32
|
+
|
|
33
|
+
if (code === 'ERR_NETWORK' || code === 'ECONNABORTED') return true;
|
|
34
|
+
if (message.toLowerCase().includes('network error')) return true;
|
|
35
|
+
if (message.toLowerCase().includes('timeout')) return true;
|
|
36
|
+
|
|
37
|
+
const status = typeof err?.response?.status === 'number' ? err.response.status : undefined;
|
|
38
|
+
if (status && (status === 429 || status >= 500)) return true;
|
|
39
|
+
|
|
40
|
+
return false;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function computeBackoffDelay(attempt: number): number {
|
|
44
|
+
const exp = Math.min(RETRYABLE_MAX_DELAY_MS, RETRYABLE_BASE_DELAY_MS * Math.pow(2, attempt - 1));
|
|
45
|
+
const jitter = Math.floor(Math.random() * RETRYABLE_JITTER_MS);
|
|
46
|
+
return exp + jitter;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function parseRetryAfterMs(value: unknown): number | null {
|
|
50
|
+
if (typeof value !== 'string') return null;
|
|
51
|
+
const trimmed = value.trim();
|
|
52
|
+
if (!trimmed) return null;
|
|
53
|
+
const seconds = Number(trimmed);
|
|
54
|
+
if (!Number.isNaN(seconds) && seconds >= 0) return seconds * 1000;
|
|
55
|
+
const parsed = Date.parse(trimmed);
|
|
56
|
+
if (Number.isNaN(parsed)) return null;
|
|
57
|
+
const delta = parsed - Date.now();
|
|
58
|
+
return delta > 0 ? delta : 0;
|
|
59
|
+
}
|
|
60
|
+
|
|
17
61
|
export const createApiClient = (baseURL: string): AxiosInstance => {
|
|
18
62
|
const apiClient = axios.create({
|
|
19
63
|
baseURL,
|
|
@@ -74,7 +118,7 @@ export const createApiClient = (baseURL: string): AxiosInstance => {
|
|
|
74
118
|
},
|
|
75
119
|
async (error: AxiosError) => {
|
|
76
120
|
const originalRequest = error.config as
|
|
77
|
-
| (InternalAxiosRequestConfig & { _retried?: boolean })
|
|
121
|
+
| (InternalAxiosRequestConfig & { _retried?: boolean; _retryCount?: number; skipRetry?: boolean })
|
|
78
122
|
| undefined;
|
|
79
123
|
log.error('Response Error:', {
|
|
80
124
|
message: error.message,
|
|
@@ -114,6 +158,25 @@ export const createApiClient = (baseURL: string): AxiosInstance => {
|
|
|
114
158
|
}
|
|
115
159
|
}
|
|
116
160
|
|
|
161
|
+
const method = originalRequest.method?.toLowerCase() ?? '';
|
|
162
|
+
const isGet = method === 'get';
|
|
163
|
+
const retryable = isRetryableNetworkError(error);
|
|
164
|
+
const retryCount = originalRequest._retryCount ?? 0;
|
|
165
|
+
const skipRetry = originalRequest.skipRetry === true;
|
|
166
|
+
|
|
167
|
+
if (isGet && retryable && !skipRetry && retryCount < RETRYABLE_MAX_ATTEMPTS) {
|
|
168
|
+
const retryAfterMs = parseRetryAfterMs(error.response?.headers?.['retry-after']);
|
|
169
|
+
originalRequest._retryCount = retryCount + 1;
|
|
170
|
+
const delayMs = retryAfterMs ?? computeBackoffDelay(retryCount + 1);
|
|
171
|
+
log.warn('Retrying GET request after transient error', {
|
|
172
|
+
url: originalRequest.url,
|
|
173
|
+
attempt: originalRequest._retryCount,
|
|
174
|
+
delayMs,
|
|
175
|
+
});
|
|
176
|
+
await sleep(delayMs);
|
|
177
|
+
return apiClient(originalRequest);
|
|
178
|
+
}
|
|
179
|
+
|
|
117
180
|
return Promise.reject(error);
|
|
118
181
|
}
|
|
119
182
|
);
|
|
@@ -19,6 +19,7 @@ const MAX_BACKOFF_MS = 30000;
|
|
|
19
19
|
const realtimeLog = log.extend('realtime');
|
|
20
20
|
const entries = new Map<string, ChannelEntry>();
|
|
21
21
|
let subscriberIdCounter = 0;
|
|
22
|
+
let resetTimer: ReturnType<typeof setTimeout> | null = null;
|
|
22
23
|
|
|
23
24
|
function clearTimer(entry: ChannelEntry) {
|
|
24
25
|
if (!entry.timer) return;
|
|
@@ -65,13 +66,66 @@ function subscribeChannel(entry: ChannelEntry) {
|
|
|
65
66
|
if (entry.channel) supabase.removeChannel(entry.channel);
|
|
66
67
|
const channel = buildChannel(entry);
|
|
67
68
|
entry.channel = channel;
|
|
68
|
-
channel.subscribe((status) => handleStatus(entry, status));
|
|
69
|
+
channel.subscribe((status: string) => handleStatus(entry, status));
|
|
69
70
|
} catch (error) {
|
|
70
71
|
realtimeLog.warn('[realtime] subscribe failed', error);
|
|
71
72
|
scheduleResubscribe(entry, 'SUBSCRIBE_FAILED');
|
|
72
73
|
}
|
|
73
74
|
}
|
|
74
75
|
|
|
76
|
+
function unsubscribeChannel(entry: ChannelEntry) {
|
|
77
|
+
if (!entry.channel) return;
|
|
78
|
+
try {
|
|
79
|
+
entry.channel.unsubscribe?.();
|
|
80
|
+
} catch (error) {
|
|
81
|
+
realtimeLog.warn('[realtime] unsubscribe failed', error);
|
|
82
|
+
}
|
|
83
|
+
entry.channel = null;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function resetRealtimeState(reason: string) {
|
|
87
|
+
realtimeLog.warn(`[realtime] reset state ${reason}`);
|
|
88
|
+
entries.forEach((entry) => {
|
|
89
|
+
clearTimer(entry);
|
|
90
|
+
entry.backoffMs = INITIAL_BACKOFF_MS;
|
|
91
|
+
unsubscribeChannel(entry);
|
|
92
|
+
});
|
|
93
|
+
entries.clear();
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function resubscribeAll() {
|
|
97
|
+
entries.forEach((entry) => {
|
|
98
|
+
if (entry.subscribers.size === 0) return;
|
|
99
|
+
subscribeChannel(entry);
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export function resetRealtime(reason: string) {
|
|
104
|
+
if (resetTimer) return;
|
|
105
|
+
resetTimer = setTimeout(() => {
|
|
106
|
+
resetTimer = null;
|
|
107
|
+
const supabase = getSupabaseClient();
|
|
108
|
+
realtimeLog.warn(`[realtime] full reset ${reason}`);
|
|
109
|
+
entries.forEach((entry) => {
|
|
110
|
+
clearTimer(entry);
|
|
111
|
+
entry.backoffMs = INITIAL_BACKOFF_MS;
|
|
112
|
+
if (entry.channel) supabase.removeChannel(entry.channel);
|
|
113
|
+
entry.channel = null;
|
|
114
|
+
});
|
|
115
|
+
try {
|
|
116
|
+
supabase.realtime?.disconnect?.();
|
|
117
|
+
} catch (error) {
|
|
118
|
+
realtimeLog.warn('[realtime] disconnect failed', error);
|
|
119
|
+
}
|
|
120
|
+
try {
|
|
121
|
+
supabase.realtime?.connect?.();
|
|
122
|
+
} catch (error) {
|
|
123
|
+
realtimeLog.warn('[realtime] connect failed', error);
|
|
124
|
+
}
|
|
125
|
+
resubscribeAll();
|
|
126
|
+
}, 250);
|
|
127
|
+
}
|
|
128
|
+
|
|
75
129
|
export function subscribeManagedChannel(key: string, configure: ChannelConfigurer): () => void {
|
|
76
130
|
let entry = entries.get(key);
|
|
77
131
|
if (!entry) {
|
package/src/data/agent/types.ts
CHANGED
|
@@ -29,7 +29,8 @@ class BundlesRemoteDataSourceImpl extends BaseRemote implements BundlesRemoteDat
|
|
|
29
29
|
|
|
30
30
|
async getById(appId: string, bundleId: string): Promise<ServiceResponse<Bundle>> {
|
|
31
31
|
const { data } = await api.get<ServiceResponse<Bundle>>(
|
|
32
|
-
`/v1/apps/${encodeURIComponent(appId)}/bundles/${encodeURIComponent(bundleId)}
|
|
32
|
+
`/v1/apps/${encodeURIComponent(appId)}/bundles/${encodeURIComponent(bundleId)}`,
|
|
33
|
+
{ skipRetry: true }
|
|
33
34
|
);
|
|
34
35
|
return data;
|
|
35
36
|
}
|
|
@@ -41,7 +42,7 @@ class BundlesRemoteDataSourceImpl extends BaseRemote implements BundlesRemoteDat
|
|
|
41
42
|
): Promise<ServiceResponse<{ url: string; redirect: boolean }>> {
|
|
42
43
|
const { data } = await api.get<ServiceResponse<{ url: string; redirect: boolean }>>(
|
|
43
44
|
`/v1/apps/${encodeURIComponent(appId)}/bundles/${encodeURIComponent(bundleId)}/download`,
|
|
44
|
-
{ params: { redirect: options?.redirect ?? false } }
|
|
45
|
+
{ params: { redirect: options?.redirect ?? false }, skipRetry: true }
|
|
45
46
|
);
|
|
46
47
|
return data;
|
|
47
48
|
}
|
|
@@ -53,7 +54,7 @@ class BundlesRemoteDataSourceImpl extends BaseRemote implements BundlesRemoteDat
|
|
|
53
54
|
): Promise<ServiceResponse<{ url: string; redirect: boolean }>> {
|
|
54
55
|
const { data } = await api.get<ServiceResponse<{ url: string; redirect: boolean }>>(
|
|
55
56
|
`/v1/apps/${encodeURIComponent(appId)}/bundles/${encodeURIComponent(bundleId)}/assets/download`,
|
|
56
|
-
{ params: { redirect: options?.redirect ?? false, kind: options?.kind } }
|
|
57
|
+
{ params: { redirect: options?.redirect ?? false, kind: options?.kind }, skipRetry: true }
|
|
57
58
|
);
|
|
58
59
|
return data;
|
|
59
60
|
}
|
package/src/data/users/types.ts
CHANGED
package/src/index.ts
CHANGED
|
@@ -2,5 +2,6 @@ export type { ComergeStudioProps } from './studio/ComergeStudio';
|
|
|
2
2
|
|
|
3
3
|
export { ComergeStudio } from './studio/ComergeStudio';
|
|
4
4
|
export { setSupabaseClient } from './core/services/supabase';
|
|
5
|
+
export { resetRealtimeState } from './core/services/supabase/realtimeManager';
|
|
5
6
|
|
|
6
7
|
|
|
@@ -249,6 +249,8 @@ function ComergeStudioInner({
|
|
|
249
249
|
const [testingMrId, setTestingMrId] = React.useState<string | null>(null);
|
|
250
250
|
const [syncingUpstream, setSyncingUpstream] = React.useState(false);
|
|
251
251
|
const [upstreamSyncStatus, setUpstreamSyncStatus] = React.useState<SyncUpstreamStatus | null>(null);
|
|
252
|
+
const isMrTestBuildInProgress = bundle.loading && bundle.loadingMode === 'test';
|
|
253
|
+
const isBaseBundleDownloading = bundle.loading && bundle.loadingMode === 'base' && !bundle.isTesting;
|
|
252
254
|
|
|
253
255
|
// Show typing dots when the last message isn't an outcome (agent still working).
|
|
254
256
|
const chatShowTypingIndicator = React.useMemo(() => {
|
|
@@ -321,6 +323,7 @@ function ComergeStudioInner({
|
|
|
321
323
|
isOwner={actions.isOwner}
|
|
322
324
|
shouldForkOnEdit={actions.shouldForkOnEdit}
|
|
323
325
|
isTesting={bundle.isTesting}
|
|
326
|
+
isBaseBundleDownloading={isBaseBundleDownloading}
|
|
324
327
|
onRestoreBase={async () => {
|
|
325
328
|
setTestingMrId(null);
|
|
326
329
|
await bundle.restoreBase();
|
|
@@ -329,11 +332,11 @@ function ComergeStudioInner({
|
|
|
329
332
|
outgoingMergeRequests={mergeRequests.lists.outgoing}
|
|
330
333
|
creatorStatsById={mergeRequests.creatorStatsById}
|
|
331
334
|
processingMrId={processingMrId}
|
|
332
|
-
isBuildingMrTest={
|
|
335
|
+
isBuildingMrTest={isMrTestBuildInProgress}
|
|
333
336
|
testingMrId={testingMrId}
|
|
334
337
|
toMergeRequestSummary={mergeRequests.toSummary}
|
|
335
338
|
onSubmitMergeRequest={
|
|
336
|
-
app?.forkedFromAppId && actions.isOwner && !hasOpenOutgoingMr
|
|
339
|
+
app?.forkedFromAppId && actions.isOwner && !mergeRequests.loading && !hasOpenOutgoingMr
|
|
337
340
|
? async () => {
|
|
338
341
|
await mergeRequests.actions.openMergeRequest(activeAppId);
|
|
339
342
|
}
|
|
@@ -361,6 +364,7 @@ function ComergeStudioInner({
|
|
|
361
364
|
}
|
|
362
365
|
}}
|
|
363
366
|
onTestMr={async (mr) => {
|
|
367
|
+
if (testingMrId === mr.id || bundle.loadingMode === 'test') return;
|
|
364
368
|
setTestingMrId(mr.id);
|
|
365
369
|
await bundle.loadTest({ appId: mr.sourceAppId, commitId: mr.sourceTipCommitId ?? mr.sourceCommitId });
|
|
366
370
|
}}
|
|
@@ -7,6 +7,7 @@ import { useForegroundSignal } from './useForegroundSignal';
|
|
|
7
7
|
export type UseAppResult = {
|
|
8
8
|
app: App | null;
|
|
9
9
|
loading: boolean;
|
|
10
|
+
refreshing: boolean;
|
|
10
11
|
error: Error | null;
|
|
11
12
|
refetch: () => Promise<void>;
|
|
12
13
|
};
|
|
@@ -23,8 +24,14 @@ export function useApp(appId: string, options?: UseAppOptions): UseAppResult {
|
|
|
23
24
|
const enabled = options?.enabled ?? true;
|
|
24
25
|
const [app, setApp] = React.useState<App | null>(null);
|
|
25
26
|
const [loading, setLoading] = React.useState(false);
|
|
27
|
+
const [refreshing, setRefreshing] = React.useState(false);
|
|
26
28
|
const [error, setError] = React.useState<Error | null>(null);
|
|
27
29
|
const foregroundSignal = useForegroundSignal(enabled && Boolean(appId));
|
|
30
|
+
const hasLoadedOnceRef = React.useRef(false);
|
|
31
|
+
|
|
32
|
+
React.useEffect(() => {
|
|
33
|
+
hasLoadedOnceRef.current = false;
|
|
34
|
+
}, [appId]);
|
|
28
35
|
|
|
29
36
|
const mergeApp = React.useCallback((prev: App | null, next: App): App => {
|
|
30
37
|
// Realtime (Supabase) rows don't include "viewer-specific" fields like `isLiked`,
|
|
@@ -38,21 +45,32 @@ export function useApp(appId: string, options?: UseAppOptions): UseAppResult {
|
|
|
38
45
|
return merged;
|
|
39
46
|
}, []);
|
|
40
47
|
|
|
41
|
-
const fetchOnce = React.useCallback(async () => {
|
|
48
|
+
const fetchOnce = React.useCallback(async (opts?: { background?: boolean }) => {
|
|
42
49
|
if (!enabled) return;
|
|
43
50
|
if (!appId) return;
|
|
44
|
-
|
|
51
|
+
const isBackground = Boolean(opts?.background);
|
|
52
|
+
const useBackgroundRefresh = isBackground && hasLoadedOnceRef.current;
|
|
53
|
+
if (useBackgroundRefresh) {
|
|
54
|
+
setRefreshing(true);
|
|
55
|
+
} else {
|
|
56
|
+
setLoading(true);
|
|
57
|
+
}
|
|
45
58
|
setError(null);
|
|
46
59
|
try {
|
|
47
60
|
const next = await appsRepository.getById(appId);
|
|
61
|
+
hasLoadedOnceRef.current = true;
|
|
48
62
|
setApp((prev) => mergeApp(prev, next));
|
|
49
63
|
} catch (e) {
|
|
50
64
|
setError(e instanceof Error ? e : new Error(String(e)));
|
|
51
65
|
setApp(null);
|
|
52
66
|
} finally {
|
|
53
|
-
|
|
67
|
+
if (useBackgroundRefresh) {
|
|
68
|
+
setRefreshing(false);
|
|
69
|
+
} else {
|
|
70
|
+
setLoading(false);
|
|
71
|
+
}
|
|
54
72
|
}
|
|
55
|
-
}, [appId, enabled]);
|
|
73
|
+
}, [appId, enabled, mergeApp]);
|
|
56
74
|
|
|
57
75
|
React.useEffect(() => {
|
|
58
76
|
if (!enabled) return;
|
|
@@ -80,10 +98,10 @@ export function useApp(appId: string, options?: UseAppOptions): UseAppResult {
|
|
|
80
98
|
if (!enabled) return;
|
|
81
99
|
if (!appId) return;
|
|
82
100
|
if (foregroundSignal <= 0) return;
|
|
83
|
-
void fetchOnce();
|
|
101
|
+
void fetchOnce({ background: true });
|
|
84
102
|
}, [appId, enabled, fetchOnce, foregroundSignal]);
|
|
85
103
|
|
|
86
|
-
return { app, loading, error, refetch: fetchOnce };
|
|
104
|
+
return { app, loading, refreshing, error, refetch: fetchOnce };
|
|
87
105
|
}
|
|
88
106
|
|
|
89
107
|
|
|
@@ -560,6 +560,7 @@ export function useBundleManager({
|
|
|
560
560
|
const baseOpIdRef = React.useRef(0);
|
|
561
561
|
const testOpIdRef = React.useRef(0);
|
|
562
562
|
const activeLoadModeRef = React.useRef<'base' | 'test' | null>(null);
|
|
563
|
+
const desiredModeRef = React.useRef<'base' | 'test'>('base');
|
|
563
564
|
|
|
564
565
|
const canRequestLatestRef = React.useRef<boolean>(canRequestLatest);
|
|
565
566
|
React.useEffect(() => {
|
|
@@ -661,6 +662,12 @@ export function useBundleManager({
|
|
|
661
662
|
const load = React.useCallback(async (src: BundleSource, mode: 'base' | 'test') => {
|
|
662
663
|
if (!src.appId) return;
|
|
663
664
|
|
|
665
|
+
if (mode === 'test') {
|
|
666
|
+
// Testing takes precedence over any in-flight base refresh.
|
|
667
|
+
desiredModeRef.current = 'test';
|
|
668
|
+
baseOpIdRef.current += 1;
|
|
669
|
+
}
|
|
670
|
+
|
|
664
671
|
const canRequestLatest = canRequestLatestRef.current;
|
|
665
672
|
if (mode === 'base' && !canRequestLatest) {
|
|
666
673
|
await activateCachedBase(src.appId);
|
|
@@ -674,7 +681,7 @@ export function useBundleManager({
|
|
|
674
681
|
setError(null);
|
|
675
682
|
setStatusLabel(mode === 'test' ? 'Loading test bundle…' : 'Loading latest build…');
|
|
676
683
|
|
|
677
|
-
if (mode === 'base') {
|
|
684
|
+
if (mode === 'base' && desiredModeRef.current === 'base') {
|
|
678
685
|
void activateCachedBase(src.appId);
|
|
679
686
|
}
|
|
680
687
|
|
|
@@ -682,6 +689,7 @@ export function useBundleManager({
|
|
|
682
689
|
const { bundlePath: path, bundle } = await resolveBundlePath(src, platform, mode);
|
|
683
690
|
if (mode === 'base' && opId !== baseOpIdRef.current) return;
|
|
684
691
|
if (mode === 'test' && opId !== testOpIdRef.current) return;
|
|
692
|
+
if (desiredModeRef.current !== mode) return;
|
|
685
693
|
setBundlePath(path);
|
|
686
694
|
const fingerprint = bundle.checksumSha256 ?? `id:${bundle.id}`;
|
|
687
695
|
|
|
@@ -750,6 +758,9 @@ export function useBundleManager({
|
|
|
750
758
|
const restoreBase = React.useCallback(async () => {
|
|
751
759
|
const src = baseRef.current;
|
|
752
760
|
if (!src.appId) return;
|
|
761
|
+
desiredModeRef.current = 'base';
|
|
762
|
+
// Exiting test mode should drop any in-flight test completion.
|
|
763
|
+
testOpIdRef.current += 1;
|
|
753
764
|
await activateCachedBase(src.appId);
|
|
754
765
|
if (canRequestLatestRef.current) {
|
|
755
766
|
await load(src, 'base');
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import * as React from 'react';
|
|
2
2
|
import { AppState, type AppStateStatus } from 'react-native';
|
|
3
3
|
|
|
4
|
-
import {
|
|
4
|
+
import { resetRealtimeState } from '../../core/services/supabase/realtimeManager';
|
|
5
5
|
|
|
6
6
|
export function useForegroundSignal(enabled: boolean = true): number {
|
|
7
7
|
const [signal, setSignal] = React.useState(0);
|
|
@@ -19,10 +19,8 @@ export function useForegroundSignal(enabled: boolean = true): number {
|
|
|
19
19
|
if (!didResume) return;
|
|
20
20
|
|
|
21
21
|
try {
|
|
22
|
-
|
|
23
|
-
supabase?.realtime?.connect?.();
|
|
22
|
+
resetRealtimeState('APP_RESUME');
|
|
24
23
|
} catch {
|
|
25
|
-
|
|
26
24
|
}
|
|
27
25
|
|
|
28
26
|
setSignal((s) => s + 1);
|
|
@@ -54,10 +54,14 @@ export function useMergeRequests(params: { appId: string }): UseMergeRequestsRes
|
|
|
54
54
|
const { appId } = params;
|
|
55
55
|
const [incoming, setIncoming] = React.useState<MergeRequest[]>([]);
|
|
56
56
|
const [outgoing, setOutgoing] = React.useState<MergeRequest[]>([]);
|
|
57
|
-
const [loading, setLoading] = React.useState(
|
|
57
|
+
const [loading, setLoading] = React.useState(() => Boolean(appId));
|
|
58
58
|
const [error, setError] = React.useState<Error | null>(null);
|
|
59
59
|
const [creatorStatsById, setCreatorStatsById] = React.useState<Record<string, UserStats>>({});
|
|
60
60
|
|
|
61
|
+
React.useEffect(() => {
|
|
62
|
+
setLoading(Boolean(appId));
|
|
63
|
+
}, [appId]);
|
|
64
|
+
|
|
61
65
|
const pollUntilMerged = React.useCallback(async (mrId: string) => {
|
|
62
66
|
const startedAt = Date.now();
|
|
63
67
|
const timeoutMs = 2 * 60 * 1000;
|
|
@@ -74,6 +78,7 @@ export function useMergeRequests(params: { appId: string }): UseMergeRequestsRes
|
|
|
74
78
|
setIncoming([]);
|
|
75
79
|
setOutgoing([]);
|
|
76
80
|
setCreatorStatsById({});
|
|
81
|
+
setLoading(false);
|
|
77
82
|
return;
|
|
78
83
|
}
|
|
79
84
|
setLoading(true);
|
|
@@ -13,14 +13,18 @@ export type UseOptimisticChatMessagesParams = {
|
|
|
13
13
|
export type UseOptimisticChatMessagesResult = {
|
|
14
14
|
messages: ChatMessage[];
|
|
15
15
|
onSend: (text: string, attachments?: string[]) => Promise<void>;
|
|
16
|
+
onRetry: (messageId: string) => Promise<void>;
|
|
17
|
+
isRetrying: (messageId: string) => boolean;
|
|
16
18
|
};
|
|
17
19
|
|
|
18
20
|
type OptimisticChatMessage = {
|
|
19
21
|
id: string;
|
|
20
22
|
content: string;
|
|
23
|
+
attachments?: string[];
|
|
21
24
|
createdAtIso: string;
|
|
22
25
|
baseServerLastId: string | null;
|
|
23
26
|
failed: boolean;
|
|
27
|
+
retrying: boolean;
|
|
24
28
|
};
|
|
25
29
|
|
|
26
30
|
function makeOptimisticId() {
|
|
@@ -115,8 +119,20 @@ export function useOptimisticChatMessages({
|
|
|
115
119
|
const createdAtIso = new Date().toISOString();
|
|
116
120
|
const baseServerLastId = chatMessages.length > 0 ? chatMessages[chatMessages.length - 1]!.id : null;
|
|
117
121
|
const id = makeOptimisticId();
|
|
118
|
-
|
|
119
|
-
|
|
122
|
+
const normalizedAttachments = attachments && attachments.length > 0 ? [...attachments] : undefined;
|
|
123
|
+
|
|
124
|
+
setOptimisticChat((prev) => [
|
|
125
|
+
...prev,
|
|
126
|
+
{
|
|
127
|
+
id,
|
|
128
|
+
content: text,
|
|
129
|
+
attachments: normalizedAttachments,
|
|
130
|
+
createdAtIso,
|
|
131
|
+
baseServerLastId,
|
|
132
|
+
failed: false,
|
|
133
|
+
retrying: false,
|
|
134
|
+
},
|
|
135
|
+
]);
|
|
120
136
|
|
|
121
137
|
void Promise.resolve(onSendChat(text, attachments)).catch(() => {
|
|
122
138
|
setOptimisticChat((prev) => prev.map((m) => (m.id === id ? { ...m, failed: true } : m)));
|
|
@@ -125,6 +141,42 @@ export function useOptimisticChatMessages({
|
|
|
125
141
|
[chatMessages, disableOptimistic, onSendChat, shouldForkOnEdit]
|
|
126
142
|
);
|
|
127
143
|
|
|
128
|
-
|
|
144
|
+
const onRetry = React.useCallback(
|
|
145
|
+
async (messageId: string) => {
|
|
146
|
+
if (shouldForkOnEdit || disableOptimistic) return;
|
|
147
|
+
const target = optimisticChat.find((m) => m.id === messageId);
|
|
148
|
+
if (!target || target.retrying) return;
|
|
149
|
+
|
|
150
|
+
const baseServerLastId = chatMessages.length > 0 ? chatMessages[chatMessages.length - 1]!.id : null;
|
|
151
|
+
setOptimisticChat((prev) =>
|
|
152
|
+
prev.map((m) =>
|
|
153
|
+
m.id === messageId
|
|
154
|
+
? { ...m, failed: false, retrying: true, baseServerLastId }
|
|
155
|
+
: m
|
|
156
|
+
)
|
|
157
|
+
);
|
|
158
|
+
|
|
159
|
+
try {
|
|
160
|
+
await onSendChat(target.content, target.attachments);
|
|
161
|
+
setOptimisticChat((prev) =>
|
|
162
|
+
prev.map((m) => (m.id === messageId ? { ...m, retrying: false } : m))
|
|
163
|
+
);
|
|
164
|
+
} catch {
|
|
165
|
+
setOptimisticChat((prev) =>
|
|
166
|
+
prev.map((m) => (m.id === messageId ? { ...m, failed: true, retrying: false } : m))
|
|
167
|
+
);
|
|
168
|
+
}
|
|
169
|
+
},
|
|
170
|
+
[chatMessages, disableOptimistic, onSendChat, optimisticChat, shouldForkOnEdit]
|
|
171
|
+
);
|
|
172
|
+
|
|
173
|
+
const isRetrying = React.useCallback(
|
|
174
|
+
(messageId: string) => {
|
|
175
|
+
return optimisticChat.some((m) => m.id === messageId && m.retrying);
|
|
176
|
+
},
|
|
177
|
+
[optimisticChat]
|
|
178
|
+
);
|
|
179
|
+
|
|
180
|
+
return { messages, onSend, onRetry, isRetrying };
|
|
129
181
|
}
|
|
130
182
|
|
|
@@ -5,6 +5,53 @@ import { appsRepository } from '../../data/apps/repository';
|
|
|
5
5
|
import { agentRepository } from '../../data/agent/repository';
|
|
6
6
|
import type { AttachmentMeta } from '../../data/attachment/types';
|
|
7
7
|
|
|
8
|
+
function sleep(ms: number): Promise<void> {
|
|
9
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function isRetryableNetworkError(e: unknown): boolean {
|
|
13
|
+
const err = e as any;
|
|
14
|
+
const code = typeof err?.code === 'string' ? err.code : '';
|
|
15
|
+
const message = typeof err?.message === 'string' ? err.message : '';
|
|
16
|
+
|
|
17
|
+
if (code === 'ERR_NETWORK' || code === 'ECONNABORTED') return true;
|
|
18
|
+
if (message.toLowerCase().includes('network error')) return true;
|
|
19
|
+
if (message.toLowerCase().includes('timeout')) return true;
|
|
20
|
+
|
|
21
|
+
const status = typeof err?.response?.status === 'number' ? err.response.status : undefined;
|
|
22
|
+
if (status && (status === 429 || status >= 500)) return true;
|
|
23
|
+
|
|
24
|
+
return false;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
async function withRetry<T>(
|
|
28
|
+
fn: () => Promise<T>,
|
|
29
|
+
opts: { attempts: number; baseDelayMs: number; maxDelayMs: number }
|
|
30
|
+
): Promise<T> {
|
|
31
|
+
let lastErr: unknown = null;
|
|
32
|
+
for (let attempt = 1; attempt <= opts.attempts; attempt += 1) {
|
|
33
|
+
try {
|
|
34
|
+
return await fn();
|
|
35
|
+
} catch (e) {
|
|
36
|
+
lastErr = e;
|
|
37
|
+
const retryable = isRetryableNetworkError(e);
|
|
38
|
+
if (!retryable || attempt >= opts.attempts) {
|
|
39
|
+
throw e;
|
|
40
|
+
}
|
|
41
|
+
const exp = Math.min(opts.maxDelayMs, opts.baseDelayMs * Math.pow(2, attempt - 1));
|
|
42
|
+
const jitter = Math.floor(Math.random() * 250);
|
|
43
|
+
await sleep(exp + jitter);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
throw lastErr;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function generateIdempotencyKey(): string {
|
|
50
|
+
const rnd = globalThis.crypto?.randomUUID?.();
|
|
51
|
+
if (rnd) return `edit:${rnd}`;
|
|
52
|
+
return `edit:${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
|
53
|
+
}
|
|
54
|
+
|
|
8
55
|
export type UseStudioActionsParams = {
|
|
9
56
|
userId: string | null;
|
|
10
57
|
/**
|
|
@@ -79,12 +126,19 @@ export function useStudioActions({
|
|
|
79
126
|
attachmentMetas = await uploadAttachments({ threadId, appId: targetApp.id, dataUrls: attachments });
|
|
80
127
|
}
|
|
81
128
|
|
|
82
|
-
const
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
129
|
+
const idempotencyKey = generateIdempotencyKey();
|
|
130
|
+
const editResult = await withRetry(
|
|
131
|
+
async () => {
|
|
132
|
+
return await agentRepository.editApp({
|
|
133
|
+
prompt,
|
|
134
|
+
thread_id: threadId,
|
|
135
|
+
app_id: targetApp.id,
|
|
136
|
+
attachments: attachmentMetas && attachmentMetas.length > 0 ? attachmentMetas : undefined,
|
|
137
|
+
idempotencyKey,
|
|
138
|
+
});
|
|
139
|
+
},
|
|
140
|
+
{ attempts: 3, baseDelayMs: 500, maxDelayMs: 4000 }
|
|
141
|
+
);
|
|
88
142
|
onEditQueued?.({
|
|
89
143
|
queueItemId: editResult.queueItemId ?? null,
|
|
90
144
|
queuePosition: editResult.queuePosition ?? null,
|
|
@@ -9,6 +9,7 @@ export type UseThreadMessagesResult = {
|
|
|
9
9
|
raw: Message[];
|
|
10
10
|
messages: ChatMessage[];
|
|
11
11
|
loading: boolean;
|
|
12
|
+
refreshing: boolean;
|
|
12
13
|
error: Error | null;
|
|
13
14
|
refetch: () => Promise<void>;
|
|
14
15
|
};
|
|
@@ -78,9 +79,15 @@ function mapMessageToChatMessage(m: Message): ChatMessage {
|
|
|
78
79
|
export function useThreadMessages(threadId: string): UseThreadMessagesResult {
|
|
79
80
|
const [raw, setRaw] = React.useState<Message[]>([]);
|
|
80
81
|
const [loading, setLoading] = React.useState(false);
|
|
82
|
+
const [refreshing, setRefreshing] = React.useState(false);
|
|
81
83
|
const [error, setError] = React.useState<Error | null>(null);
|
|
82
84
|
const activeRequestIdRef = React.useRef(0);
|
|
83
85
|
const foregroundSignal = useForegroundSignal(Boolean(threadId));
|
|
86
|
+
const hasLoadedOnceRef = React.useRef(false);
|
|
87
|
+
|
|
88
|
+
React.useEffect(() => {
|
|
89
|
+
hasLoadedOnceRef.current = false;
|
|
90
|
+
}, [threadId]);
|
|
84
91
|
|
|
85
92
|
const upsertSorted = React.useCallback((prev: Message[], m: Message) => {
|
|
86
93
|
const next = prev.filter((x) => x.id !== m.id);
|
|
@@ -89,24 +96,38 @@ export function useThreadMessages(threadId: string): UseThreadMessagesResult {
|
|
|
89
96
|
return next;
|
|
90
97
|
}, []);
|
|
91
98
|
|
|
92
|
-
const refetch = React.useCallback(async () => {
|
|
99
|
+
const refetch = React.useCallback(async (opts?: { background?: boolean }) => {
|
|
93
100
|
if (!threadId) {
|
|
94
101
|
setRaw([]);
|
|
102
|
+
setLoading(false);
|
|
103
|
+
setRefreshing(false);
|
|
95
104
|
return;
|
|
96
105
|
}
|
|
97
106
|
const requestId = ++activeRequestIdRef.current;
|
|
98
|
-
|
|
107
|
+
const isBackground = Boolean(opts?.background);
|
|
108
|
+
const useBackgroundRefresh = isBackground && hasLoadedOnceRef.current;
|
|
109
|
+
if (useBackgroundRefresh) {
|
|
110
|
+
setRefreshing(true);
|
|
111
|
+
} else {
|
|
112
|
+
setLoading(true);
|
|
113
|
+
}
|
|
99
114
|
setError(null);
|
|
100
115
|
try {
|
|
101
116
|
const list = await messagesRepository.list(threadId);
|
|
102
117
|
if (activeRequestIdRef.current !== requestId) return;
|
|
118
|
+
hasLoadedOnceRef.current = true;
|
|
103
119
|
setRaw([...list].sort(compareMessages));
|
|
104
120
|
} catch (e) {
|
|
105
121
|
if (activeRequestIdRef.current !== requestId) return;
|
|
106
122
|
setError(e instanceof Error ? e : new Error(String(e)));
|
|
107
123
|
setRaw([]);
|
|
108
124
|
} finally {
|
|
109
|
-
if (activeRequestIdRef.current
|
|
125
|
+
if (activeRequestIdRef.current !== requestId) return;
|
|
126
|
+
if (useBackgroundRefresh) {
|
|
127
|
+
setRefreshing(false);
|
|
128
|
+
} else {
|
|
129
|
+
setLoading(false);
|
|
130
|
+
}
|
|
110
131
|
}
|
|
111
132
|
}, [threadId]);
|
|
112
133
|
|
|
@@ -127,7 +148,7 @@ export function useThreadMessages(threadId: string): UseThreadMessagesResult {
|
|
|
127
148
|
React.useEffect(() => {
|
|
128
149
|
if (!threadId) return;
|
|
129
150
|
if (foregroundSignal <= 0) return;
|
|
130
|
-
void refetch();
|
|
151
|
+
void refetch({ background: true });
|
|
131
152
|
}, [foregroundSignal, refetch, threadId]);
|
|
132
153
|
|
|
133
154
|
const messages = React.useMemo(() => {
|
|
@@ -136,7 +157,7 @@ export function useThreadMessages(threadId: string): UseThreadMessagesResult {
|
|
|
136
157
|
return resolved.map(mapMessageToChatMessage);
|
|
137
158
|
}, [raw]);
|
|
138
159
|
|
|
139
|
-
return { raw, messages, loading, error, refetch };
|
|
160
|
+
return { raw, messages, loading, refreshing, error, refetch };
|
|
140
161
|
}
|
|
141
162
|
|
|
142
163
|
|