@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.
@@ -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 { bundlePath, renderToken, loading, loadingMode, statusLabel, error, isTesting, loadBase, loadTest, restoreBase };
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 queueTop = queueItems.length > 0 ? (
135
- <ChatQueue items={queueItems} onRemove={onRemoveQueueItem} />
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…</Text>
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
  />