@comergehq/studio 0.1.26 → 0.1.27
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 +2 -1
- package/dist/index.d.ts +2 -1
- package/dist/index.js +844 -238
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +833 -227
- package/dist/index.mjs.map +1 -1
- package/package.json +1 -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/data/agent-progress/repository.ts +179 -0
- package/src/data/agent-progress/types.ts +67 -0
- package/src/studio/ComergeStudio.tsx +16 -1
- package/src/studio/hooks/useAgentRunProgress.ts +357 -0
- package/src/studio/hooks/useBundleManager.ts +26 -4
- package/src/studio/ui/ChatPanel.tsx +13 -2
- package/src/studio/ui/RuntimeRenderer.tsx +6 -1
- package/src/studio/ui/StudioOverlay.tsx +3 -0
|
@@ -16,6 +16,7 @@ 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 { useAgentRunProgress } from './hooks/useAgentRunProgress';
|
|
19
20
|
import { appsRepository } from '../data/apps/repository';
|
|
20
21
|
import type { SyncUpstreamStatus } from '../data/apps/types';
|
|
21
22
|
|
|
@@ -26,6 +27,7 @@ export type ComergeStudioProps = {
|
|
|
26
27
|
onNavigateHome?: () => void;
|
|
27
28
|
style?: ViewStyle;
|
|
28
29
|
showBubble?: boolean;
|
|
30
|
+
enableAgentProgress?: boolean;
|
|
29
31
|
studioControlOptions?: import('@comergehq/studio-control').StudioControlOptions;
|
|
30
32
|
embeddedBaseBundles?: EmbeddedBaseBundles;
|
|
31
33
|
};
|
|
@@ -37,6 +39,7 @@ export function ComergeStudio({
|
|
|
37
39
|
onNavigateHome,
|
|
38
40
|
style,
|
|
39
41
|
showBubble = true,
|
|
42
|
+
enableAgentProgress = true,
|
|
40
43
|
studioControlOptions,
|
|
41
44
|
embeddedBaseBundles,
|
|
42
45
|
}: ComergeStudioProps) {
|
|
@@ -72,6 +75,7 @@ export function ComergeStudio({
|
|
|
72
75
|
captureTargetRef={captureTargetRef}
|
|
73
76
|
style={style}
|
|
74
77
|
showBubble={showBubble}
|
|
78
|
+
enableAgentProgress={enableAgentProgress}
|
|
75
79
|
studioControlOptions={studioControlOptions}
|
|
76
80
|
embeddedBaseBundles={embeddedBaseBundles}
|
|
77
81
|
/>
|
|
@@ -96,6 +100,7 @@ type InnerProps = {
|
|
|
96
100
|
captureTargetRef: React.RefObject<View | null>;
|
|
97
101
|
style?: ViewStyle;
|
|
98
102
|
showBubble: boolean;
|
|
103
|
+
enableAgentProgress: boolean;
|
|
99
104
|
studioControlOptions?: import('@comergehq/studio-control').StudioControlOptions;
|
|
100
105
|
embeddedBaseBundles?: EmbeddedBaseBundles;
|
|
101
106
|
};
|
|
@@ -114,6 +119,7 @@ function ComergeStudioInner({
|
|
|
114
119
|
captureTargetRef,
|
|
115
120
|
style,
|
|
116
121
|
showBubble,
|
|
122
|
+
enableAgentProgress,
|
|
117
123
|
studioControlOptions,
|
|
118
124
|
embeddedBaseBundles,
|
|
119
125
|
}: InnerProps) {
|
|
@@ -179,6 +185,7 @@ function ComergeStudioInner({
|
|
|
179
185
|
const threadId = app?.threadId ?? '';
|
|
180
186
|
const thread = useThreadMessages(threadId);
|
|
181
187
|
const editQueue = useEditQueue(activeAppId);
|
|
188
|
+
const agentProgress = useAgentRunProgress(threadId, { enabled: enableAgentProgress });
|
|
182
189
|
const editQueueActions = useEditQueueActions(activeAppId);
|
|
183
190
|
const [lastEditQueueInfo, setLastEditQueueInfo] = React.useState<{
|
|
184
191
|
queueItemId?: string | null;
|
|
@@ -251,14 +258,20 @@ function ComergeStudioInner({
|
|
|
251
258
|
const [upstreamSyncStatus, setUpstreamSyncStatus] = React.useState<SyncUpstreamStatus | null>(null);
|
|
252
259
|
const isMrTestBuildInProgress = bundle.loading && bundle.loadingMode === 'test';
|
|
253
260
|
const isBaseBundleDownloading = bundle.loading && bundle.loadingMode === 'base' && !bundle.isTesting;
|
|
261
|
+
const runtimePreparingText =
|
|
262
|
+
bundle.bundleStatus === 'pending'
|
|
263
|
+
? 'Bundling app… this may take a few minutes'
|
|
264
|
+
: 'Preparing app…';
|
|
254
265
|
|
|
255
266
|
// Show typing dots when the last message isn't an outcome (agent still working).
|
|
256
267
|
const chatShowTypingIndicator = React.useMemo(() => {
|
|
268
|
+
if (agentProgress.hasLiveProgress) return false;
|
|
257
269
|
if (!thread.raw || thread.raw.length === 0) return false;
|
|
258
270
|
const last = thread.raw[thread.raw.length - 1];
|
|
259
271
|
const payloadType = typeof (last.payload as any)?.type === 'string' ? String((last.payload as any).type) : undefined;
|
|
260
272
|
return payloadType !== 'outcome';
|
|
261
|
-
}, [thread.raw]);
|
|
273
|
+
}, [agentProgress.hasLiveProgress, thread.raw]);
|
|
274
|
+
const showChatProgress = agentProgress.hasLiveProgress || Boolean(agentProgress.view.bundle?.active);
|
|
262
275
|
|
|
263
276
|
React.useEffect(() => {
|
|
264
277
|
updateLastEditQueueInfo(null);
|
|
@@ -311,6 +324,7 @@ function ComergeStudioInner({
|
|
|
311
324
|
<RuntimeRenderer
|
|
312
325
|
appKey={appKey}
|
|
313
326
|
bundlePath={bundle.bundlePath}
|
|
327
|
+
preparingText={runtimePreparingText}
|
|
314
328
|
forcePreparing={showPostEditPreparing}
|
|
315
329
|
renderToken={bundle.renderToken}
|
|
316
330
|
allowInitialPreparing={!embeddedBaseBundles}
|
|
@@ -377,6 +391,7 @@ function ComergeStudioInner({
|
|
|
377
391
|
onSendChat={(text, attachments) => actions.sendEdit({ prompt: text, attachments })}
|
|
378
392
|
chatQueueItems={chatQueueItems}
|
|
379
393
|
onRemoveQueueItem={(id) => editQueueActions.cancel(id)}
|
|
394
|
+
chatProgress={showChatProgress ? agentProgress.view : null}
|
|
380
395
|
onNavigateHome={onNavigateHome}
|
|
381
396
|
showBubble={showBubble}
|
|
382
397
|
studioControlOptions={studioControlOptions}
|
|
@@ -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,7 +3,7 @@ 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
8
|
|
|
9
9
|
function sleep(ms: number): Promise<void> {
|
|
@@ -74,6 +74,7 @@ export type BundleLoadState = {
|
|
|
74
74
|
renderToken: number;
|
|
75
75
|
loading: boolean;
|
|
76
76
|
loadingMode: 'base' | 'test' | null;
|
|
77
|
+
bundleStatus: BundleStatus | null;
|
|
77
78
|
statusLabel: string | null;
|
|
78
79
|
error: string | null;
|
|
79
80
|
/**
|
|
@@ -473,7 +474,8 @@ async function pollBundle(appId: string, bundleId: string, opts: { timeoutMs: nu
|
|
|
473
474
|
async function resolveBundlePath(
|
|
474
475
|
src: BundleSource,
|
|
475
476
|
platform: BundlePlatform,
|
|
476
|
-
mode: 'base' | 'test'
|
|
477
|
+
mode: 'base' | 'test',
|
|
478
|
+
onStatus?: (status: BundleStatus) => void
|
|
477
479
|
): Promise<{ bundlePath: string; label: string; bundle: Bundle }> {
|
|
478
480
|
const { appId, commitId } = src;
|
|
479
481
|
const dir = bundlesCacheDir();
|
|
@@ -489,11 +491,13 @@ async function resolveBundlePath(
|
|
|
489
491
|
},
|
|
490
492
|
{ attempts: 3, baseDelayMs: 500, maxDelayMs: 4000 }
|
|
491
493
|
);
|
|
494
|
+
onStatus?.(initiate.status);
|
|
492
495
|
|
|
493
496
|
const finalBundle =
|
|
494
497
|
initiate.status === 'succeeded' || initiate.status === 'failed'
|
|
495
498
|
? initiate
|
|
496
499
|
: await pollBundle(appId, initiate.id, { timeoutMs: 3 * 60 * 1000, intervalMs: 1200 });
|
|
500
|
+
onStatus?.(finalBundle.status);
|
|
497
501
|
|
|
498
502
|
if (finalBundle.status === 'failed') {
|
|
499
503
|
throw new Error('Bundle build failed.');
|
|
@@ -546,6 +550,7 @@ export function useBundleManager({
|
|
|
546
550
|
const [renderToken, setRenderToken] = React.useState(0);
|
|
547
551
|
const [loading, setLoading] = React.useState(false);
|
|
548
552
|
const [loadingMode, setLoadingMode] = React.useState<'base' | 'test' | null>(null);
|
|
553
|
+
const [bundleStatus, setBundleStatus] = React.useState<BundleStatus | null>(null);
|
|
549
554
|
const [statusLabel, setStatusLabel] = React.useState<string | null>(null);
|
|
550
555
|
const [error, setError] = React.useState<string | null>(null);
|
|
551
556
|
const [isTesting, setIsTesting] = React.useState(false);
|
|
@@ -678,6 +683,7 @@ export function useBundleManager({
|
|
|
678
683
|
activeLoadModeRef.current = mode;
|
|
679
684
|
setLoading(true);
|
|
680
685
|
setLoadingMode(mode);
|
|
686
|
+
setBundleStatus(null);
|
|
681
687
|
setError(null);
|
|
682
688
|
setStatusLabel(mode === 'test' ? 'Loading test bundle…' : 'Loading latest build…');
|
|
683
689
|
|
|
@@ -686,10 +692,13 @@ export function useBundleManager({
|
|
|
686
692
|
}
|
|
687
693
|
|
|
688
694
|
try {
|
|
689
|
-
const { bundlePath: path, bundle } = await resolveBundlePath(src, platform, mode)
|
|
695
|
+
const { bundlePath: path, bundle } = await resolveBundlePath(src, platform, mode, (status) => {
|
|
696
|
+
setBundleStatus(status);
|
|
697
|
+
});
|
|
690
698
|
if (mode === 'base' && opId !== baseOpIdRef.current) return;
|
|
691
699
|
if (mode === 'test' && opId !== testOpIdRef.current) return;
|
|
692
700
|
if (desiredModeRef.current !== mode) return;
|
|
701
|
+
setBundleStatus(bundle.status);
|
|
693
702
|
setBundlePath(path);
|
|
694
703
|
const fingerprint = bundle.checksumSha256 ?? `id:${bundle.id}`;
|
|
695
704
|
|
|
@@ -735,6 +744,7 @@ export function useBundleManager({
|
|
|
735
744
|
} catch (e) {
|
|
736
745
|
if (mode === 'base' && opId !== baseOpIdRef.current) return;
|
|
737
746
|
if (mode === 'test' && opId !== testOpIdRef.current) return;
|
|
747
|
+
setBundleStatus('failed');
|
|
738
748
|
const msg = e instanceof Error ? e.message : String(e);
|
|
739
749
|
setError(msg);
|
|
740
750
|
setStatusLabel(null);
|
|
@@ -772,7 +782,19 @@ export function useBundleManager({
|
|
|
772
782
|
void loadBase();
|
|
773
783
|
}, [base.appId, base.commitId, platform, canRequestLatest, loadBase]);
|
|
774
784
|
|
|
775
|
-
return {
|
|
785
|
+
return {
|
|
786
|
+
bundlePath,
|
|
787
|
+
renderToken,
|
|
788
|
+
loading,
|
|
789
|
+
loadingMode,
|
|
790
|
+
bundleStatus,
|
|
791
|
+
statusLabel,
|
|
792
|
+
error,
|
|
793
|
+
isTesting,
|
|
794
|
+
loadBase,
|
|
795
|
+
loadTest,
|
|
796
|
+
restoreBase,
|
|
797
|
+
};
|
|
776
798
|
}
|
|
777
799
|
|
|
778
800
|
|
|
@@ -12,6 +12,10 @@ import { Text } from '../../components/primitives/Text';
|
|
|
12
12
|
import type { ChatMessage } from '../../components/models/types';
|
|
13
13
|
import type { EditQueueItem } from '../../data/apps/edit-queue/types';
|
|
14
14
|
import { ChatQueue } from '../../components/chat/ChatQueue';
|
|
15
|
+
import { AgentProgressCard } from '../../components/chat/AgentProgressCard';
|
|
16
|
+
import { BundleProgressCard } from '../../components/chat/BundleProgressCard';
|
|
17
|
+
import type { AgentRunProgressView } from '../hooks/useAgentRunProgress';
|
|
18
|
+
import { useTheme } from '../../theme';
|
|
15
19
|
|
|
16
20
|
export type ChatPanelProps = {
|
|
17
21
|
title?: string;
|
|
@@ -34,6 +38,7 @@ export type ChatPanelProps = {
|
|
|
34
38
|
isRetryingMessage?: (messageId: string) => boolean;
|
|
35
39
|
queueItems?: EditQueueItem[];
|
|
36
40
|
onRemoveQueueItem?: (id: string) => void;
|
|
41
|
+
progress?: AgentRunProgressView | null;
|
|
37
42
|
};
|
|
38
43
|
|
|
39
44
|
export function ChatPanel({
|
|
@@ -57,7 +62,9 @@ export function ChatPanel({
|
|
|
57
62
|
isRetryingMessage,
|
|
58
63
|
queueItems = [],
|
|
59
64
|
onRemoveQueueItem,
|
|
65
|
+
progress = null,
|
|
60
66
|
}: ChatPanelProps) {
|
|
67
|
+
const theme = useTheme();
|
|
61
68
|
const listRef = React.useRef<ChatMessageListRef | null>(null);
|
|
62
69
|
const [nearBottom, setNearBottom] = React.useState(true);
|
|
63
70
|
|
|
@@ -131,8 +138,12 @@ export function ChatPanel({
|
|
|
131
138
|
);
|
|
132
139
|
}
|
|
133
140
|
|
|
134
|
-
const
|
|
135
|
-
|
|
141
|
+
const bundleProgress = progress?.bundle ?? null;
|
|
142
|
+
const queueTop = progress || queueItems.length > 0 ? (
|
|
143
|
+
<View style={{ gap: theme.spacing.sm }}>
|
|
144
|
+
{progress ? (bundleProgress ? <BundleProgressCard progress={bundleProgress} /> : <AgentProgressCard progress={progress} />) : null}
|
|
145
|
+
{queueItems.length > 0 ? <ChatQueue items={queueItems} onRemove={onRemoveQueueItem} /> : null}
|
|
146
|
+
</View>
|
|
136
147
|
) : null;
|
|
137
148
|
|
|
138
149
|
return (
|
|
@@ -8,6 +8,10 @@ import { Text } from '../../components/primitives/Text';
|
|
|
8
8
|
export type RuntimeRendererProps = {
|
|
9
9
|
appKey: string;
|
|
10
10
|
bundlePath: string | null;
|
|
11
|
+
/**
|
|
12
|
+
* Loading text shown while runtime cannot render a bundle yet.
|
|
13
|
+
*/
|
|
14
|
+
preparingText?: string;
|
|
11
15
|
/**
|
|
12
16
|
* When true, show the "Preparing app…" UI even if a previous bundle is available.
|
|
13
17
|
* Used to avoid briefly rendering an outdated bundle during post-edit base refresh.
|
|
@@ -28,6 +32,7 @@ export type RuntimeRendererProps = {
|
|
|
28
32
|
export function RuntimeRenderer({
|
|
29
33
|
appKey,
|
|
30
34
|
bundlePath,
|
|
35
|
+
preparingText,
|
|
31
36
|
forcePreparing,
|
|
32
37
|
renderToken,
|
|
33
38
|
style,
|
|
@@ -48,7 +53,7 @@ export function RuntimeRenderer({
|
|
|
48
53
|
|
|
49
54
|
return (
|
|
50
55
|
<View style={[{ flex: 1, justifyContent: 'center', alignItems: 'center', padding: 24 }, style]}>
|
|
51
|
-
<Text variant="bodyMuted">Preparing app
|
|
56
|
+
<Text variant="bodyMuted">{preparingText ?? 'Preparing app…'}</Text>
|
|
52
57
|
</View>
|
|
53
58
|
);
|
|
54
59
|
}
|
|
@@ -64,6 +64,7 @@ export type StudioOverlayProps = {
|
|
|
64
64
|
onSendChat: (text: string, attachments?: string[]) => void | Promise<void>;
|
|
65
65
|
chatQueueItems?: import('../../data/apps/edit-queue/types').EditQueueItem[];
|
|
66
66
|
onRemoveQueueItem?: (id: string) => void;
|
|
67
|
+
chatProgress?: import('../hooks/useAgentRunProgress').AgentRunProgressView | null;
|
|
67
68
|
|
|
68
69
|
// Navigation callbacks
|
|
69
70
|
onNavigateHome?: () => void;
|
|
@@ -105,6 +106,7 @@ export function StudioOverlay({
|
|
|
105
106
|
onSendChat,
|
|
106
107
|
chatQueueItems,
|
|
107
108
|
onRemoveQueueItem,
|
|
109
|
+
chatProgress,
|
|
108
110
|
onNavigateHome,
|
|
109
111
|
showBubble,
|
|
110
112
|
studioControlOptions,
|
|
@@ -287,6 +289,7 @@ export function StudioOverlay({
|
|
|
287
289
|
isRetryingMessage={optimistic.isRetrying}
|
|
288
290
|
queueItems={queueItemsForChat}
|
|
289
291
|
onRemoveQueueItem={onRemoveQueueItem}
|
|
292
|
+
progress={chatProgress}
|
|
290
293
|
/>
|
|
291
294
|
}
|
|
292
295
|
/>
|