@comergehq/studio 0.1.26 → 0.1.28
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 +1576 -601
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +1292 -317
- package/dist/index.mjs.map +1 -1
- package/package.json +2 -1
- package/src/components/chat/AgentProgressCard.tsx +82 -0
- package/src/components/chat/BundleProgressCard.tsx +75 -0
- package/src/components/chat/ChatMessageBubble.tsx +48 -4
- package/src/components/chat/ChatMessageList.tsx +56 -36
- package/src/components/comments/useAppComments.ts +12 -0
- package/src/data/agent-progress/repository.ts +179 -0
- package/src/data/agent-progress/types.ts +67 -0
- package/src/studio/ComergeStudio.tsx +23 -2
- package/src/studio/analytics/client.ts +103 -0
- package/src/studio/analytics/events.ts +98 -0
- package/src/studio/analytics/track.ts +237 -0
- package/src/studio/bootstrap/StudioBootstrap.tsx +8 -2
- package/src/studio/bootstrap/useStudioBootstrap.ts +18 -2
- package/src/studio/hooks/useAgentRunProgress.ts +357 -0
- package/src/studio/hooks/useAppStats.ts +14 -2
- package/src/studio/hooks/useBundleManager.ts +43 -5
- package/src/studio/hooks/useMergeRequests.ts +63 -14
- package/src/studio/hooks/useStudioActions.ts +34 -1
- package/src/studio/ui/ChatPanel.tsx +13 -2
- package/src/studio/ui/PreviewPanel.tsx +10 -0
- package/src/studio/ui/RuntimeRenderer.tsx +6 -1
- package/src/studio/ui/StudioOverlay.tsx +3 -0
- package/src/studio/ui/preview-panel/usePreviewPanelData.ts +1 -0
|
@@ -0,0 +1,357 @@
|
|
|
1
|
+
import * as React from 'react';
|
|
2
|
+
|
|
3
|
+
import { agentProgressRepository } from '../../data/agent-progress/repository';
|
|
4
|
+
import type { AgentProgressPhase, AgentRun, AgentRunEvent, AgentTodoSummary } from '../../data/agent-progress/types';
|
|
5
|
+
import { useForegroundSignal } from './useForegroundSignal';
|
|
6
|
+
|
|
7
|
+
type BundleStage = 'queued' | 'building' | 'fixing' | 'retrying' | 'finalizing' | 'ready' | 'failed';
|
|
8
|
+
|
|
9
|
+
export type AgentBundleProgressView = {
|
|
10
|
+
active: boolean;
|
|
11
|
+
status: 'loading' | 'succeeded' | 'failed';
|
|
12
|
+
phaseLabel: string;
|
|
13
|
+
progressValue: number;
|
|
14
|
+
errorMessage: string | null;
|
|
15
|
+
platform: 'ios' | 'android' | 'both';
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export type AgentRunProgressView = {
|
|
19
|
+
runId: string | null;
|
|
20
|
+
status: AgentRun['status'] | null;
|
|
21
|
+
phase: AgentProgressPhase | null;
|
|
22
|
+
latestMessage: string | null;
|
|
23
|
+
changedFilesCount: number;
|
|
24
|
+
recentFiles: string[];
|
|
25
|
+
todoSummary: AgentTodoSummary | null;
|
|
26
|
+
bundle: AgentBundleProgressView | null;
|
|
27
|
+
events: AgentRunEvent[];
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
export type UseAgentRunProgressResult = {
|
|
31
|
+
run: AgentRun | null;
|
|
32
|
+
view: AgentRunProgressView;
|
|
33
|
+
loading: boolean;
|
|
34
|
+
error: Error | null;
|
|
35
|
+
hasLiveProgress: boolean;
|
|
36
|
+
refetch: () => Promise<void>;
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
function upsertBySeq(prev: AgentRunEvent[], next: AgentRunEvent): AgentRunEvent[] {
|
|
40
|
+
const map = new Map<number, AgentRunEvent>();
|
|
41
|
+
for (const item of prev) map.set(item.seq, item);
|
|
42
|
+
map.set(next.seq, next);
|
|
43
|
+
return Array.from(map.values()).sort((a, b) => a.seq - b.seq);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function mergeMany(prev: AgentRunEvent[], incoming: AgentRunEvent[]): AgentRunEvent[] {
|
|
47
|
+
if (incoming.length === 0) return prev;
|
|
48
|
+
const map = new Map<number, AgentRunEvent>();
|
|
49
|
+
for (const item of prev) map.set(item.seq, item);
|
|
50
|
+
for (const item of incoming) map.set(item.seq, item);
|
|
51
|
+
return Array.from(map.values()).sort((a, b) => a.seq - b.seq);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function toMs(v: string | null | undefined): number {
|
|
55
|
+
if (!v) return 0;
|
|
56
|
+
const n = Date.parse(v);
|
|
57
|
+
return Number.isFinite(n) ? n : 0;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function shouldSwitchRun(current: AgentRun | null, candidate: AgentRun): boolean {
|
|
61
|
+
if (!current) return true;
|
|
62
|
+
if (candidate.id === current.id) return true;
|
|
63
|
+
return toMs(candidate.startedAt) >= toMs(current.startedAt);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function toInt(value: unknown): number {
|
|
67
|
+
const n = Number(value);
|
|
68
|
+
return Number.isFinite(n) ? n : 0;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function toBundleStage(value: unknown): BundleStage | null {
|
|
72
|
+
if (value === 'queued') return 'queued';
|
|
73
|
+
if (value === 'building') return 'building';
|
|
74
|
+
if (value === 'fixing') return 'fixing';
|
|
75
|
+
if (value === 'retrying') return 'retrying';
|
|
76
|
+
if (value === 'finalizing') return 'finalizing';
|
|
77
|
+
if (value === 'ready') return 'ready';
|
|
78
|
+
if (value === 'failed') return 'failed';
|
|
79
|
+
return null;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function toBundlePlatform(value: unknown): 'ios' | 'android' | 'both' {
|
|
83
|
+
if (value === 'ios' || value === 'android') return value;
|
|
84
|
+
return 'both';
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function clamp01(value: number): number {
|
|
88
|
+
if (value <= 0) return 0;
|
|
89
|
+
if (value >= 1) return 1;
|
|
90
|
+
return value;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function defaultBundleLabel(stage: BundleStage): string {
|
|
94
|
+
if (stage === 'queued') return 'Queued for build';
|
|
95
|
+
if (stage === 'building') return 'Building bundle';
|
|
96
|
+
if (stage === 'fixing') return 'Applying auto-fix';
|
|
97
|
+
if (stage === 'retrying') return 'Retrying bundle';
|
|
98
|
+
if (stage === 'finalizing') return 'Finalizing artifacts';
|
|
99
|
+
if (stage === 'ready') return 'Bundle ready';
|
|
100
|
+
if (stage === 'failed') return 'Bundle failed';
|
|
101
|
+
return 'Building bundle';
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function fallbackBundleProgress(stage: BundleStage, startedAtMs: number, nowMs: number): number {
|
|
105
|
+
if (stage === 'ready') return 1;
|
|
106
|
+
if (stage === 'failed') return 0.96;
|
|
107
|
+
const elapsed = Math.max(0, nowMs - startedAtMs);
|
|
108
|
+
const expectedMs = 60_000;
|
|
109
|
+
if (elapsed <= expectedMs) {
|
|
110
|
+
const t = clamp01(elapsed / expectedMs);
|
|
111
|
+
return 0.05 + 0.85 * (1 - Math.pow(1 - t, 2));
|
|
112
|
+
}
|
|
113
|
+
const over = elapsed - expectedMs;
|
|
114
|
+
return Math.min(0.9 + 0.07 * (1 - Math.exp(-over / 25_000)), 0.97);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function deriveView(run: AgentRun | null, events: AgentRunEvent[], nowMs: number): AgentRunProgressView {
|
|
118
|
+
const files: string[] = [];
|
|
119
|
+
const fileSeen = new Set<string>();
|
|
120
|
+
let todoSummary: AgentTodoSummary | null = null;
|
|
121
|
+
let latestMessage: string | null = null;
|
|
122
|
+
let phase = run?.currentPhase ?? null;
|
|
123
|
+
let bundleStage: BundleStage | null = null;
|
|
124
|
+
let bundleLabel: string | null = null;
|
|
125
|
+
let bundleError: string | null = null;
|
|
126
|
+
let bundleProgressHint: number | null = null;
|
|
127
|
+
let bundlePlatform: 'ios' | 'android' | 'both' = 'both';
|
|
128
|
+
let bundleStartedAtMs: number | null = null;
|
|
129
|
+
let lastBundleSig: string | null = null;
|
|
130
|
+
|
|
131
|
+
for (const ev of events) {
|
|
132
|
+
if (ev.eventType === 'phase_changed') {
|
|
133
|
+
if (typeof ev.payload?.message === 'string') latestMessage = ev.payload.message;
|
|
134
|
+
if (ev.phase) phase = ev.phase;
|
|
135
|
+
}
|
|
136
|
+
if (ev.eventType === 'file_changed') {
|
|
137
|
+
if (ev.path && !fileSeen.has(ev.path)) {
|
|
138
|
+
fileSeen.add(ev.path);
|
|
139
|
+
files.push(ev.path);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
if (ev.eventType === 'todo_updated') {
|
|
143
|
+
todoSummary = {
|
|
144
|
+
total: toInt(ev.payload?.total),
|
|
145
|
+
pending: toInt(ev.payload?.pending),
|
|
146
|
+
inProgress: toInt(ev.payload?.inProgress),
|
|
147
|
+
completed: toInt(ev.payload?.completed),
|
|
148
|
+
currentTask: typeof ev.payload?.currentTask === 'string' ? ev.payload.currentTask : null,
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const stageType = typeof ev.payload?.stage === 'string' ? ev.payload.stage : null;
|
|
153
|
+
if (stageType !== 'bundle') continue;
|
|
154
|
+
const nextStage = toBundleStage(ev.payload?.bundlePhase);
|
|
155
|
+
if (!nextStage) continue;
|
|
156
|
+
const nextPlatform = toBundlePlatform(ev.payload?.platform);
|
|
157
|
+
const message = typeof ev.payload?.message === 'string' ? ev.payload.message : null;
|
|
158
|
+
const phaseLabel = message || (typeof ev.payload?.message === 'string' ? ev.payload.message : null);
|
|
159
|
+
const hintRaw = Number(ev.payload?.progressHint);
|
|
160
|
+
const progressHint = Number.isFinite(hintRaw) ? clamp01(hintRaw) : null;
|
|
161
|
+
const errorText = typeof ev.payload?.error === 'string' ? ev.payload.error : null;
|
|
162
|
+
const sig = `${ev.seq}:${nextStage}:${nextPlatform}:${progressHint ?? 'none'}:${phaseLabel ?? 'none'}:${errorText ?? 'none'}`;
|
|
163
|
+
if (sig === lastBundleSig) continue;
|
|
164
|
+
lastBundleSig = sig;
|
|
165
|
+
bundleStage = nextStage;
|
|
166
|
+
bundlePlatform = nextPlatform;
|
|
167
|
+
if (phaseLabel) bundleLabel = phaseLabel;
|
|
168
|
+
if (progressHint != null) bundleProgressHint = progressHint;
|
|
169
|
+
if (errorText) bundleError = errorText;
|
|
170
|
+
const evMs = toMs(ev.createdAt);
|
|
171
|
+
if (!bundleStartedAtMs && evMs > 0) bundleStartedAtMs = evMs;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
if (!latestMessage) {
|
|
175
|
+
if (phase === 'planning') latestMessage = 'Planning changes...';
|
|
176
|
+
else if (phase === 'analyzing') latestMessage = 'Analyzing relevant files...';
|
|
177
|
+
else if (phase === 'editing') latestMessage = 'Applying code updates...';
|
|
178
|
+
else if (phase === 'validating') latestMessage = 'Validating updates...';
|
|
179
|
+
else if (phase === 'finalizing') latestMessage = 'Finalizing response...';
|
|
180
|
+
else if (phase) latestMessage = `Working (${phase})...`;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const runFinished = run?.status === 'succeeded' || run?.status === 'failed' || run?.status === 'cancelled';
|
|
184
|
+
let bundle: AgentBundleProgressView | null = null;
|
|
185
|
+
if (bundleStage && !runFinished) {
|
|
186
|
+
const baseTime = bundleStartedAtMs ?? toMs(run?.startedAt) ?? nowMs;
|
|
187
|
+
const fallback = fallbackBundleProgress(bundleStage, baseTime || nowMs, nowMs);
|
|
188
|
+
const value = bundleProgressHint != null ? Math.max(fallback, bundleProgressHint) : fallback;
|
|
189
|
+
const status = bundleStage === 'failed' ? 'failed' : bundleStage === 'ready' ? 'succeeded' : 'loading';
|
|
190
|
+
bundle = {
|
|
191
|
+
active: status === 'loading',
|
|
192
|
+
status,
|
|
193
|
+
phaseLabel: bundleLabel || defaultBundleLabel(bundleStage),
|
|
194
|
+
progressValue: clamp01(value),
|
|
195
|
+
errorMessage: bundleError,
|
|
196
|
+
platform: bundlePlatform,
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
return {
|
|
201
|
+
runId: run?.id ?? null,
|
|
202
|
+
status: run?.status ?? null,
|
|
203
|
+
phase,
|
|
204
|
+
latestMessage,
|
|
205
|
+
changedFilesCount: fileSeen.size,
|
|
206
|
+
recentFiles: files.slice(-5),
|
|
207
|
+
todoSummary,
|
|
208
|
+
bundle,
|
|
209
|
+
events,
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
export function useAgentRunProgress(threadId: string, opts?: { enabled?: boolean }): UseAgentRunProgressResult {
|
|
214
|
+
const enabled = Boolean(opts?.enabled ?? true);
|
|
215
|
+
const [run, setRun] = React.useState<AgentRun | null>(null);
|
|
216
|
+
const [events, setEvents] = React.useState<AgentRunEvent[]>([]);
|
|
217
|
+
const [loading, setLoading] = React.useState(false);
|
|
218
|
+
const [error, setError] = React.useState<Error | null>(null);
|
|
219
|
+
const activeRequestIdRef = React.useRef(0);
|
|
220
|
+
const lastSeqRef = React.useRef(0);
|
|
221
|
+
const runRef = React.useRef<AgentRun | null>(null);
|
|
222
|
+
const foregroundSignal = useForegroundSignal(Boolean(threadId) && enabled);
|
|
223
|
+
const [bundleTick, setBundleTick] = React.useState(0);
|
|
224
|
+
|
|
225
|
+
React.useEffect(() => {
|
|
226
|
+
lastSeqRef.current = 0;
|
|
227
|
+
runRef.current = null;
|
|
228
|
+
}, [threadId]);
|
|
229
|
+
|
|
230
|
+
React.useEffect(() => {
|
|
231
|
+
runRef.current = run;
|
|
232
|
+
}, [run]);
|
|
233
|
+
|
|
234
|
+
const refetch = React.useCallback(async () => {
|
|
235
|
+
if (!threadId || !enabled) {
|
|
236
|
+
setRun(null);
|
|
237
|
+
setEvents([]);
|
|
238
|
+
setLoading(false);
|
|
239
|
+
setError(null);
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
242
|
+
const requestId = ++activeRequestIdRef.current;
|
|
243
|
+
setLoading(true);
|
|
244
|
+
setError(null);
|
|
245
|
+
try {
|
|
246
|
+
const latestRun = await agentProgressRepository.getLatestRun(threadId);
|
|
247
|
+
if (activeRequestIdRef.current !== requestId) return;
|
|
248
|
+
if (!latestRun) {
|
|
249
|
+
setRun(null);
|
|
250
|
+
setEvents([]);
|
|
251
|
+
lastSeqRef.current = 0;
|
|
252
|
+
return;
|
|
253
|
+
}
|
|
254
|
+
const initialEvents = await agentProgressRepository.listEvents(latestRun.id);
|
|
255
|
+
if (activeRequestIdRef.current !== requestId) return;
|
|
256
|
+
const sorted = [...initialEvents].sort((a, b) => a.seq - b.seq);
|
|
257
|
+
setRun(latestRun);
|
|
258
|
+
setEvents(sorted);
|
|
259
|
+
lastSeqRef.current = sorted.length > 0 ? sorted[sorted.length - 1]!.seq : 0;
|
|
260
|
+
} catch (e) {
|
|
261
|
+
if (activeRequestIdRef.current !== requestId) return;
|
|
262
|
+
setError(e instanceof Error ? e : new Error(String(e)));
|
|
263
|
+
setRun(null);
|
|
264
|
+
setEvents([]);
|
|
265
|
+
lastSeqRef.current = 0;
|
|
266
|
+
} finally {
|
|
267
|
+
if (activeRequestIdRef.current === requestId) setLoading(false);
|
|
268
|
+
}
|
|
269
|
+
}, [enabled, threadId]);
|
|
270
|
+
|
|
271
|
+
React.useEffect(() => {
|
|
272
|
+
void refetch();
|
|
273
|
+
}, [refetch]);
|
|
274
|
+
|
|
275
|
+
React.useEffect(() => {
|
|
276
|
+
if (!threadId || !enabled) return;
|
|
277
|
+
if (foregroundSignal <= 0) return;
|
|
278
|
+
void refetch();
|
|
279
|
+
}, [enabled, foregroundSignal, refetch, threadId]);
|
|
280
|
+
|
|
281
|
+
React.useEffect(() => {
|
|
282
|
+
if (!threadId || !enabled) return;
|
|
283
|
+
const unsubRuns = agentProgressRepository.subscribeThreadRuns(threadId, {
|
|
284
|
+
onInsert: (nextRun) => {
|
|
285
|
+
const currentRun = runRef.current;
|
|
286
|
+
if (!shouldSwitchRun(currentRun, nextRun)) return;
|
|
287
|
+
setRun(nextRun);
|
|
288
|
+
runRef.current = nextRun;
|
|
289
|
+
if (!currentRun || currentRun.id !== nextRun.id) {
|
|
290
|
+
lastSeqRef.current = 0;
|
|
291
|
+
setEvents([]);
|
|
292
|
+
void agentProgressRepository
|
|
293
|
+
.listEvents(nextRun.id)
|
|
294
|
+
.then((initial) => {
|
|
295
|
+
if (runRef.current?.id !== nextRun.id) return;
|
|
296
|
+
setEvents((prev) => mergeMany(prev, initial));
|
|
297
|
+
const maxSeq = initial.length > 0 ? initial[initial.length - 1]!.seq : 0;
|
|
298
|
+
if (maxSeq > lastSeqRef.current) lastSeqRef.current = maxSeq;
|
|
299
|
+
})
|
|
300
|
+
.catch(() => {});
|
|
301
|
+
}
|
|
302
|
+
},
|
|
303
|
+
onUpdate: (nextRun) => {
|
|
304
|
+
const currentRun = runRef.current;
|
|
305
|
+
if (!shouldSwitchRun(currentRun, nextRun)) return;
|
|
306
|
+
setRun(nextRun);
|
|
307
|
+
runRef.current = nextRun;
|
|
308
|
+
},
|
|
309
|
+
});
|
|
310
|
+
return unsubRuns;
|
|
311
|
+
}, [enabled, threadId, foregroundSignal]);
|
|
312
|
+
|
|
313
|
+
React.useEffect(() => {
|
|
314
|
+
if (!enabled || !run?.id) return;
|
|
315
|
+
const runId = run.id;
|
|
316
|
+
const processIncoming = (incoming: AgentRunEvent) => {
|
|
317
|
+
if (runRef.current?.id !== runId) return;
|
|
318
|
+
setEvents((prev) => upsertBySeq(prev, incoming));
|
|
319
|
+
if (incoming.seq > lastSeqRef.current) {
|
|
320
|
+
const expectedNext = lastSeqRef.current + 1;
|
|
321
|
+
const seenSeq = incoming.seq;
|
|
322
|
+
const currentLast = lastSeqRef.current;
|
|
323
|
+
lastSeqRef.current = seenSeq;
|
|
324
|
+
if (seenSeq > expectedNext) {
|
|
325
|
+
void agentProgressRepository
|
|
326
|
+
.listEvents(runId, currentLast)
|
|
327
|
+
.then((missing) => {
|
|
328
|
+
if (runRef.current?.id !== runId) return;
|
|
329
|
+
setEvents((prev) => mergeMany(prev, missing));
|
|
330
|
+
if (missing.length > 0) {
|
|
331
|
+
const maxSeq = missing[missing.length - 1]!.seq;
|
|
332
|
+
if (maxSeq > lastSeqRef.current) lastSeqRef.current = maxSeq;
|
|
333
|
+
}
|
|
334
|
+
})
|
|
335
|
+
.catch(() => {});
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
};
|
|
339
|
+
const unsubscribe = agentProgressRepository.subscribeRunEvents(runId, {
|
|
340
|
+
onInsert: processIncoming,
|
|
341
|
+
onUpdate: processIncoming,
|
|
342
|
+
});
|
|
343
|
+
return unsubscribe;
|
|
344
|
+
}, [enabled, run?.id, foregroundSignal]);
|
|
345
|
+
|
|
346
|
+
const view = React.useMemo(() => deriveView(run, events, Date.now()), [bundleTick, events, run]);
|
|
347
|
+
React.useEffect(() => {
|
|
348
|
+
if (!view.bundle?.active) return;
|
|
349
|
+
const interval = setInterval(() => {
|
|
350
|
+
setBundleTick((v) => v + 1);
|
|
351
|
+
}, 300);
|
|
352
|
+
return () => clearInterval(interval);
|
|
353
|
+
}, [view.bundle?.active]);
|
|
354
|
+
const hasLiveProgress = Boolean(run) && run?.status === 'running';
|
|
355
|
+
return { run, view, loading, error, hasLiveProgress, refetch };
|
|
356
|
+
}
|
|
357
|
+
|
|
@@ -3,6 +3,8 @@ import * as Haptics from 'expo-haptics';
|
|
|
3
3
|
|
|
4
4
|
import type { App } from '../../data/apps/types';
|
|
5
5
|
import { appLikesRepository } from '../../data/likes/repository';
|
|
6
|
+
import type { InteractionSource } from '../analytics/events';
|
|
7
|
+
import { trackLikeApp, trackOpenComments, trackUnlikeApp } from '../analytics/track';
|
|
6
8
|
|
|
7
9
|
export type UseAppStatsParams = {
|
|
8
10
|
appId: string;
|
|
@@ -11,6 +13,7 @@ export type UseAppStatsParams = {
|
|
|
11
13
|
initialForks?: number;
|
|
12
14
|
initialIsLiked?: boolean;
|
|
13
15
|
onOpenComments?: () => void;
|
|
16
|
+
interactionSource?: InteractionSource;
|
|
14
17
|
};
|
|
15
18
|
|
|
16
19
|
export type AppStatsResult = {
|
|
@@ -30,6 +33,7 @@ export function useAppStats({
|
|
|
30
33
|
initialForks = 0,
|
|
31
34
|
initialIsLiked = false,
|
|
32
35
|
onOpenComments,
|
|
36
|
+
interactionSource = 'unknown',
|
|
33
37
|
}: UseAppStatsParams): AppStatsResult {
|
|
34
38
|
const [likeCount, setLikeCount] = React.useState(initialLikes);
|
|
35
39
|
const [commentCount, setCommentCount] = React.useState(initialComments);
|
|
@@ -77,15 +81,22 @@ export function useAppStats({
|
|
|
77
81
|
if (newIsLiked) {
|
|
78
82
|
const res = await appLikesRepository.create(appId, {});
|
|
79
83
|
if (typeof res.stats?.total === 'number') setLikeCount(Math.max(0, res.stats.total));
|
|
84
|
+
await trackLikeApp({ appId, source: interactionSource, success: true });
|
|
80
85
|
} else {
|
|
81
86
|
const res = await appLikesRepository.removeMine(appId);
|
|
82
87
|
if (typeof res.stats?.total === 'number') setLikeCount(Math.max(0, res.stats.total));
|
|
88
|
+
await trackUnlikeApp({ appId, source: interactionSource, success: true });
|
|
83
89
|
}
|
|
84
90
|
} catch (e) {
|
|
85
91
|
setIsLiked(!newIsLiked);
|
|
86
92
|
setLikeCount((prev) => Math.max(0, prev + (newIsLiked ? -1 : 1)));
|
|
93
|
+
if (newIsLiked) {
|
|
94
|
+
await trackLikeApp({ appId, source: interactionSource, success: false, error: e });
|
|
95
|
+
} else {
|
|
96
|
+
await trackUnlikeApp({ appId, source: interactionSource, success: false, error: e });
|
|
97
|
+
}
|
|
87
98
|
}
|
|
88
|
-
}, [appId, isLiked, likeCount]);
|
|
99
|
+
}, [appId, interactionSource, isLiked, likeCount]);
|
|
89
100
|
|
|
90
101
|
const handleOpenComments = React.useCallback(() => {
|
|
91
102
|
if (!appId) return;
|
|
@@ -93,8 +104,9 @@ export function useAppStats({
|
|
|
93
104
|
void Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
|
94
105
|
} catch {
|
|
95
106
|
}
|
|
107
|
+
void trackOpenComments({ appId, source: interactionSource });
|
|
96
108
|
onOpenComments?.();
|
|
97
|
-
}, [appId, onOpenComments]);
|
|
109
|
+
}, [appId, interactionSource, onOpenComments]);
|
|
98
110
|
|
|
99
111
|
return { likeCount, commentCount, forkCount, isLiked, setCommentCount, handleLike, handleOpenComments };
|
|
100
112
|
}
|
|
@@ -3,8 +3,9 @@ import * as FileSystem from 'expo-file-system/legacy';
|
|
|
3
3
|
import { Asset } from 'expo-asset';
|
|
4
4
|
import { unzip } from 'react-native-zip-archive';
|
|
5
5
|
|
|
6
|
-
import type { Platform as BundlePlatform, Bundle, BundleAsset } from '../../data/apps/bundles/types';
|
|
6
|
+
import type { Platform as BundlePlatform, Bundle, BundleAsset, BundleStatus } from '../../data/apps/bundles/types';
|
|
7
7
|
import { bundlesRepository } from '../../data/apps/bundles/repository';
|
|
8
|
+
import { trackTestBundle } from '../analytics/track';
|
|
8
9
|
|
|
9
10
|
function sleep(ms: number): Promise<void> {
|
|
10
11
|
return new Promise((r) => setTimeout(r, ms));
|
|
@@ -74,6 +75,7 @@ export type BundleLoadState = {
|
|
|
74
75
|
renderToken: number;
|
|
75
76
|
loading: boolean;
|
|
76
77
|
loadingMode: 'base' | 'test' | null;
|
|
78
|
+
bundleStatus: BundleStatus | null;
|
|
77
79
|
statusLabel: string | null;
|
|
78
80
|
error: string | null;
|
|
79
81
|
/**
|
|
@@ -473,7 +475,8 @@ async function pollBundle(appId: string, bundleId: string, opts: { timeoutMs: nu
|
|
|
473
475
|
async function resolveBundlePath(
|
|
474
476
|
src: BundleSource,
|
|
475
477
|
platform: BundlePlatform,
|
|
476
|
-
mode: 'base' | 'test'
|
|
478
|
+
mode: 'base' | 'test',
|
|
479
|
+
onStatus?: (status: BundleStatus) => void
|
|
477
480
|
): Promise<{ bundlePath: string; label: string; bundle: Bundle }> {
|
|
478
481
|
const { appId, commitId } = src;
|
|
479
482
|
const dir = bundlesCacheDir();
|
|
@@ -489,11 +492,13 @@ async function resolveBundlePath(
|
|
|
489
492
|
},
|
|
490
493
|
{ attempts: 3, baseDelayMs: 500, maxDelayMs: 4000 }
|
|
491
494
|
);
|
|
495
|
+
onStatus?.(initiate.status);
|
|
492
496
|
|
|
493
497
|
const finalBundle =
|
|
494
498
|
initiate.status === 'succeeded' || initiate.status === 'failed'
|
|
495
499
|
? initiate
|
|
496
500
|
: await pollBundle(appId, initiate.id, { timeoutMs: 3 * 60 * 1000, intervalMs: 1200 });
|
|
501
|
+
onStatus?.(finalBundle.status);
|
|
497
502
|
|
|
498
503
|
if (finalBundle.status === 'failed') {
|
|
499
504
|
throw new Error('Bundle build failed.');
|
|
@@ -546,6 +551,7 @@ export function useBundleManager({
|
|
|
546
551
|
const [renderToken, setRenderToken] = React.useState(0);
|
|
547
552
|
const [loading, setLoading] = React.useState(false);
|
|
548
553
|
const [loadingMode, setLoadingMode] = React.useState<'base' | 'test' | null>(null);
|
|
554
|
+
const [bundleStatus, setBundleStatus] = React.useState<BundleStatus | null>(null);
|
|
549
555
|
const [statusLabel, setStatusLabel] = React.useState<string | null>(null);
|
|
550
556
|
const [error, setError] = React.useState<string | null>(null);
|
|
551
557
|
const [isTesting, setIsTesting] = React.useState(false);
|
|
@@ -678,6 +684,7 @@ export function useBundleManager({
|
|
|
678
684
|
activeLoadModeRef.current = mode;
|
|
679
685
|
setLoading(true);
|
|
680
686
|
setLoadingMode(mode);
|
|
687
|
+
setBundleStatus(null);
|
|
681
688
|
setError(null);
|
|
682
689
|
setStatusLabel(mode === 'test' ? 'Loading test bundle…' : 'Loading latest build…');
|
|
683
690
|
|
|
@@ -686,10 +693,13 @@ export function useBundleManager({
|
|
|
686
693
|
}
|
|
687
694
|
|
|
688
695
|
try {
|
|
689
|
-
const { bundlePath: path, bundle } = await resolveBundlePath(src, platform, mode)
|
|
696
|
+
const { bundlePath: path, bundle } = await resolveBundlePath(src, platform, mode, (status) => {
|
|
697
|
+
setBundleStatus(status);
|
|
698
|
+
});
|
|
690
699
|
if (mode === 'base' && opId !== baseOpIdRef.current) return;
|
|
691
700
|
if (mode === 'test' && opId !== testOpIdRef.current) return;
|
|
692
701
|
if (desiredModeRef.current !== mode) return;
|
|
702
|
+
setBundleStatus(bundle.status);
|
|
693
703
|
setBundlePath(path);
|
|
694
704
|
const fingerprint = bundle.checksumSha256 ?? `id:${bundle.id}`;
|
|
695
705
|
|
|
@@ -735,6 +745,7 @@ export function useBundleManager({
|
|
|
735
745
|
} catch (e) {
|
|
736
746
|
if (mode === 'base' && opId !== baseOpIdRef.current) return;
|
|
737
747
|
if (mode === 'test' && opId !== testOpIdRef.current) return;
|
|
748
|
+
setBundleStatus('failed');
|
|
738
749
|
const msg = e instanceof Error ? e.message : String(e);
|
|
739
750
|
setError(msg);
|
|
740
751
|
setStatusLabel(null);
|
|
@@ -752,7 +763,22 @@ export function useBundleManager({
|
|
|
752
763
|
}, [load]);
|
|
753
764
|
|
|
754
765
|
const loadTest = React.useCallback(async (src: BundleSource) => {
|
|
755
|
-
|
|
766
|
+
try {
|
|
767
|
+
await load(src, 'test');
|
|
768
|
+
await trackTestBundle({
|
|
769
|
+
appId: src.appId,
|
|
770
|
+
commitId: src.commitId ?? undefined,
|
|
771
|
+
success: true,
|
|
772
|
+
});
|
|
773
|
+
} catch (error) {
|
|
774
|
+
await trackTestBundle({
|
|
775
|
+
appId: src.appId,
|
|
776
|
+
commitId: src.commitId ?? undefined,
|
|
777
|
+
success: false,
|
|
778
|
+
error,
|
|
779
|
+
});
|
|
780
|
+
throw error;
|
|
781
|
+
}
|
|
756
782
|
}, [load]);
|
|
757
783
|
|
|
758
784
|
const restoreBase = React.useCallback(async () => {
|
|
@@ -772,7 +798,19 @@ export function useBundleManager({
|
|
|
772
798
|
void loadBase();
|
|
773
799
|
}, [base.appId, base.commitId, platform, canRequestLatest, loadBase]);
|
|
774
800
|
|
|
775
|
-
return {
|
|
801
|
+
return {
|
|
802
|
+
bundlePath,
|
|
803
|
+
renderToken,
|
|
804
|
+
loading,
|
|
805
|
+
loadingMode,
|
|
806
|
+
bundleStatus,
|
|
807
|
+
statusLabel,
|
|
808
|
+
error,
|
|
809
|
+
isTesting,
|
|
810
|
+
loadBase,
|
|
811
|
+
loadTest,
|
|
812
|
+
restoreBase,
|
|
813
|
+
};
|
|
776
814
|
}
|
|
777
815
|
|
|
778
816
|
|
|
@@ -5,6 +5,11 @@ import { mergeRequestsRepository } from '../../data/merge-requests/repository';
|
|
|
5
5
|
import type { MergeRequestSummary } from '../../components/models/types';
|
|
6
6
|
import { usersRepository } from '../../data/users/repository';
|
|
7
7
|
import type { UserStats } from '../../data/users/types';
|
|
8
|
+
import {
|
|
9
|
+
trackApproveMergeRequest,
|
|
10
|
+
trackOpenMergeRequest,
|
|
11
|
+
trackRejectMergeRequest,
|
|
12
|
+
} from '../analytics/track';
|
|
8
13
|
|
|
9
14
|
export type MergeRequestLists = {
|
|
10
15
|
/**
|
|
@@ -118,27 +123,71 @@ export function useMergeRequests(params: { appId: string }): UseMergeRequestsRes
|
|
|
118
123
|
|
|
119
124
|
React.useEffect(() => {
|
|
120
125
|
void refresh();
|
|
121
|
-
}, [refresh]);
|
|
126
|
+
}, [appId, refresh]);
|
|
122
127
|
|
|
123
128
|
const openMergeRequest = React.useCallback(async (sourceAppId: string) => {
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
129
|
+
try {
|
|
130
|
+
const mr = await mergeRequestsRepository.open({ sourceAppId });
|
|
131
|
+
await refresh();
|
|
132
|
+
await trackOpenMergeRequest({
|
|
133
|
+
appId,
|
|
134
|
+
mergeRequestId: mr.id,
|
|
135
|
+
success: true,
|
|
136
|
+
});
|
|
137
|
+
return mr;
|
|
138
|
+
} catch (error) {
|
|
139
|
+
await trackOpenMergeRequest({
|
|
140
|
+
appId,
|
|
141
|
+
success: false,
|
|
142
|
+
error,
|
|
143
|
+
});
|
|
144
|
+
throw error;
|
|
145
|
+
}
|
|
127
146
|
}, [refresh]);
|
|
128
147
|
|
|
129
148
|
const approve = React.useCallback(async (mrId: string) => {
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
149
|
+
try {
|
|
150
|
+
const mr = await mergeRequestsRepository.update(mrId, { status: 'approved' });
|
|
151
|
+
await refresh();
|
|
152
|
+
const merged = await pollUntilMerged(mrId);
|
|
153
|
+
await refresh();
|
|
154
|
+
await trackApproveMergeRequest({
|
|
155
|
+
appId,
|
|
156
|
+
mergeRequestId: mrId,
|
|
157
|
+
success: true,
|
|
158
|
+
});
|
|
159
|
+
return merged ?? mr;
|
|
160
|
+
} catch (error) {
|
|
161
|
+
await trackApproveMergeRequest({
|
|
162
|
+
appId,
|
|
163
|
+
mergeRequestId: mrId,
|
|
164
|
+
success: false,
|
|
165
|
+
error,
|
|
166
|
+
});
|
|
167
|
+
throw error;
|
|
168
|
+
}
|
|
169
|
+
}, [appId, pollUntilMerged, refresh]);
|
|
136
170
|
|
|
137
171
|
const reject = React.useCallback(async (mrId: string) => {
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
172
|
+
try {
|
|
173
|
+
const mr = await mergeRequestsRepository.update(mrId, { status: 'rejected' });
|
|
174
|
+
await refresh();
|
|
175
|
+
await trackRejectMergeRequest({
|
|
176
|
+
appId,
|
|
177
|
+
mergeRequestId: mrId,
|
|
178
|
+
success: true,
|
|
179
|
+
});
|
|
180
|
+
return mr;
|
|
181
|
+
} catch (error) {
|
|
182
|
+
await trackRejectMergeRequest({
|
|
183
|
+
appId,
|
|
184
|
+
mergeRequestId: mrId,
|
|
185
|
+
success: false,
|
|
186
|
+
error,
|
|
187
|
+
});
|
|
188
|
+
throw error;
|
|
189
|
+
}
|
|
190
|
+
}, [appId, refresh]);
|
|
142
191
|
|
|
143
192
|
const toSummary = React.useCallback((mr: MergeRequest): MergeRequestSummary => {
|
|
144
193
|
const stats = creatorStatsById[mr.createdBy];
|