@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.
Files changed (32) hide show
  1. package/dist/index.d.mts +3 -1
  2. package/dist/index.d.ts +3 -1
  3. package/dist/index.js +694 -306
  4. package/dist/index.js.map +1 -1
  5. package/dist/index.mjs +721 -330
  6. package/dist/index.mjs.map +1 -1
  7. package/package.json +1 -1
  8. package/src/components/bubble/Bubble.tsx +9 -0
  9. package/src/components/bubble/types.ts +2 -0
  10. package/src/components/chat/ChatComposer.tsx +4 -21
  11. package/src/components/chat/ChatMessageBubble.tsx +33 -2
  12. package/src/components/chat/ChatMessageList.tsx +12 -1
  13. package/src/components/chat/ChatPage.tsx +8 -14
  14. package/src/components/merge-requests/ReviewMergeRequestCard.tsx +1 -1
  15. package/src/components/primitives/MarkdownText.tsx +134 -35
  16. package/src/components/studio-sheet/StudioBottomSheet.tsx +26 -29
  17. package/src/core/services/http/index.ts +64 -1
  18. package/src/core/services/supabase/realtimeManager.ts +55 -1
  19. package/src/data/agent/types.ts +1 -0
  20. package/src/data/apps/bundles/remote.ts +4 -3
  21. package/src/data/users/types.ts +1 -1
  22. package/src/index.ts +1 -0
  23. package/src/studio/ComergeStudio.tsx +6 -2
  24. package/src/studio/hooks/useApp.ts +24 -6
  25. package/src/studio/hooks/useBundleManager.ts +12 -1
  26. package/src/studio/hooks/useForegroundSignal.ts +2 -4
  27. package/src/studio/hooks/useMergeRequests.ts +6 -1
  28. package/src/studio/hooks/useOptimisticChatMessages.ts +55 -3
  29. package/src/studio/hooks/useStudioActions.ts +60 -6
  30. package/src/studio/hooks/useThreadMessages.ts +26 -5
  31. package/src/studio/ui/ChatPanel.tsx +6 -3
  32. 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) {
@@ -10,6 +10,7 @@ export type EditAgentAppRequest = {
10
10
  thread_id: string;
11
11
  app_id: string;
12
12
  attachments?: AttachmentMeta[];
13
+ idempotencyKey?: string;
13
14
  };
14
15
 
15
16
  export type AgentCreateAppResult = {
@@ -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
  }
@@ -2,7 +2,7 @@ export type UserStats = {
2
2
  userId: string;
3
3
  name: string | null;
4
4
  avatar: string | null;
5
- approvedOpenedMergeRequests: number;
5
+ approvedOrMergedMergeRequests: number;
6
6
  totalOpenedMergeRequests: number;
7
7
  };
8
8
 
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={bundle.loading}
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
- setLoading(true);
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
- setLoading(false);
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 { getSupabaseClient } from '../../core/services/supabase';
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
- const supabase = getSupabaseClient();
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(false);
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
- setOptimisticChat((prev) => [...prev, { id, content: text, createdAtIso, baseServerLastId, failed: false }]);
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
- return { messages, onSend };
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 editResult = await agentRepository.editApp({
83
- prompt,
84
- thread_id: threadId,
85
- app_id: targetApp.id,
86
- attachments: attachmentMetas && attachmentMetas.length > 0 ? attachmentMetas : undefined,
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
- setLoading(true);
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 === requestId) setLoading(false);
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